SpyBara
Go Premium

plugins/mods/test.md 2026-10-01 23:59 UTC to 2026-10-02 19:58 UTC

This page contains 76 additions and 76 deletions.

2026
Thu 1 23:59 Fri 2 19:58

Probar un mod

Escribe pruebas automatizadas para un mod de Claude Code que disparan eventos, simulan las respuestas de Claude Code y presionan 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 dispara los eventos que manejan tus hooks y verifica lo que 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.

Escribir una prueba

Una prueba carga tu mod, envía eventos a través de sus hooks como lo haría Claude Code y comprueba lo que hicieron los hooks, sin una sesión, sin iniciar sesión y sin 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 un test(), o la ejecución falla con declares no test(): nothing ran. Un archivo de prueba puede importar los archivos propios de tu mod y los helpers .ts vecinos, así que puedes hacer pruebas unitarias de funciones simples, como las reglas de un juego, sin el kit.

Esta prueba dispara dos llamadas a herramientas, ejecuta el comando /tally de Crear un mod y comprueba que la respuesta cuente ambas. Su primera línea es un stub, que responde a las llamadas a herramientas en lugar de Claude Code. Guárdala 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' }))

  // Fire 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 e indica 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ó por el hook tool.call del mod, que sumó uno a su conteo y pasó la llamada al stub. No se ejecutó ningún ls ni se leyó ningún archivo. Luego $.command.run fue al hook command.run del mod, y answer es el objeto que devolvió ese hook.

El comando termina con estado 1 cuando una prueba falla, así que funciona en CI. Si tus propios mods no pueden cargarse en el shell que lo ejecuta, imprime una línea que empieza con claude plugin test: hooks modules are turned off con el motivo y termina con estado 1.

Crear stubs para lo que respondería Claude Code

En una prueba no se ejecuta ningún modelo, almacén ni herramienta, así 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 actúa como Claude Code. No es la API de mods que recibe un hook. Cada uno de sus métodos dispara el evento del mismo nombre, lo envía a través de los hooks de tu mod y se resuelve con el resultado: $.tool.call({ tool: 'Bash', command: 'ls' }) dispara tool.call. $.command.run, $.prompt.submit, $.session.start y $.turn.complete funcionan de la misma manera, y $.classic.Stop y los demás métodos $.classic disparan un evento de hook de configuración. Una prueba no puede disparar directamente una llamada a la API de mods como ui.close. Desencadénala 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 a la API de mods sin el $., así que un stub registrado como store.get responde al $.store.get de tu mod. Cuando tu mod llama a $.model.complete o $.store.get, un stub proporciona la respuesta.

Este ejemplo crea un stub para una llamada a un 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 empieza con PASS. El archivo contiene solo el hook bajo prueba, así 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 crea un stub para la llamada al modelo para comprobar qué hace el hook con una respuesta aprobatoria:

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 empieza con PASS. Para comprobar la otra rama, agrega una segunda prueba cuyo stub devuelva un text que empiece con FAIL, y espera Try again.

Un stub para una llamada a la API de mods devuelve un objeto con un campo value, que contiene aquello con lo que se resuelve la llamada en tu mod: { value: 7 } hace que $.store.get se resuelva con 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 de su evento, como muestra la tabla. Consultar lo que devuelve un stub muestra qué forma toma cada nombre común. Estos errores significan que un stub es incorrecto o falta. La salida de una prueba fallida incluye un bloque encabezado por the engine reported:, y cada error aparece allí:

  • returned neither { value } nor { deny }: un stub para una llamada a la API de mods devolvió un valor sin envolver
  • no implementation for seguido de un nombre: tu mod hizo esa llamada y ningún stub la responde

El kit también exporta mocks en memoria que responden a un espacio de nombres completo por ti. mock.clock(on) responde a $.clock, mock.store(on, { count: 7 }) responde a $.store desde un almacén que empieza con esas entradas, y mock.env(on, { CI: 'true' }) responde a $.env.get desde esas variables. mock.clock devuelve un reloj simulado que tu prueba hace avanzar, así que una prueba de un temporizador no espera. mock.store no devuelve nada, así que para comprobar lo que guardó tu mod, escribe tú mismo los dos stubs de store como lo hace la prueba de dibujo.

