SpyBara
Go Premium

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

This page contains 422 additions and 0 deletions.

2026
Thu 1 23:02

Tester un mod

Écrivez des tests automatisés pour un mod Claude Code qui lèvent des événements, remplacent les réponses de Claude Code et appuient sur des boutons, sans session, connexion ou réseau.

Vous pouvez écrire des tests automatisés pour un mod et les exécuter depuis votre shell avec claude plugin test. Un test lève les événements que vos hooks gèrent et vérifie ce que les hooks ont fait, afin que vous détectiez un problème avant qu'il n'atteigne une session. Le premier exemple teste le mod de Créer un mod.

Écrire un test

Un test charge votre mod, envoie des événements via ses hooks de la manière que Claude Code le ferait, et vérifie ce que les hooks ont fait, sans session, connexion ou réseau. Vous exécutez les tests depuis votre shell avec claude plugin test, et chaque fichier de test importe le kit de test, une bibliothèque de test dans le module claude-code/testing.

Donnez à chaque fichier de test un nom qui se termine par .test.ts, comme first-mod.test.ts, et enregistrez-le n'importe où dans le répertoire du plugin. Chaque fichier de test a besoin d'au moins un test(), sinon l'exécution échoue avec declares no test(): nothing ran. Un fichier de test peut importer vos propres fichiers du mod et les helpers .ts frères, afin que vous puissiez tester les fonctions simples, comme les règles d'un jeu, sans le kit.

Ce test lève deux appels d'outils, exécute la commande /tally de Créer un mod, et vérifie que la réponse compte les deux. Sa première ligne est un stub, qui répond aux appels d'outils à la place de Claude Code. Enregistrez-le sous 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')
})

Dans votre shell, exécutez les tests depuis le répertoire first-mod :

claude plugin test

La sortie nomme chaque test et s'il a réussi, avec des timings qui varient d'une exécution à l'autre :

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]

Chaque $.tool.call a traversé le hook tool.call du mod, qui a ajouté un à son compte et a transmis l'appel au stub. Aucun ls n'a été exécuté et aucun fichier n'a été lu. $.command.run a ensuite accédé au hook command.run du mod, et answer est l'objet que ce hook a retourné.

La commande se termine avec le statut 1 quand un test échoue, afin qu'elle fonctionne dans CI. Si vos propres mods ne peuvent pas se charger dans le shell qui l'exécute, il imprime une ligne commençant par claude plugin test: hooks modules are turned off avec la raison, et se termine avec le statut 1.

Remplacer ce que Claude Code répondrait

Aucun modèle, magasin ou outil ne s'exécute dans un test, donc partout où votre mod s'attend à ce que Claude Code réponde, le test fournit la réponse avec un stub. Une fonction de test reçoit deux arguments pour cela :

  • $ : le propre $ du test, qui se tient à la place de Claude Code. Ce n'est pas l'API des mods qu'un hook reçoit. Chacune de ses méthodes lève l'événement du même nom, l'envoie via les hooks de votre mod, et se résout au résultat : $.tool.call({ tool: 'Bash', command: 'ls' }) lève tool.call. $.command.run, $.prompt.submit, $.session.start, et $.turn.complete fonctionnent de la même manière, et $.classic.Stop et les autres méthodes $.classic lèvent un événement de hook de paramètres. Un test ne peut pas lever directement un appel d'API des mods comme ui.close. Déclenchez-le via votre mod, par exemple en appuyant sur le bouton qui ferme le volet.
  • on : appelez-le pour enregistrer des stubs, qui sont des hooks qui répondent à la place de Claude Code. Nommez un stub pour un appel d'API des mods sans le $., afin qu'un stub enregistré comme store.get réponde à votre mod $.store.get. Quand votre mod appelle $.model.complete ou $.store.get, un stub fournit la réponse.

Cet exemple remplace un appel de modèle. Le hook appartient à un mod nommé grader, et gère une commande /grade qui envoie une phrase à un modèle et rapporte si la réponse commence par PASS. Le fichier ne contient que le hook testé, donc le mod a également besoin d'un plugin.json et d'un hooks.json, comme dans Créer un mod. Pour taper /grade dans une session, le mod doit également enregistrer la commande :

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' }
  })
}

