SpyBara
Go Premium

plugins/mods/test.md 2026-09-30 23:00 UTC to 2026-10-01 21:02 UTC

This page contains 422 additions and 0 deletions.

2026
Thu 1 21:02

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' }) genera tool.call. $.command.run, $.prompt.submit, $.session.start, y $.turn.complete funcionan de la misma manera, y $.classic.Stop y los otros métodos $.classic generan un evento de hook de configuración. Una prueba no puede generar directamente una llamada de API de mods como ui.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 como store.get responde a $.store.get de tu mod. Cuando tu mod llama a $.model.complete o $.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 simple
  • 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 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 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 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 que session.start configura, 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.register que hace un hook session.start como el del tutorial. Sin él, esa llamada rechaza con no implementation for command.register y 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 bajo the engine reported: solo si una verificación posterior falla.

  • Un hook que devuelve next(e) necesita un stub para responder. 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 devolvió next(e).

  • Un stub para turn.step es 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.value
    

    Cuando el bucle termina, result es el objeto que el stub devolvió, después de que tu hook turn.step haya tenido la oportunidad de cambiarlo. Aquí result.answer es '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 stub tool.call que 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 en tier('prepend'), para cargar tu mod como prepend, append, o builtin, su lugar en el orden en que los mods se ejecutan. Sin él, tu mod carga como user.
  • plugins: pasa a test un objeto de opciones antes del cuerpo de la prueba. Su matriz plugins contiene mods que escribes en línea, cada uno con un name y una función register. Para cargar uno en algún lugar que no sea user, agrega tier a é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