Seguir las reglas del kit de pruebas

El kit de pruebas tiene algunas reglas propias, y romper una produce los errores con los que primero se topan quienes empiezan a escribir pruebas:

  • Registra cada stub antes de la primera llamada de la prueba a $. Llamar a on después de eso lanza un error como on("ui.render") after the test first called $.

  • session.start no se ejecuta por sí solo. Cada prueba empieza con tu módulo recién cargado y ninguno de sus hooks llamado, así que las variables a nivel de módulo conservan sus valores iniciales. Si un hook depende de lo que configura session.start, dispá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 }))
    // Fire the event, which runs your session.start hook
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
    

    El segundo stub responde a la llamada $.command.register que hace un hook de session.start como el del tutorial. Sin él, esa llamada se rechaza con no implementation for command.register y el kit omite tu hook, así que no se ejecuta nada de lo que sigue a la llamada en el hook. La prueba no falla en ese punto. El hook omitido aparece bajo the engine reported: solo si falla una comprobación posterior.

  • Un hook que devuelve next(e) necesita un stub que responda. Cuando tu hook ui.render devuelve next(e), por ejemplo para no dibujar nada mientras Claude está inactivo, montarlo falla con no 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 haya devuelto next(e).

  • Un stub para turn.step es un generador asíncrono, y la prueba lee el flujo hasta su final 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 }
    })
    
    // Fire 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.value
    

    Cuando termina el bucle, result es el objeto que devolvió el stub, después de que tu hook turn.step haya tenido la oportunidad de cambiarlo. Aquí result.answer es 'ok'.

  • Dispara una llamada a herramienta con el nombre y los argumentos de la herramienta como campos, como await $.tool.call({ tool: 'Bash', command: 'ls' }), y registra un stub de tool.call que devuelva { result }.

Consultar lo que devuelve un stub

Cada llamada a la API de mods que hace tu mod en una prueba necesita un stub que responda en lugar de Claude Code, excepto las pocas que el kit responde por sí mismo: las llamadas a $.ui.invalidate y $.state. Para las llamadas a $.clock, usa mock.clock(on), o el $.clock.now() de tu mod falla con no implementation for clock.now.

Esta tabla enumera las que más usan los mods. La primera columna es la llamada que hace tu mod o el evento que pasa con next(e). La segunda es la función que se pasa a on con ese nombre, así que la fila de $.store.get se convierte en on('store.get', ($, e) => ({ value: saved.get(e.key) })). Un '...' en un stub marca texto que debes completar tú:

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 de tool.call, porque la pregunta le llega como una llamada a la herramienta AskUserQuestion: ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). Comprueba e.tool primero si tu mod pasa otras llamadas a 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 a la API de mods que deba fallar () => ({ deny: 'the reason' }), que hace que la llamada se rechace en tu mod. En cambio, un stub que lanza una excepción se omite.
session.start () => ({ cwd: '/work' })
turn.start ($, e) => ({ turnId: e.turnId })
tool.call () => ({ result: '...' })
turn.complete () => ({ text: '' }). Dispá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 }). Dispá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.

Probar un temporizador

Un mod que ejecuta trabajo con un temporizador necesita un reloj que la prueba controle, para que la prueba pueda hacer avanzar el tiempo en lugar de esperar. const clock = mock.clock(on) devuelve un reloj simulado que comienza en 0 y solo avanza cuando tu prueba lo hace avanzar. Para comenzar en otro momento, pásalo en milisegundos, como en mock.clock(on, { now: 5000 }). El reloj tiene estos métodos:

Método Qué hace
await clock.advance(1000) Hace avanzar el tiempo esa cantidad de milisegundos y ejecuta cada temporizador que vence
await clock.set(5000) Hace avanzar el tiempo hasta ese valor, como lo haría advance
clock.now() Devuelve el tiempo, que es el valor al que se resuelve $.clock.now() en tu mod
await clock.settle() Ejecuta los temporizadores que ya vencieron, como una cadena de llamadas a $.clock.after con retraso cero, sin hacer avanzar el tiempo
await clock.sleep(2000) Dentro de un stub, hace que ese stub responda solo una vez que la prueba haya avanzado hasta ese punto, que es como simulas un modelo o proceso lento