Ce test remplace l'appel de modèle pour vérifier ce que le hook fait avec une réponse réussie :

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')
})

Le test réussit parce que le reply du hook est l'objet sous value, dont le text commence par PASS. Pour vérifier l'autre branche, ajoutez un deuxième test dont le stub retourne un text qui commence par FAIL, et attendez Try again.

Un stub pour un appel d'API des mods retourne un objet avec un champ value, qui contient ce que l'appel se résout en dans votre mod : { value: 7 } fait que $.store.get se résout à 7. Un stub pour l'un des événements de Claude Code, comme turn.step ou tool.call, retourne le propre résultat de cet événement, comme { result: 'ok' }. $.session.send et $.prompt.fill prennent également le résultat de l'événement, comme le montre le tableau. Rechercher ce qu'un stub retourne montre quelle forme chaque nom courant prend. Deux erreurs signifient qu'un stub est incorrect ou manquant. La sortie d'un test échoué inclut un bloc intitulé the engine reported:, et chaque erreur y apparaît :

  • returned neither { value } nor { deny } : un stub pour un appel d'API des mods a retourné une valeur nue
  • no implementation for suivi d'un nom : votre mod a fait cet appel et aucun stub ne le répond

Le kit exporte également des mocks en mémoire qui répondent à un espace de noms entier pour vous. mock.clock(on) répond à $.clock, mock.store(on, { count: 7 }) répond à $.store à partir d'un magasin qui commence par ces entrées, et mock.env(on, { CI: 'true' }) répond à $.env.get à partir de ces variables. mock.clock retourne une horloge simulée que votre test avance, afin qu'un test d'une minuterie n'attende pas. mock.store ne retourne rien, donc pour vérifier ce que votre mod a enregistré, écrivez vous-même les deux stubs store comme le fait le test de dessin.

Suivre les règles du kit de test

Le kit de test a quelques règles qui lui sont propres, et en enfreindre une produit les erreurs que les nouveaux auteurs de tests rencontrent en premier :

  • Enregistrez chaque stub avant le premier appel du test sur $. Appeler on après cela lève une erreur comme on("ui.render") after the test first called $.

  • session.start ne s'exécute pas par lui-même. Chaque test commence avec votre module fraîchement chargé et aucun de ses hooks appelés, donc les variables au niveau du module conservent leurs valeurs initiales. Si un hook dépend de ce que session.start configure, levez-le d'abord :

    // 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' })
    

    Le deuxième stub répond à l'appel $.command.register qu'un hook session.start comme celui du tutoriel fait. Sans lui, cet appel rejette avec no implementation for command.register et le kit saute votre hook, donc rien après l'appel dans le hook ne s'exécute. Le test n'échoue pas à ce stade. Le hook ignoré est listé sous the engine reported: uniquement si une vérification ultérieure échoue.

  • Un hook qui retourne next(e) a besoin d'un stub pour répondre. Quand votre hook ui.render retourne next(e), par exemple pour ne rien dessiner pendant que Claude est inactif, le monter échoue avec no implementation for ui.render. Enregistrez un stub qui retourne un élément en tant que données simples :

    // Stands for what Claude Code would draw at the site
    on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))
    

    Avec le stub enregistré, le montage réussit, et ui.find({ type: 'Text' }) retourne cet élément chaque fois que votre hook a retourné next(e).

  • Un stub pour turn.step est un générateur asynchrone, et le test lit le flux jusqu'à la fin pour obtenir le résultat :

    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
    

    Quand la boucle se termine, result est l'objet que le stub a retourné, après que votre hook turn.step ait eu la chance de le modifier. Ici result.answer est 'ok'.

  • Levez un appel d'outil avec le nom et les arguments de l'outil en tant que champs, comme await $.tool.call({ tool: 'Bash', command: 'ls' }), et enregistrez un stub tool.call qui retourne { result }.

Rechercher ce qu'un stub retourne

Chaque appel d'API des mods que votre mod fait dans un test a besoin d'un stub qui répond à la place de Claude Code, sauf les quelques-uns que le kit répond lui-même : les appels $.ui.invalidate et $.state. Pour les appels $.clock, utilisez mock.clock(on), sinon votre mod $.clock.now() échoue avec no implementation for clock.now.

