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èvetool.call.$.command.run,$.prompt.submit,$.session.start, et$.turn.completefonctionnent de la même manière, et$.classic.Stopet les autres méthodes$.classiclèvent un événement de hook de paramètres. Un test ne peut pas lever directement un appel d'API des mods commeui.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é commestore.getréponde à votre mod$.store.get. Quand votre mod appelle$.model.completeou$.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 nueno implementation forsuivi 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
$. Appeleronaprès cela lève une erreur commeon("ui.render") after the test first called $. -
session.startne 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 quesession.startconfigure, 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.registerqu'un hooksession.startcomme celui du tutoriel fait. Sans lui, cet appel rejette avecno implementation for command.registeret 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é sousthe 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 hookui.renderretournenext(e), par exemple pour ne rien dessiner pendant que Claude est inactif, le monter échoue avecno 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.stepest 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.valueQuand la boucle se termine,
resultest l'objet que le stub a retourné, après que votre hookturn.stepait eu la chance de le modifier. Iciresult.answerest'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 stubtool.callqui 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 danstier('prepend'), pour charger votre mod commeprepend,append, oubuiltin, sa place dans l'ordre dans lequel les mods s'exécutent. Sans lui, votre mod se charge commeuser.plugins: passez àtestun objet d'options avant le corps du test. Son tableaupluginscontient des mods que vous écrivez en ligne, chacun avec unnameet une fonctionregister. Pour charger un ailleurs queuser, ajouteztierà 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
- Dépanner un mod : découvrez pourquoi un mod ne fait rien dans une session
- Référence des mods : chaque événement d'entrée et résultat, pour écrire des stubs