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

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' }) levanta tool.call. $.command.run, $.prompt.submit, $.session.start, e $.turn.complete funcionam da mesma forma, e $.classic.Stop e os outros métodos $.classic levantam um evento de hook de configurações. Um teste não pode levantar uma chamada de mods API como ui.close diretamente. 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 como store.get responde seu $.store.get do mod. Quando seu mod chama $.model.complete ou $.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 simples
  • no implementation for seguido 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 $. Chamar on depois disso lança um erro como on("ui.render") after the test first called $.

  • session.start nã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 que session.start configura, 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.register que um hook session.start como o do tutorial faz. Sem ele, essa chamada rejeita com no implementation for command.register e 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 sob the engine reported: apenas se uma verificação posterior falhar.

  • Um hook que retorna next(e) precisa de um stub para responder. Quando seu hook ui.render retorna next(e), por exemplo para não desenhar nada enquanto Claude está ocioso, montá-lo falha com no 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 retornou next(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.value
    

    Quando o loop termina, result é o objeto que o stub retornou, depois que seu hook turn.step teve a chance de alterá-lo. Aqui result.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 stub tool.call que 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 em tier('prepend'), para carregar seu mod como prepend, append, ou builtin, seu lugar na ordem que mods são executados. Sem ele, seu mod carrega como user.
  • plugins: passe test um objeto de opções antes do corpo do teste. Seu array plugins contém mods que você escreve inline, cada um com um name e uma função register. Para carregar um em algum lugar diferente de user, adicione tier a 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