Ce tableau liste ceux que les mods utilisent le plus. La première colonne est l'appel que votre mod fait ou l'événement qu'il transmet avec next(e). La deuxième est la fonction à passer à on sous ce nom, afin que la ligne $.store.get devienne on('store.get', ($, e) => ({ value: saved.get(e.key) })). Un '...' dans un stub marque le texte pour vous à remplir :

Votre mod appelle ou transmet Stub
$.command.register, $.tool.register, $.ui.toast, $.ui.log, $.ui.status, $.ui.close, $.store.set () => ({ value: undefined }). Pour ui.toast et ui.log, le texte est e.text.
$.store.get ($, e) => ({ value: saved.get(e.key) })
$.fs.read ($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }). e.path arrive en tant que chemin absolu, donc comparez avec endsWith.
$.ui.open () => ({ value: { isPlaced: true } })
$.ui.ask Un stub tool.call, parce que la question l'atteint comme un appel à l'outil AskUserQuestion : ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). Vérifiez e.tool d'abord si votre mod transmet d'autres appels d'outils.
$.model.complete () => ({ value: { isAnswered: true, text: '...', usage } })
$.process.run ($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } }). e.argv est la liste des arguments et e.init contient cwd et timeoutMs.
Tout appel d'API des mods qui devrait échouer () => ({ deny: 'the reason' }), ce qui fait que l'appel rejette dans votre mod. Un stub qui lance une exception est ignoré à la place.
session.start () => ({ cwd: '/work' })
turn.start ($, e) => ({ turnId: e.turnId })
tool.call () => ({ result: '...' })
turn.complete () => ({ text: '' }). Levez-le avec $.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 arrive en tant que chaîne même quand votre mod a passé { sessionId }.
session.receive ($, e) => ({ text: e.text }). Levez-le avec $.session.receive({ origin: { kind: 'peer-send-message' }, text }).
ui.render () => ({ type: 'Text', props: {}, children: ['...'] })

expect a les assertions toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined, et toThrow, et .not avant n'importe lequel d'entre eux.

Tester une minuterie

Un mod qui exécute du travail sur une minuterie a besoin d'une horloge que le test contrôle, afin que le test puisse avancer le temps au lieu d'attendre. const clock = mock.clock(on) retourne une horloge simulée qui commence à 0 et ne se déplace que quand votre test la déplace. Pour commencer à un autre moment, passez-le en millisecondes, comme dans mock.clock(on, { now: 5000 }). L'horloge a ces méthodes :

Méthode Ce qu'elle fait
await clock.advance(1000) Avance le temps de ce nombre de millisecondes et exécute chaque minuterie qui arrive à échéance
await clock.set(5000) Avance le temps à cette valeur, comme advance le ferait
clock.now() Retourne l'heure, ce que votre mod $.clock.now() se résout à
await clock.settle() Exécute les minuteries qui sont déjà dues, comme une chaîne d'appels $.clock.after à délai zéro, sans déplacer le temps
await clock.sleep(2000) À l'intérieur d'un stub, fait que ce stub ne répond que quand le test a avancé jusque-là, ce qui est comment vous simulez un modèle ou un processus lent

Ce hook appartient à un mod nommé countdown, et gère une commande /countdown qui prend un nombre de secondes, démarre une minuterie $.clock.every d'une seconde, et affiche un toast à zéro. Comme avec grader, le fichier ne contient que le hook testé et n'enregistre pas la commande :

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 {}
  })
}

Ce test exécute /countdown 3 et déplace l'horloge simulée, afin qu'il vérifie trois secondes de comportement sans attendre trois secondes :

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'])
})

Le premier expect montre que le toast ne vient pas tôt, et le deuxième montre qu'il vient une fois. Chaque advance se résout après que les minuteries qui sont venues à échéance aient exécuté, afin que la vérification sur la ligne suivante voie leur effet.

Tester un dessin