Este hook pertenece a un mod llamado countdown y maneja un comando /countdown que recibe un número de segundos, inicia un temporizador $.clock.every de un segundo y muestra un toast al llegar a cero. Al igual que 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 hace avanzar 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 llega antes de tiempo, y el segundo muestra que llega una sola vez. Cada advance se resuelve después de que se hayan ejecutado los temporizadores que vencieron, por lo que la verificación de la línea siguiente ve su efecto.

Probar un dibujo

Una prueba puede dibujar uno de los puntos de renderizado de tu mod y luego presionar, escribir en y buscar los elementos que dibujó. $.ui.mount dibuja el punto a través del hook ui.render de tu mod y devuelve un handle con un método para cada una de esas acciones. Para cubrir varias apps en una sola prueba, establece surface en la app para la que quieres dibujar. Esta prueba abre el panel de Crear un panel con pestañas, cambia de pestaña, presiona el botón y verifica el conteo en la terminal y en la app de Desktop:

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 apps dibujan la línea del conteo y el mod ha guardado 2. El conteo se mantiene de la primera app a la segunda porque ambos montajes usan el mismo módulo cargado.

El handle que devuelve $.ui.mount tiene estos métodos, que identifican los elementos por la key que les diste:

Método Qué hace
press({ key: 'more' }) Presiona el Button con esa key
input({ key: 'new-note', text: 'buy milk' }) Escribe el texto en el Input con esa key 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 key
find({ key: 'more' }) o find({ type: 'Text', text: 'Count: 2' }) Devuelve el primer elemento que coincide 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 handler ha terminado, así que puedes verificar el resultado en la línea siguiente. Establece props con lo que Claude Code pasaría para ese punto. La tabla de puntos de renderizado enumera las props de cada punto, y los tipos para tu compilación incluyen sus tipos.

Una prueba de dibujo verifica el árbol que devuelve tu hook y si es válido para esa app. No verifica cómo lo pinta la app, así que revisa también un nuevo diseño en una sesión real.

Probar un dibujo después de `/clear`

Cada prueba comienza con todos los valores de $.state en su valor predeterminado, que es como los deja /clear. Para probar lo que hace tu mod a continuación, omite session.start, dispara classic.SessionStart con source: 'clear' y verifica lo que dibuja tu mod.

Esta prueba verifica el módulo de Volver a cargar un valor guardado después de /clear. Agrégala al archivo de Probar un dibujo, donde está definido PANE. La primera prueba de ese archivo espera que el botón guarde el conteo, como lo hace el botón de Guardar 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', () => ({}))

  // Fire the event that follows /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 almacenado en $.state antes de que se dibuje el panel. Sin ese hook en tu módulo, el panel dibuja Count: 0, find devuelve undefined y la prueba falla en toBeDefined.

Prueba un mod de políticas

Un mod que tu organización incluye en prependPlugins puede rechazar otro mod antes de que se cargue. Para probar uno, establece el nivel de tu mod y dale a la prueba un segundo mod para que el tuyo lo permita o lo rechace:

  • tier: llámalo una vez al inicio del archivo de prueba, como en tier('prepend'), para cargar tu mod como prepend, append o builtin, su lugar en el orden en que se ejecutan los mods. Sin él, tu mod se carga como user.
  • plugins: pásale a test un objeto de opciones antes del cuerpo de la prueba. Su arreglo plugins contiene mods que escribes en línea, cada uno con un name y una función register. Para cargar uno en un lugar distinto de user, agrégale tier.

Este archivo de prueba carga primero el mod de políticas de la página de administración. Comprueba que el mod de políticas rechaza un mod que inicia un proceso y permite uno que no lo hace:

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íticas tal como lo muestra la página de administración.

El kit carga todos los mods en la primera llamada de la prueba sobre $. Cuando tu mod rechaza uno, esa llamada lanza un error, y el mensaje indica el mod rechazado, el mod que lo rechazó y tu motivo. En la segunda prueba no se rechaza nada, así que reader responde la llamada a herramienta antes de que llegue al stub.

Próximos pasos