claude-code/testing está declarado no mesmo arquivo de tipos da API: é lá que você confere cada função.
Entenda o que o teste simula
claude plugin test <pasta> procura os arquivos *.test.ts do mod e roda cada um contra o engine de verdade. O mod carrega como numa sessão, com a mesma cadeia e as mesmas regras de validação.
Cada teste recebe dois objetos. O $ do engine, para disparar eventos de cima (iniciar a sessão, chamar uma ferramenta, rodar um comando). E um on cujos hooks ficam embaixo do mod. A referência é direta: nada fica abaixo desses hooks; eles fazem o papel do engine.
🆕 Novo aqui? O kit de teste
claude-code/testing— o módulo de onde vêmtest,describe,expectemock.test(nome, corpo)— o corpo éasync ($, on) => {…}.- Mock — um hook do teste que responde no lugar do engine (ex.:
on('fs.read', …)devolve o texto de um arquivo que não existe).
Como ler o desenho: o teste empurra eventos de cima, o mod reage no meio, e o que o mod pede ao $ desce até a camada cinza. Se nenhum hook do teste responde a um pedido, ninguém responde: o teste falha dizendo qual noun faltou.
| Numa sessão | No teste |
|---|---|
| o modelo pede uma ferramenta | await $.tool.call({ tool: 'Write', … }) |
| a ferramenta roda de verdade | on('tool.call', () => ({ result: 'ok', text: 'ok' })) |
| o disco responde | on('fs.read', …) lendo de um Map |
a pessoa digita /recibo | await $.command.run(comando('recibo')) |
Escreva os mocks antes do $
A regra que mais derruba teste novo: todo on(...) vem antes da primeira chamada a $. Um mock pendurado depois é recusado com a mensagem on(...) after the test first called $ (pegadinha 18 do COMO-FAZER-UM-MOD.md).
O inema-mods resolve isso com um arquivo de mundo: tests/mundo.ts pendura todos os mocks de uma vez e devolve um objeto onde o teste lê o que aconteceu (comandos registrados, toasts, processos rodados).
mock.clock(on)
on('session.start', ($, e) => ({ cwd: e.cwd }))
on('prompt.submit', ($, e) => ({ text: e.text, ...(e.context !== undefined && { context: e.context }) }))
on('fs.exists', ($, e) => ({ value: mundo.arquivos.has(e.path) }))
on('fs.read', ($, e) => {
const f = mundo.arquivos.get(e.path)
return f ? { value: f.texto } : { deny: `ENOENT: ${e.path}` }
})
on('store.get', () => ({ value: undefined }))
on('store.set', () => ({ value: undefined }))
on('command.register', ($, e) => {
mundo.comandos.push(e.name)
return { value: { command: e.name } }
})
{ value }, ou { deny } para "arquivo não existe"). O arquivo vai copiado em cada mod: um mod não importa nada de fora da própria pasta.✓ Ordem certa
- ✓
const mundo = mundoDe(on) - ✓
on('tool.call', …)extra do teste - ✓
await $.session.start(SESSAO) - ✓ chamadas e
expect
✗ Ordem que falha
- ✗
await $.session.start(...)primeiro - ✗ depois
on('fs.read', …) - ✗ resultado:
on(...) after the test first called $
⚠️ O mock específico antes do genérico
O $.ui.ask chega ao fundo como um tool.call de AskUserQuestion. No mundo.ts do freio-de-mao, o mock on('tool.call', { tool: 'AskUserQuestion' }, …) é registrado antes de qualquer mock genérico de tool.call, "para não ser engolido por ele", diz o comentário no arquivo.
💡 A mensagem diz qual mock falta
Se o mod usa um noun que o mundo não responde (session.usage, fs.list, env.get...), o erro nomeia exatamente o que faltou (pegadinha 3). Acrescente o on('<noun>.<método>', …) e rode de novo.
Teste um comando de ponta a ponta
O teste do starter-mod (Claude Mods Starter Kit, da Prompt Advisers, MIT) cabe numa tela e cobre o mod inteiro: sessão, duas leituras (uma falha), o comando /readcount e a resposta.
import { test, expect } from "claude-code/testing";
test("counts successful reads and excludes failed ones", async ($, on) => {
on("session.start", ($, e) => ({ cwd: e.cwd }));
on("command.register", ($, e) => ({ value: { command: e.name } }));
on("tool.call", ($, e) => e.file_path === "/missing" ? { isError: true, result: "missing", text: "missing" } : { result: {}, text: "ok" });
await $.session.start({ surface: "terminal", isInteractive: true, cwd: "/work" } as any);
await $.tool.call({ tool: "Read", file_path: "/file" } as any);
await $.tool.call({ tool: "Read", file_path: "/missing" } as any);
const r = await $.command.run({ command: "readcount", args: "", origin: { kind: "composer" }, presentation: { isFullscreen: false, columns: 100 } } as any);
expect(r.text).toBe("Successful reads observed: 1");
});
Três mocks, antes de tudo
Sessão, registro de comando e a ferramenta Read, que falha só para /missing.
$.session.start
Dispara o session.start do mod, que registra /readcount.
Duas chamadas de $.tool.call
Uma dá certo, outra volta com isError. O mod deve contar só a primeira.
$.command.run e expect
Roda o comando como se a pessoa digitasse e confere o texto exato.
No terminal, dentro da pasta claude-mods-starter-kit:
claude plugin test templates/starter-mod
Saída real (05/10/2026, Claude Code 2.1.289):
tests/starter.test.ts: (pass) counts successful reads and excludes failed ones [20.64ms] 1 pass 0 fail Ran 1 test across 1 file. [0.13s]
1 pass e 0 fail. Os tempos entre colchetes mudam de máquina para máquina. Para ver o teste pegar um erro, troque o 1 do expect por 2, rode de novo e desfaça.🆕 Novo aqui? test(nome, { options }, corpo)
Entre o nome e o corpo pode vir um objeto de opções. options faz o papel dos valores do /config: o mod recebe em register(on, options). O freio-de-mao usa assim: test('sem tela com sem_tela=permitir: roda', { options: { sem_tela: 'permitir' } }, async ($, on) => {…}). Sem options, valem os padrões do manifesto. O outro campo é timeoutMs (tópico 6).
Controle o relógio
Mod que agenda trabalho ($.clock.after, $.clock.every) ou carimba hora ($.clock.now) precisa de um relógio que só anda quando o teste manda. É o mock.clock(on).
Por isso a pegadinha 7 manda usar $.clock.now() e nunca Date.now(): o segundo lê o relógio da máquina, que o teste não controla.
Do relógio que mock.clock devolve | Faz |
|---|---|
now() | a hora que o $.clock.now() do mod recebe |
advance(ms) | anda e solta, em ordem, cada espera vencida no caminho |
set(ms) | vai até uma hora igual ou maior que a atual |
settle() | igual a advance(0): deixa rodar o que já está pronto, sem andar |
sleep(ms) | um mock do teste que responde "atrasado" |
const clock = mock.clock(on, { now: START });
mock.store(on);
const { fs, seen } = world(on, { [`${CWD}/.git/HEAD`]: "ref" });
await $.session.start({ surface: "terminal", isInteractive: true, cwd: CWD } as any);
await $.session.measure(measure(50));
await clock.settle();
expect(seen.forks).toBe(0);
await $.session.measure(measure(86));
await clock.settle();
expect(seen.forks).toBe(1);
$.clock.after(0, …)). O settle() é o passo entre "disparei" e "confiro o que aconteceu".⚠️ Espera presa tem limite
Segundo os tipos, uma espera presa no relógio falso além do orçamento de um hook (dez segundos de tempo real) é solta, como um hook que estourou. Se o teste esquece de chamar advance, o sintoma é lentidão seguida de falha, não um travamento eterno.
Monte a tela em terminal e desktop
Para testar o que o mod desenha, o teste monta um componente numa superfície que ele nomeia: $.ui.mount({ plugin, surface, component, … }). A superfície nunca é presumida.
A referência recomenda escrever o corpo uma vez e repetir para ['terminal', 'desktop'] as const. Assim o teste prova que o mod não depende de uma tela só (lembre: Raster só existe no terminal).
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({
plugin: 'marcador-sessao',
surface,
component: 'Pane',
requestId: 'marcador-sessao',
viewport: VIEWPORT,
props: PANE_PROPS,
})
expect(await ui.find({ type: 'Text', text: 'rotas' })).toBeDefined()
await ui.press({ key: 'colar0' })
await ui.unmount()
}
expect(mundo.preenchidos).toHaveLength(2)
key; o texto, pelo conteúdo. No fim, duas colagens: uma por superfície.Como ler o desenho: o mesmo código passa pelas duas superfícies. Se o mod usasse um elemento que o desktop não tem, a montagem do desktop recusaria a árvore e o teste falharia ali, não na mão do usuário.
✓ Ache assim
- ✓
Textpelo texto:{ type: 'Text', text: /usado/ } - ✓
ButtoneBoxpelakey - ✓ Na faixa, um
ui.renderdo teste por baixo devolvendo{ type: 'Text', children: [''] }(pegadinha 11)
✗ Não funciona
- ✗
Textpelakey: ela some na árvore desenhada (pegadinha 12) - ✗ Testar só no terminal e publicar para o desktop
- ✗ Esquecer o
unmount()entre as voltas
Leia uma falha de teste
Uma falha real, de 05/10/2026, nesta máquina. Rodando bash scripts/check.sh no kit da Prompt Advisers (validate e test de cada plugin), o resultado foi 123 de 124 testes passando. Todos os validates deram ok.
O que falhou: o terminal-pet, com 12 passando e 1 falhando. O teste "the whole sprite set stays far under the terminal's color-pair table" estourou o limite de 5 s com a máquina carregada. E o check.sh parou ali.
Ache o plugin e o teste
O check.sh abre com set -euo pipefail: no primeiro plugin que falha, o laço para, e o que vem depois dele (aqui, o starter-mod) não roda. Por isso o registro diz "parou no terminal-pet".
Separe tempo de asserção
Os tipos dizem: um teste falha quando lança, rejeita ou passa do seu tempo (5000 ms, ou timeoutMs). Aqui nenhum expect falhou; o tempo acabou.
Leia o corpo do teste
Ele não chama $: decodifica cada quadro do bichinho, a cada 10 ms ao longo de 6 s, para todos os humores e três níveis. É cálculo puro, e cálculo puro fica lento quando a CPU está ocupada.
Decida: bug ou ambiente?
Repita só aquele plugin com a máquina livre. Se passar, é ambiente. A correção mínima é dar tempo ao teste pesado: test(nome, { timeoutMs: 15000 }, corpo), sem mexer no mod.
✓ Sinais de tempo estourado
- ✓ Passa sozinho, falha com a máquina ocupada
- ✓ Corpo com laços grandes e nenhum
await - ✓ Nenhuma linha de
expectcitada
✗ Sinais de bug de verdade
- ✗ Falha igual toda vez
- ✗ Mostra o valor esperado e o recebido
- ✗ Mensagem nomeando um mock que falta
Teste rápido (opcional): o teste do Rafa falha com on(...) after the test first called $. O que ele corrige?
🎓 Resumo do módulo
Próximo módulo:
4.3 — Depurar e as pegadinhas