Un test peut dessiner l'un de vos sites de rendu du mod, puis appuyer, taper et trouver les éléments qu'il a dessinés. $.ui.mount dessine le site via le hook ui.render de votre mod et retourne un handle avec une méthode pour chacun d'eux. Pour couvrir plusieurs applications dans un test, définissez surface sur l'application à dessiner. Ce test ouvre le volet de Construire un volet avec des onglets, bascule les onglets, appuie sur le bouton, et vérifie le compte dans le terminal et l'application 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)
})

Dans votre shell, exécutez claude plugin test depuis le répertoire hello-tabs. Le test réussit quand les deux applications dessinent la ligne de compte et le mod a enregistré 2. Le compte se reporte de la première application à la deuxième parce que les deux montages utilisent le même module chargé.

Le handle que $.ui.mount retourne a ces méthodes, qui adressent les éléments par la key que vous leur avez donnée :

Méthode Ce qu'elle fait
press({ key: 'more' }) Appuie sur le Button avec cette clé
input({ key: 'new-note', text: 'buy milk' }) Tape le texte dans l'Input avec cette clé et appuie sur Entrée. Ajoutez kind: 'change' pour taper sans soumettre.
select({ key: 'size', value: 'large' }) Choisit l'option avec cette valeur dans le Select avec cette clé
find({ key: 'more' }) ou find({ type: 'Text', text: 'Count: 2' }) Retourne le premier élément correspondant comme { type, props, children }, ou undefined. text peut être une chaîne ou une expression régulière.
unmount() Supprime le dessin

Chaque méthode se résout après que votre gestionnaire ait terminé, afin que vous puissiez vérifier le résultat sur la ligne suivante. Définissez props à ce que Claude Code passerait pour ce site. Le tableau des sites de rendu liste les props de chaque site, et les types pour votre build ont leurs types.

Un test de dessin vérifie l'arborescence que votre hook retourne et si elle est valide pour cette application. Il ne vérifie pas comment l'application la peint, donc regardez une nouvelle mise en page dans une vraie session aussi.

Tester un dessin après `/clear`

Chaque test commence avec chaque valeur $.state à sa valeur par défaut, ce qui est comment /clear les laisse. Pour tester ce que votre mod fait ensuite, ignorez session.start, levez classic.SessionStart avec source: 'clear', et vérifiez ce que votre mod dessine.

Ce test vérifie le module de Charger une valeur enregistrée à nouveau après /clear. Ajoutez-le au fichier de Tester un dessin, où PANE est défini. Le premier test de ce fichier s'attend à ce que le bouton enregistre le compte, comme le bouton dans Enregistrer à partir de plus d'une session le fait :

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()
})

Le test réussit quand votre hook classic.SessionStart a copié le 7 enregistré dans $.state avant que le volet ne se dessine. Sans ce hook dans votre module, le volet dessine Count: 0, find retourne undefined, et le test échoue à toBeDefined.

Tester un mod qui juge d'autres mods

Un mod que votre organisation liste dans prependPlugins peut refuser un autre mod avant qu'il ne se charge. Pour en tester un, définissez le tier de votre mod et donnez au test un deuxième mod pour que le vôtre admette ou refuse :

  • tier : appelez-le une fois en haut du fichier de test, comme dans tier('prepend'), pour charger votre mod comme prepend, append, ou builtin, sa place dans l'ordre dans lequel les mods s'exécutent. Sans lui, votre mod se charge comme user.
  • plugins : passez à test un objet d'options avant le corps du test. Son tableau plugins contient des mods que vous écrivez en ligne, chacun avec un name et une fonction register. Pour charger un ailleurs que user, ajoutez tier à celui-ci.

Ce fichier de test charge le mod de politique de la page d'administration d'abord. Il vérifie que le mod de politique refuse un mod qui démarre un processus et en admet un qui ne le fait pas :

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' })
})

Dans votre shell, exécutez claude plugin test depuis le répertoire acme-guard. Les deux tests réussissent avec le mod de politique comme la page d'administration le montre.

Le kit charge chaque mod au premier appel du test sur $. Quand votre mod en refuse un, cet appel lance une exception, et le message nomme le mod refusé, le mod qui l'a refusé, et votre raison. Dans le deuxième test rien n'est refusé, donc reader répond à l'appel d'outil avant qu'il n'atteigne le stub.

Étapes suivantes