Prueba un mod
Escribe pruebas automatizadas para un mod de Claude Code que generen eventos, simulen las respuestas de Claude Code y presionen botones, sin sesión, inicio de sesión ni red.
Puedes escribir pruebas automatizadas para un mod y ejecutarlas desde tu shell con claude plugin test. Una prueba genera los eventos que tus hooks manejan y verifica qué hicieron los hooks, para que detectes un problema antes de que llegue a una sesión. El primer ejemplo prueba el mod de Crear un mod.
Escribe una prueba
Una prueba carga tu mod, envía eventos a través de sus hooks de la manera que lo haría Claude Code, y verifica qué hicieron los hooks, sin una sesión, un inicio de sesión ni una red. Ejecutas las pruebas desde tu shell con claude plugin test, y cada archivo de prueba importa el kit de pruebas, una biblioteca de pruebas en el módulo claude-code/testing.
Dale a cada archivo de prueba un nombre que termine en .test.ts, como first-mod.test.ts, y guárdalo en cualquier lugar del directorio del plugin. Cada archivo de prueba necesita al menos una test(), o la ejecución falla con declares no test(): nothing ran. Un archivo de prueba puede importar los propios archivos de tu mod y helpers .ts hermanos, para que puedas hacer pruebas unitarias de funciones simples, como las reglas de un juego, sin el kit.
Esta prueba genera dos llamadas de herramientas, ejecuta el comando /tally de Crear un mod, y verifica que la respuesta cuente ambas. Su primera línea es un stub, que responde las llamadas de herramientas en lugar de Claude Code. Guárdalo como first-mod/tests/first-mod.test.ts:
import { expect, test } from 'claude-code/testing'
test('/tally reports the tool calls the mod has seen', async ($, on) => {
// Answer each tool call in Claude Code's place, so no tool runs
on('tool.call', () => ({ result: 'ok' }))
// Raise two tool calls, which the mod's tool.call hook counts
await $.tool.call({ tool: 'Bash', command: 'ls' })
await $.tool.call({ tool: 'Read', file_path: 'README.md' })
// Run /tally and check the text its hook returns
const answer = await $.command.run({ command: 'tally', args: '' })
expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})
En tu shell, ejecuta las pruebas desde el directorio first-mod:
claude plugin test
La salida nombra cada prueba y si pasó, con tiempos que varían de una ejecución a otra:
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [22.87ms]
1 pass
0 fail
Ran 1 test across 1 file. [0.19s]
Cada $.tool.call pasó a través del hook tool.call del mod, que agregó uno a su contador y pasó la llamada al stub. No se ejecutó ls y no se leyó ningún archivo. $.command.run luego fue al hook command.run del mod, y answer es el objeto que ese hook devolvió.
El comando sale con estado 1 cuando una prueba falla, por lo que funciona en CI. Si tus propios mods no pueden cargarse en el shell que lo ejecuta, imprime una línea que comienza con claude plugin test: hooks modules are turned off con la razón, y sale con estado 1.
Simula lo que Claude Code respondería
Ningún modelo, almacén o herramienta se ejecuta en una prueba, por lo que dondequiera que tu mod espere que Claude Code responda, la prueba proporciona la respuesta con un stub. Una función de prueba recibe dos argumentos para eso:
$: el$propio de la prueba, que se sitúa donde Claude Code lo hace. No es la API de mods que recibe un hook. Cada uno de sus métodos genera el evento del mismo nombre, lo envía a través de los hooks de tu mod, y se resuelve al resultado:$.tool.call({ tool: 'Bash', command: 'ls' })generatool.call.$.command.run,$.prompt.submit,$.session.start, y$.turn.completefuncionan de la misma manera, y$.classic.Stopy los otros métodos$.classicgeneran un evento de hook de configuración. Una prueba no puede generar directamente una llamada de API de mods comoui.close. Actívala a través de tu mod, por ejemplo presionando el botón que cierra el panel.on: llámalo para registrar stubs, que son hooks que responden en lugar de Claude Code. Nombra un stub para una llamada de API de mods sin el$., por lo que un stub registrado comostore.getresponde a$.store.getde tu mod. Cuando tu mod llama a$.model.completeo$.store.get, un stub proporciona la respuesta.
Este ejemplo simula una llamada de modelo. El hook pertenece a un mod llamado grader, y maneja un comando /grade que envía una oración a un modelo e informa si la respuesta comienza con PASS. El archivo contiene solo el hook bajo prueba, por lo que el mod también necesita un plugin.json y un hooks.json, como en Crear un mod. Para escribir /grade en una sesión, el mod también tiene que registrar el comando:
export function register(on) {
on('command.run', { command: 'grade' }, async ($, e) => {
// e.args is the text typed after /grade
const reply = await $.model.complete({
model: 'haiku',
system: 'Grade the sentence. Start your reply with PASS or FAIL.',
prompt: e.args,
})
const passed = reply.isAnswered && reply.text.startsWith('PASS')
return { text: passed ? 'Passed' : 'Try again' }
})
}
Esta prueba simula la llamada del modelo para verificar qué hace el hook con una respuesta aprobada:
import { expect, test } from 'claude-code/testing'
test('a passing grade is reported', async ($, on) => {
// Answer the mod's $.model.complete call with a fixed reply, so no model runs
on('model.complete', () => ({
value: {
isAnswered: true,
text: 'PASS\nNice sentence.',
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
},
}))
// Run /grade, which makes the mod call the model
const answer = await $.command.run({ command: 'grade', args: 'The cat sat on the mat.' })
expect(answer.text).toBe('Passed')
})
La prueba pasa porque el reply del hook es el objeto bajo value, cuyo text comienza con PASS. Para verificar la otra rama, agrega una segunda prueba cuyo stub devuelva un text que comience con FAIL, y espera Try again.
Un stub para una llamada de API de mods devuelve un objeto con un campo value, que contiene lo que la llamada se resuelve en tu mod: { value: 7 } hace que $.store.get se resuelva a 7. Un stub para uno de los eventos de Claude Code, como turn.step o tool.call, devuelve el resultado propio de ese evento, como { result: 'ok' }. $.session.send y $.prompt.fill también toman el resultado del evento, como muestra la tabla. Busca qué devuelve un stub muestra qué forma toma cada nombre común. Dos errores significan que un stub es incorrecto o falta. La salida de una prueba fallida incluye un bloque encabezado the engine reported:, y cada error aparece allí:
returned neither { value } nor { deny }: un stub para una llamada de API de mods devolvió un valor simpleno implementation forseguido de un nombre: tu mod hizo esa llamada y ningún stub la responde
El kit también exporta mocks en memoria que responden un espacio de nombres completo por ti. mock.clock(on) responde $.clock, mock.store(on, { count: 7 }) responde $.store desde un almacén que comienza con esas entradas, y mock.env(on, { CI: 'true' }) responde $.env.get desde esas variables. mock.clock devuelve un reloj simulado que tu prueba avanza, por lo que una prueba de un temporizador no espera. mock.store no devuelve nada, por lo que para verificar qué guardó tu mod, escribe los dos stubs store tú mismo como lo hace la prueba de dibujo.
Sigue las reglas del kit de pruebas
El kit de pruebas tiene algunas reglas propias, y romper una produce los errores que los nuevos autores de pruebas encuentran primero:
-
Registra cada stub antes de la primera llamada de la prueba a
$. Llamar aondespués de eso lanza un error comoon("ui.render") after the test first called $. -
session.startno se ejecuta por sí solo. Cada prueba comienza con tu módulo recién cargado y ninguno de sus hooks llamado, por lo que las variables a nivel de módulo mantienen sus valores iniciales. Si un hook depende de lo quesession.startconfigura, genéralo primero:// Answer the event after your hook passes it on with next(e) on('session.start', () => ({ cwd: '/work' })) // Answer the $.command.register call your hook makes on('command.register', () => ({ value: undefined })) // Raise the event, which runs your session.start hook await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })El segundo stub responde la llamada
$.command.registerque hace un hooksession.startcomo el del tutorial. Sin él, esa llamada rechaza conno implementation for command.registery el kit omite tu hook, por lo que nada después de la llamada en el hook se ejecuta. La prueba no falla en ese punto. El hook omitido se enumera bajothe engine reported:solo si una verificación posterior falla. -
Un hook que devuelve
next(e)necesita un stub para responder. Cuando tu hookui.renderdevuelvenext(e), por ejemplo para no dibujar nada mientras Claude está inactivo, montarlo falla conno implementation for ui.render. Registra un stub que devuelva un elemento como datos simples:// Stands for what Claude Code would draw at the site on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))Con el stub registrado, el montaje tiene éxito, y
ui.find({ type: 'Text' })devuelve ese elemento siempre que tu hook devolviónext(e). -
Un stub para
turn.stepes un generador asincrónico, y la prueba lee el flujo hasta su fin para obtener el resultado:on('turn.step', async function* ($, e) { // Each yield is one piece of the model's streamed reply yield { kind: 'text', index: 0, text: 'ok' } // The return value is the result of the whole request return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: null } }) // Raise one request to the model, which runs your turn.step hook const stream = $.turn.step({ turnId: 't', index: 0, model: 'claude-test', messageCount: 1 }) // Read every piece until the stream says it's done let step = await stream.next() while (step.done !== true) step = await stream.next() const result = step.valueCuando el bucle termina,
resultes el objeto que el stub devolvió, después de que tu hookturn.stephaya tenido la oportunidad de cambiarlo. Aquíresult.answeres'ok'. -
Genera una llamada de herramienta con el nombre de la herramienta y los argumentos como campos, como
await $.tool.call({ tool: 'Bash', command: 'ls' }), y registra un stubtool.callque devuelva{ result }.
Busca qué devuelve un stub
Cada llamada de API de mods que tu mod hace en una prueba necesita un stub que responda en lugar de Claude Code, excepto las pocas que el kit responde por sí solo: llamadas $.ui.invalidate y $.state. Para llamadas $.clock, usa mock.clock(on), o $.clock.now() de tu mod falla con no implementation for clock.now.
Esta tabla enumera las que los mods usan más. La primera columna es la llamada que tu mod hace o el evento que pasa con next(e). La segunda es la función a pasar a on bajo ese nombre, por lo que la fila $.store.get se convierte en on('store.get', ($, e) => ({ value: saved.get(e.key) })). Un '...' en un stub marca texto para que lo completes:
| Tu mod llama o pasa | Stub |
|---|---|
$.command.register, $.tool.register, $.ui.toast, $.ui.log, $.ui.status, $.ui.close, $.store.set |
() => ({ value: undefined }). Para ui.toast y ui.log, el texto es e.text. |
$.store.get |
($, e) => ({ value: saved.get(e.key) }) |
$.fs.read |
($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }). e.path llega como una ruta absoluta, así que compara con endsWith. |
$.ui.open |
() => ({ value: { isPlaced: true } }) |
$.ui.ask |
Un stub tool.call, porque la pregunta la alcanza como una llamada a la herramienta AskUserQuestion: ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). Verifica e.tool primero si tu mod pasa otras llamadas de herramientas. |
$.model.complete |
() => ({ value: { isAnswered: true, text: '...', usage } }) |
$.process.run |
($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } }). e.argv es la lista de argumentos y e.init contiene cwd y timeoutMs. |
| Cualquier llamada de API de mods que debería fallar | () => ({ deny: 'the reason' }), que hace que la llamada rechace en tu mod. Un stub que lanza se omite en su lugar. |
session.start |
() => ({ cwd: '/work' }) |
turn.start |
($, e) => ({ turnId: e.turnId }) |
tool.call |
() => ({ result: '...' }) |
turn.complete |
() => ({ text: '' }). Genéralo con $.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null }). |
prompt.submit |
($, e) => ({ text: e.text }) |
prompt.fill |
() => ({ isFilled: true }) |
$.prompt.read |
() => ({ value: { text: '...', cursor: 0 } }) |
$.ui.copy |
() => ({ value: { isCopied: true } }) |
$.session.messages |
() => ({ value: [{ role: 'assistant', text: '...', toolUses: [] }] }) |
$.session.id, $.agent.list |
() => ({ value: 'abc123' }), () => ({ value: [] }) |
session.send |
() => ({ isDelivered: true }). e.to llega como una cadena incluso cuando tu mod pasó { sessionId }. |
session.receive |
($, e) => ({ text: e.text }). Genéralo con $.session.receive({ origin: { kind: 'peer-send-message' }, text }). |
ui.render |
() => ({ type: 'Text', props: {}, children: ['...'] }) |
expect tiene las aserciones toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined, y toThrow, y .not antes de cualquiera de ellas.
Prueba un temporizador
Un mod que ejecuta trabajo en un temporizador necesita un reloj que la prueba controle, para que la prueba pueda avanzar el tiempo en lugar de esperar. const clock = mock.clock(on) devuelve un reloj simulado que comienza en 0 y se mueve solo cuando tu prueba lo mueve. Para comenzar en otro momento, pásalo en milisegundos, como en mock.clock(on, { now: 5000 }). El reloj tiene estos métodos:
| Método | Lo que hace |
|---|---|
await clock.advance(1000) |
Avanza el tiempo por esa cantidad de milisegundos y ejecuta cada temporizador que vence |
await clock.set(5000) |
Avanza el tiempo a ese valor, como lo haría advance |
clock.now() |
Devuelve el tiempo, que es lo que $.clock.now() de tu mod se resuelve a |
await clock.settle() |
Ejecuta temporizadores que ya vencen, como una cadena de llamadas $.clock.after de cero retrasos, sin mover el tiempo |
await clock.sleep(2000) |
Dentro de un stub, hace que ese stub responda solo una vez que la prueba haya avanzado tan lejos, que es cómo simulas un modelo o proceso lento |
Este hook pertenece a un mod llamado countdown, y maneja un comando /countdown que toma un número de segundos, inicia un temporizador $.clock.every de un segundo, y muestra un toast en cero. Como con grader, el archivo contiene solo el hook bajo prueba y no registra el comando:
export function register(on) {
on('command.run', { command: 'countdown' }, async ($, e) => {
// e.args is the text typed after /countdown
let left = Number(e.args)
const timer = $.clock.every(1000, () => {
left -= 1
if (left === 0) {
timer.cancel()
$.ui.toast('Time is up')
}
})
// Print nothing in the transcript
return {}
})
}
Esta prueba ejecuta /countdown 3 y mueve el reloj simulado, por lo que verifica tres segundos de comportamiento sin esperar tres segundos:
import { expect, mock, test } from 'claude-code/testing'
test('the countdown ends with a toast', async ($, on) => {
// Answer every $.clock call from a clock the test controls
const clock = mock.clock(on)
// Collect the text of each toast the mod shows
const toasts: string[] = []
on('ui.toast', ($, e) => {
toasts.push(e.text)
return { value: undefined }
})
await $.command.run({ command: 'countdown', args: '3' })
// After two seconds the timer has fired twice, and no toast is due
await clock.advance(2000)
expect(toasts).toEqual([])
// The third second brings the count to zero
await clock.advance(1000)
expect(toasts).toEqual(['Time is up'])
})
El primer expect muestra que el toast no viene temprano, y el segundo muestra que viene una vez. Cada advance se resuelve después de que los temporizadores que vencieron hayan ejecutado, por lo que la verificación en la siguiente línea ve su efecto.
Prueba un dibujo
Una prueba puede dibujar uno de los sitios de renderizado de tu mod, luego presionar, escribir en, y encontrar los elementos que dibujó. $.ui.mount dibuja el sitio a través del hook ui.render de tu mod y devuelve un identificador con un método para cada uno de esos. Para cubrir varias aplicaciones en una prueba, establece surface en la aplicación para la que dibujar. Esta prueba abre el panel de Construye un panel con pestañas, cambia pestañas, presiona el botón, y verifica el contador en la terminal y la aplicación de escritorio:
import { expect, test } from 'claude-code/testing'
// What Claude Code passes to a ui.render hook for this pane, apart from the app
const PANE = {
plugin: 'hello-tabs',
component: 'Pane',
requestId: 'hello-tabs',
viewport: { columns: 100, rows: 30 },
props: {
title: 'Hello tabs',
isFocused: true,
bodyColumns: 60,
placement: 'inline',
scroll: { offset: 0, bodyRows: 10 },
view: {},
},
} as const
test('the second tab counts presses and saves the count', async ($, on) => {
// Stub $.store with a Map, so the test can read what the mod saved
const saved = new Map<string, unknown>()
on('store.get', ($, e) => ({ value: saved.get(e.key) }))
on('store.set', ($, e) => {
saved.set(e.key, e.value)
return { value: undefined }
})
// Draw the pane once for each app
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
// Press the buttons by the key the mod gave them
await ui.press({ key: 'tab-two' })
await ui.press({ key: 'more' })
// The second tab's count line is in the drawing
expect(await ui.find({ type: 'Text', text: /^Count: \d+$/ })).toBeDefined()
await ui.unmount()
}
// One press in each app makes two
expect(saved.get('count')).toBe(2)
})
En tu shell, ejecuta claude plugin test desde el directorio hello-tabs. La prueba pasa cuando ambas aplicaciones dibujan la línea de contador y el mod ha guardado 2. El contador se transfiere de la primera aplicación a la segunda porque ambos montajes usan el mismo módulo cargado.
El identificador que $.ui.mount devuelve tiene estos métodos, que direccionan elementos por la key que les diste:
| Método | Lo que hace |
|---|---|
press({ key: 'more' }) |
Presiona el Button con esa clave |
input({ key: 'new-note', text: 'buy milk' }) |
Escribe el texto en el Input con esa clave y presiona Enter. Agrega kind: 'change' para escribir sin enviar. |
select({ key: 'size', value: 'large' }) |
Elige la opción con ese valor en el Select con esa clave |
find({ key: 'more' }) o find({ type: 'Text', text: 'Count: 2' }) |
Devuelve el primer elemento coincidente como { type, props, children }, o undefined. text puede ser una cadena o una expresión regular. |
unmount() |
Elimina el dibujo |
Cada método se resuelve después de que tu controlador haya terminado, por lo que puedes verificar el resultado en la siguiente línea. Establece props en lo que Claude Code pasaría para ese sitio. La tabla de sitios de renderizado enumera los props de cada sitio, y los tipos para tu compilación tienen sus tipos.
Una prueba de dibujo verifica el árbol que devuelve tu hook y si es válido para esa aplicación. No verifica cómo la aplicación lo pinta, así que mira un nuevo diseño en una sesión real también.
Prueba un dibujo después de `/clear`
Cada prueba comienza con cada valor $.state en su predeterminado, que es cómo /clear los deja. Para probar qué hace tu mod a continuación, omite session.start, genera classic.SessionStart con source: 'clear', y verifica qué dibuja tu mod.
Esta prueba verifica el módulo de Carga un valor guardado nuevamente después de /clear. Agrégalo al archivo de Prueba un dibujo, donde PANE está definido. La primera prueba de ese archivo espera que el botón guarde el contador, como lo hace el botón en Guarda desde más de una sesión:
test('the saved count comes back after /clear', async ($, on) => {
// The store already holds a count of 7
on('store.get', () => ({ value: 7 }))
// Answer the event after your hook passes it on with next(e)
on('classic.SessionStart', () => ({}))
// Raise the event that fires after /clear, which runs your hook
await $.classic.SessionStart({ source: 'clear' })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
await ui.press({ key: 'tab-two' })
// The pane shows the stored count, not the default of 0
expect(await ui.find({ type: 'Text', text: 'Count: 7' })).toBeDefined()
})
La prueba pasa cuando tu hook classic.SessionStart ha copiado el 7 guardado en $.state antes de que el panel se dibuje. Sin ese hook en tu módulo, el panel dibuja Count: 0, find devuelve undefined, y la prueba falla en toBeDefined.
Prueba un mod que juzga otros mods
Un mod que tu organización enumera en prependPlugins puede rechazar otro mod antes de que cargue. Para probar uno, establece el nivel de tu mod y dale a la prueba un segundo mod para que el tuyo admita o rechace:
tier: llámalo una vez en la parte superior del archivo de prueba, como entier('prepend'), para cargar tu mod comoprepend,append, obuiltin, su lugar en el orden en que los mods se ejecutan. Sin él, tu mod carga comouser.plugins: pasa atestun objeto de opciones antes del cuerpo de la prueba. Su matrizpluginscontiene mods que escribes en línea, cada uno con unnamey una funciónregister. Para cargar uno en algún lugar que no seauser, agregatiera él.
Este archivo de prueba carga el mod de política de la página de administración primero. Verifica que el mod de política rechace un mod que inicia un proceso y admita uno que no:
import { expect, test, tier } from 'claude-code/testing'
// Load the mod under test ahead of every other mod
tier('prepend')
// A second mod whose code calls $.process.run, which the policy blocks
const runner = {
name: 'runner',
register(on) {
on('tool.call', async ($, e, next) => {
await $.process.run(['ls'])
return { result: 'runner answered' }
})
},
}
// A second mod that calls nothing the policy blocks
const reader = {
name: 'reader',
register(on) {
on('tool.call', async ($, e, next) => {
return { result: 'reader answered' }
})
},
}
test('refuses a mod that starts a process', { plugins: [runner] }, async ($, on) => {
on('tool.call', () => ({ result: 'claude code answered' }))
let message = ''
try {
// The first call on $ loads the mods, so the refusal is thrown here
await $.tool.call({ tool: 'Bash', command: 'ls' })
} catch (error) {
message = error.message
}
expect(message).toBe('runner: refused by acme-guard: Acme policy: mods may not call process.run')
})
test('admits a mod that starts no process', { plugins: [reader] }, async ($, on) => {
on('tool.call', () => ({ result: 'claude code answered' }))
const out = await $.tool.call({ tool: 'Bash', command: 'ls' })
// The answer comes from reader, which shows that it loaded
expect(out).toEqual({ result: 'reader answered' })
})
En tu shell, ejecuta claude plugin test desde el directorio acme-guard. Ambas pruebas pasan con el mod de política como lo muestra la página de administración.
El kit carga cada mod en la primera llamada de la prueba a $. Cuando tu mod rechaza uno, esa llamada lanza, y el mensaje nombra el mod rechazado, el mod que lo rechazó, y tu razón. En la segunda prueba nada es rechazado, por lo que reader responde la llamada de herramienta antes de que llegue al stub.
Próximos pasos
- Soluciona problemas de un mod: descubre por qué un mod no hace nada en una sesión
- Referencia de mods: cada evento de entrada y resultado, para escribir stubs