Testar um mod
Escreva testes automatizados para um mod Claude Code que levantam eventos, simulam respostas do Claude Code e pressionam botões, sem sessão, login ou rede.
Você pode escrever testes automatizados para um mod e executá-los a partir do seu shell com claude plugin test. Um teste levanta os eventos que seus hooks tratam e verifica o que os hooks fizeram, para que você detecte um problema antes que ele chegue a uma sessão. O primeiro exemplo testa o mod de Create a mod.
Escrever um teste
Um teste carrega seu mod, envia eventos através de seus hooks da forma como Claude Code faria, e verifica o que os hooks fizeram, sem uma sessão, um login ou uma rede. Você executa testes a partir do seu shell com claude plugin test, e cada arquivo de teste importa o test kit, uma biblioteca de teste no módulo claude-code/testing.
Dê a cada arquivo de teste um nome que termine em .test.ts, como first-mod.test.ts, e salve-o em qualquer lugar no diretório do plugin. Cada arquivo de teste precisa de pelo menos um test(), ou a execução falha com declares no test(): nothing ran. Um arquivo de teste pode importar seus próprios arquivos do mod e helpers .ts irmãos, para que você possa fazer testes unitários de funções simples, como as regras de um jogo, sem o kit.
Este teste levanta duas chamadas de ferramenta, executa o comando /tally de Create a mod, e verifica se a resposta conta ambas. Sua primeira linha é um stub, que responde as chamadas de ferramenta no lugar do Claude Code. Salve-o 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')
})
No seu shell, execute os testes a partir do diretório first-mod:
claude plugin test
A saída nomeia cada teste e se passou, com tempos que variam de execução para execução:
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 passou pelo hook tool.call do mod, que adicionou um à sua contagem e passou a chamada para o stub. Nenhum ls foi executado e nenhum arquivo foi lido. $.command.run então foi para o hook command.run do mod, e answer é o objeto que esse hook retornou.
O comando sai com status 1 quando um teste falha, então funciona em CI. Se seus próprios mods não conseguirem carregar no shell que o executa, ele imprime uma linha começando com claude plugin test: hooks modules are turned off com o motivo, e sai com status 1.
Simular o que Claude Code responderia
Nenhum modelo, armazenamento ou ferramenta é executado em um teste, então onde quer que seu mod espere que Claude Code responda, o teste fornece a resposta com um stub. Uma função de teste recebe dois argumentos para isso:
$: o próprio$do teste, que fica no lugar do Claude Code. Não é a mods API que um hook recebe. Cada um de seus métodos levanta o evento de mesmo nome, o envia através dos hooks do seu mod, e resolve para o resultado:$.tool.call({ tool: 'Bash', command: 'ls' })levantatool.call.$.command.run,$.prompt.submit,$.session.start, e$.turn.completefuncionam da mesma forma, e$.classic.Stope os outros métodos$.classiclevantam um evento de hook de configurações. Um teste não pode levantar uma chamada de mods API comoui.closediretamente. Dispare-a através do seu mod, por exemplo pressionando o botão que fecha o painel.on: chame-o para registrar stubs, que são hooks que respondem no lugar do Claude Code. Nomeie um stub para uma chamada de mods API sem o$., então um stub registrado comostore.getresponde seu$.store.getdo mod. Quando seu mod chama$.model.completeou$.store.get, um stub fornece a resposta.
Este exemplo simula uma chamada de modelo. O hook pertence a um mod chamado grader, e trata um comando /grade que envia uma frase para um modelo e relata se a resposta começa com PASS. O arquivo contém apenas o hook sob teste, então o mod também precisa de um plugin.json e um hooks.json, como em Create a mod. Para digitar /grade em uma sessão, o mod também tem que registrar o 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' }
})
}
Este teste simula a chamada do modelo para verificar o que o hook faz com uma resposta aprovada:
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')
})
O teste passa porque o reply do hook é o objeto sob value, cujo text começa com PASS. Para verificar o outro ramo, adicione um segundo teste cujo stub retorna um text que começa com FAIL, e espere Try again.
Um stub para uma chamada de mods API retorna um objeto com um campo value, que contém o que a chamada resolve em seu mod: { value: 7 } faz $.store.get resolver para 7. Um stub para um dos eventos do Claude Code, como turn.step ou tool.call, retorna o resultado próprio desse evento, como { result: 'ok' }. $.session.send e $.prompt.fill também levam o resultado do evento, como a tabela mostra. Look up what a stub returns mostra qual forma cada nome comum assume. Dois erros significam que um stub está errado ou faltando. A saída de um teste falhado inclui um bloco intitulado the engine reported:, e cada erro aparece lá:
returned neither { value } nor { deny }: um stub para uma chamada de mods API retornou um valor simplesno implementation forseguido por um nome: seu mod fez essa chamada e nenhum stub a responde
O kit também exporta mocks em memória que respondem um namespace inteiro para você. mock.clock(on) responde $.clock, mock.store(on, { count: 7 }) responde $.store de um armazenamento que começa com essas entradas, e mock.env(on, { CI: 'true' }) responde $.env.get dessas variáveis. mock.clock retorna um relógio simulado que seu teste avança, então um teste de um temporizador não espera. mock.store não retorna nada, então para verificar o que seu mod salvou, escreva os dois stubs store você mesmo como o drawing test faz.
Seguir as regras do test kit
O test kit tem algumas regras próprias, e quebrar uma produz os erros que novos autores de testes encontram primeiro:
-
Registre cada stub antes da primeira chamada do teste em
$. Chamarondepois disso lança um erro comoon("ui.render") after the test first called $. -
session.startnão é executado por si só. Cada teste começa com seu módulo carregado recentemente e nenhum de seus hooks chamado, então variáveis de nível de módulo mantêm seus valores iniciais. Se um hook depende do quesession.startconfigura, levante-o primeiro:// 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' })O segundo stub responde a chamada
$.command.registerque um hooksession.startcomo o do tutorial faz. Sem ele, essa chamada rejeita comno implementation for command.registere o kit pula seu hook, então nada depois da chamada no hook é executado. O teste não falha nesse ponto. O hook pulado é listado sobthe engine reported:apenas se uma verificação posterior falhar. -
Um hook que retorna
next(e)precisa de um stub para responder. Quando seu hookui.renderretornanext(e), por exemplo para não desenhar nada enquanto Claude está ocioso, montá-lo falha comno implementation for ui.render. Registre um stub que retorna um elemento como dados simples:// Stands for what Claude Code would draw at the site on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))Com o stub registrado, a montagem é bem-sucedida, e
ui.find({ type: 'Text' })retorna esse elemento sempre que seu hook retornounext(e). -
Um stub para
turn.stepé um gerador assíncrono, e o teste lê o fluxo até o final para obter o 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.valueQuando o loop termina,
resulté o objeto que o stub retornou, depois que seu hookturn.stepteve a chance de alterá-lo. Aquiresult.answeré'ok'. -
Levante uma chamada de ferramenta com o nome da ferramenta e argumentos como campos, como
await $.tool.call({ tool: 'Bash', command: 'ls' }), e registre um stubtool.callque retorna{ result }.
Procurar o que um stub retorna
Cada chamada de mods API que seu mod faz em um teste precisa de um stub que responda no lugar do Claude Code, exceto as poucas que o kit responde por si: chamadas $.ui.invalidate e $.state. Para chamadas $.clock, use mock.clock(on), ou seu $.clock.now() do mod falha com no implementation for clock.now.
Esta tabela lista as que mods usam mais. A primeira coluna é a chamada que seu mod faz ou o evento que passa com next(e). A segunda é a função para passar para on sob esse nome, então a linha $.store.get se torna on('store.get', ($, e) => ({ value: saved.get(e.key) })). Um '...' em um stub marca texto para você preencher:
| Seu mod chama ou passa | Stub |
|---|---|
$.command.register, $.tool.register, $.ui.toast, $.ui.log, $.ui.status, $.ui.close, $.store.set |
() => ({ value: undefined }). Para ui.toast e ui.log, o texto é e.text. |
$.store.get |
($, e) => ({ value: saved.get(e.key) }) |
$.fs.read |
($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }). e.path chega como um caminho absoluto, então compare com endsWith. |
$.ui.open |
() => ({ value: { isPlaced: true } }) |
$.ui.ask |
Um stub tool.call, porque a pergunta a alcança como uma chamada para a ferramenta AskUserQuestion: ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). Verifique e.tool primeiro se seu mod passa outras chamadas de ferramenta. |
$.model.complete |
() => ({ value: { isAnswered: true, text: '...', usage } }) |
$.process.run |
($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } }). e.argv é a lista de argumentos e e.init contém cwd e timeoutMs. |
| Qualquer chamada de mods API que deve falhar | () => ({ deny: 'the reason' }), que faz a chamada rejeitar em seu mod. Um stub que lança é pulado. |
session.start |
() => ({ cwd: '/work' }) |
turn.start |
($, e) => ({ turnId: e.turnId }) |
tool.call |
() => ({ result: '...' }) |
turn.complete |
() => ({ text: '' }). Levante-o com $.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 chega como uma string mesmo quando seu mod passou { sessionId }. |
session.receive |
($, e) => ({ text: e.text }). Levante-o com $.session.receive({ origin: { kind: 'peer-send-message' }, text }). |
ui.render |
() => ({ type: 'Text', props: {}, children: ['...'] }) |
expect tem as asserções toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined, e toThrow, e .not antes de qualquer uma delas.
Testar um temporizador
Um mod que executa trabalho em um temporizador precisa de um relógio que o teste controla, para que o teste possa avançar o tempo em vez de esperar. const clock = mock.clock(on) retorna um relógio simulado que começa em 0 e se move apenas quando seu teste o move. Para começar em outro tempo, passe-o em milissegundos, como em mock.clock(on, { now: 5000 }). O relógio tem estes métodos:
| Método | O que faz |
|---|---|
await clock.advance(1000) |
Avança o tempo por esse número de milissegundos e executa cada temporizador que vence |
await clock.set(5000) |
Avança o tempo para esse valor, como advance faria |
clock.now() |
Retorna o tempo, que é o que seu $.clock.now() do mod resolve para |
await clock.settle() |
Executa temporizadores que já vencem, como uma cadeia de chamadas $.clock.after de zero atraso, sem mover o tempo |
await clock.sleep(2000) |
Dentro de um stub, faz esse stub responder apenas uma vez que o teste avançou tão longe, que é como você simula um modelo ou processo lento |
Este hook pertence a um mod chamado countdown, e trata um comando /countdown que leva um número de segundos, inicia um temporizador $.clock.every de um segundo, e mostra um toast em zero. Como com grader, o arquivo contém apenas o hook sob teste e não registra o 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 {}
})
}
Este teste executa /countdown 3 e move o relógio simulado, então verifica três segundos de comportamento sem esperar três 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'])
})
O primeiro expect mostra que o toast não vem cedo, e o segundo mostra que vem uma vez. Cada advance resolve depois que os temporizadores que vencem foram executados, então a verificação na próxima linha vê seu efeito.
Testar um desenho
Um teste pode desenhar um dos render sites do seu mod, então pressionar, digitar em e encontrar os elementos que desenhou. $.ui.mount desenha o site através do hook ui.render do seu mod e retorna um identificador com um método para cada um desses. Para cobrir vários aplicativos em um teste, defina surface para o aplicativo a desenhar. Este teste abre o painel de Build a pane with tabs, muda abas, pressiona o botão, e verifica a contagem no terminal e no aplicativo 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)
})
No seu shell, execute claude plugin test a partir do diretório hello-tabs. O teste passa quando ambos os aplicativos desenham a linha de contagem e o mod salvou 2. A contagem é transferida do primeiro aplicativo para o segundo porque ambas as montagens usam o mesmo módulo carregado.
O identificador que $.ui.mount retorna tem estes métodos, que endereçam elementos pela key que você deu a eles:
| Método | O que faz |
|---|---|
press({ key: 'more' }) |
Pressiona o Button com essa chave |
input({ key: 'new-note', text: 'buy milk' }) |
Digita o texto no Input com essa chave e pressiona Enter. Adicione kind: 'change' para digitar sem enviar. |
select({ key: 'size', value: 'large' }) |
Escolhe a opção com esse valor no Select com essa chave |
find({ key: 'more' }) ou find({ type: 'Text', text: 'Count: 2' }) |
Retorna o primeiro elemento correspondente como { type, props, children }, ou undefined. text pode ser uma string ou uma expressão regular. |
unmount() |
Remove o desenho |
Cada método resolve depois que seu manipulador terminou, então você pode verificar o resultado na próxima linha. Defina props para o que Claude Code passaria para esse site. A tabela de render sites lista os props de cada site, e os tipos para sua compilação têm seus tipos.
Um teste de desenho verifica a árvore que seu hook retorna e se é válida para esse aplicativo. Não verifica como o aplicativo a pinta, então veja um novo layout em uma sessão real também.
Testar um desenho após `/clear`
Cada teste começa com cada valor $.state em seu padrão, que é como /clear os deixa. Para testar o que seu mod faz a seguir, pule session.start, levante classic.SessionStart com source: 'clear', e verifique o que seu mod desenha.
Este teste verifica o módulo de Load a saved value again after /clear. Adicione-o ao arquivo de Test a drawing, onde PANE é definido. O primeiro teste desse arquivo espera que o botão salve a contagem, como o botão em Save from more than one session faz:
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()
})
O teste passa quando seu hook classic.SessionStart copiou o 7 armazenado em $.state antes do painel desenhar. Sem esse hook em seu módulo, o painel desenha Count: 0, find retorna undefined, e o teste falha em toBeDefined.
Testar um mod que julga outros mods
Um mod que sua organização lista em prependPlugins pode recusar outro mod antes que ele carregue. Para testar um, defina o nível do seu mod e dê ao teste um segundo mod para o seu admitir ou recusar:
tier: chame-o uma vez no topo do arquivo de teste, como emtier('prepend'), para carregar seu mod comoprepend,append, oubuiltin, seu lugar na ordem que mods são executados. Sem ele, seu mod carrega comouser.plugins: passetestum objeto de opções antes do corpo do teste. Seu arraypluginscontém mods que você escreve inline, cada um com umnamee uma funçãoregister. Para carregar um em algum lugar diferente deuser, adicionetiera ele.
Este arquivo de teste carrega o policy mod da página admin primeiro. Verifica que o policy mod recusa um mod que inicia um processo e admite um que não:
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' })
})
No seu shell, execute claude plugin test a partir do diretório acme-guard. Ambos os testes passam com o policy mod como a página admin mostra.
O kit carrega cada mod na primeira chamada do teste em $. Quando seu mod recusa um, essa chamada lança, e a mensagem nomeia o mod recusado, o mod que o recusou, e seu motivo. No segundo teste nada é recusado, então reader responde a chamada de ferramenta antes que chegue ao stub.
Próximos passos
- Troubleshoot a mod: descubra por que um mod não faz nada em uma sessão
- Mods reference: cada evento de entrada e resultado, para escrever stubs