モッドをテストする
イベントを発生させ、Claude Code の回答をスタブし、ボタンを押すモッドの自動テストを書きます。セッション、サインイン、ネットワークは不要です。
モッドの自動テストを書いて、シェルから claude plugin test で実行できます。テストはフックが処理するイベントを発生させ、フックが何をしたかをチェックするため、セッションに到達する前に問題を見つけることができます。最初の例は モッドを作成する のモッドをテストします。
テストを書く
テストはモッドを読み込み、Claude Code が行うようにイベントをフックを通して送信し、セッション、サインイン、ネットワークなしでフックが何をしたかをチェックします。テストはシェルから claude plugin test で実行し、各テストファイルはテストキット(claude-code/testing モジュール内のテストライブラリ)をインポートします。
各テストファイルに .test.ts で終わる名前(例:first-mod.test.ts)を付け、プラグインディレクトリ内のどこかに保存します。すべてのテストファイルには少なくとも 1 つの test() が必要です。そうでないと、declares no test(): nothing ran で実行が失敗します。テストファイルはモッド自体のファイルと兄弟の .ts ヘルパーをインポートできるため、ゲームのルールなどのプレーン関数をキットなしでユニットテストできます。
このテストは 2 つのツール呼び出しを発生させ、モッドを作成する の /tally コマンドを実行し、返信が両方をカウントしていることをチェックします。最初の行は スタブ で、Claude Code の代わりにツール呼び出しに答えます。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')
})
シェルで first-mod ディレクトリからテストを実行します:
claude plugin test
出力は各テストとそれが成功したかどうかを名前で示し、実行ごとに異なるタイミングを表示します:
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]
各 $.tool.call はモッドの tool.call フックを通過し、カウントに 1 を追加してスタブに呼び出しを渡しました。ls は実行されず、ファイルは読み込まれませんでした。$.command.run はモッドの command.run フックに移動し、answer はそのフックが返したオブジェクトです。
テストが失敗すると、コマンドはステータス 1 で終了するため、CI で機能します。独自のモッドがそれを実行するシェルで読み込めない場合、claude plugin test: hooks modules are turned off で始まる行と理由を出力し、ステータス 1 で終了します。
Claude Code が答えるものをスタブする
テストではモデル、ストア、またはツールが実行されないため、モッドが Claude Code の回答を期待する場所では、テストはスタブで回答を提供します。テスト関数はそのために 2 つの引数を受け取ります:
$: テスト独自の$で、Claude Code が立つ場所に立ちます。これはフックが受け取る mods API ではありません。各メソッドは同じ名前のイベントを発生させ、モッドのフックを通して送信し、結果に解決します:$.tool.call({ tool: 'Bash', command: 'ls' })はtool.callを発生させます。$.command.run、$.prompt.submit、$.session.start、$.turn.completeは同じように機能し、$.classic.Stopと他の$.classicメソッドは 設定フックイベント を発生させます。テストはui.closeなどの mods API 呼び出しを直接発生させることはできません。モッドを通してトリガーします。例えば、ペインを閉じるボタンを押します。on: スタブを登録するために呼び出します。スタブは Claude Code の代わりに答えるフックです。mods API 呼び出しの$.なしでスタブに名前を付けます。そのため、store.getとして登録されたスタブはモッドの$.store.getに答えます。モッドが$.model.completeまたは$.store.getを呼び出すと、スタブが回答を提供します。
この例はモデル呼び出しをスタブします。フックは grader という名前のモッドに属し、文をモデルに送信して返信が PASS で始まるかどうかを報告する /grade コマンドを処理します。ファイルはテスト中のフックのみを保持するため、モッドは モッドを作成する のように plugin.json と hooks.json も必要です。セッションで /grade と入力するには、モッドは コマンドを登録 する必要があります:
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' }
})
}
このテストはモデル呼び出しをスタブして、フックが成功した返信で何をするかをチェックします:
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')
})
テストは成功します。フックの reply は value の下のオブジェクトで、その text は PASS で始まるためです。他のブランチをチェックするには、スタブが FAIL で始まる text を返す 2 番目のテストを追加し、Try again を期待します。
mods API 呼び出しのスタブは value フィールドを持つオブジェクトを返します。これはモッドで呼び出しが解決するものを保持します:{ value: 7 } は $.store.get を 7 に解決させます。turn.step または tool.call などの Claude Code のイベントのスタブは、そのイベント独自の結果({ result: 'ok' } など)を返します。$.session.send と $.prompt.fill はイベントの結果もテーブルが示すように取ります。スタブが返すものを調べる は各一般的な名前がどの形式を取るかを示します。2 つのエラーはスタブが間違っているか不足していることを意味します。失敗したテストの出力には the engine reported: で始まるブロックが含まれ、各エラーがそこに表示されます:
returned neither { value } nor { deny }: mods API 呼び出しのスタブが裸の値を返したno implementation forの後に名前が続く:モッドがその呼び出しを行い、スタブがそれに答えない
キットはまた、メモリ内モックをエクスポートします。これはネームスペース全体に答えます。mock.clock(on) は $.clock に答え、mock.store(on, { count: 7 }) はそれらのエントリで始まるストアから $.store に答え、mock.env(on, { CI: 'true' }) はそれらの変数から $.env.get に答えます。mock.clock はモッククロックを返し、テストはそれを進めるため、タイマーのテストは待機しません。mock.store は何も返さないため、モッドが何を保存したかをチェックするには、描画テスト が行うように 2 つの store スタブを自分で書きます。
テストキットのルールに従う
テストキットには独自のルールがいくつかあり、1 つを破ると新しいテスト作成者が最初に遭遇するエラーが生成されます:
-
$の最初の呼び出しの前にすべてのスタブを登録します。 その後にonを呼び出すと、on("ui.render") after the test first called $などのエラーがスローされます。 -
session.startは単独では実行されません。 各テストはモジュールが新しく読み込まれた状態で開始され、フックは呼び出されないため、モジュールレベルの変数は初期値を保持します。フックがsession.startが設定するものに依存する場合、最初にそれを発生させます:// 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' })2 番目のスタブは、
session.startフック(例えば チュートリアル のもの)が行う$.command.register呼び出しに答えます。それなしでは、その呼び出しはno implementation for command.registerで拒否され、キットはフックをスキップするため、フック内の呼び出しの後の何も実行されません。テストはその時点で失敗しません。スキップされたフックは、後のチェックが失敗した場合にのみthe engine reported:の下にリストされます。 -
next(e)を返すフックにはスタブが必要です。 例えば、Claude がアイドル状態の間は何も描画しないためにnext(e)を返すui.renderフックは、マウント がno implementation for ui.renderで失敗します。プレーンデータとして要素を返すスタブを登録します:// Stands for what Claude Code would draw at the site on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))スタブが登録されると、マウントが成功し、
ui.find({ type: 'Text' })はフックがnext(e)を返すたびにその要素を返します。 -
turn.stepのスタブは非同期ジェネレータです。テストはストリームを最後まで読んで結果を取得します: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ループが終了すると、
resultはスタブが返したオブジェクトで、モッドのturn.stepフックが変更する機会を持った後です。ここでresult.answerは'ok'です。 -
ツール呼び出しをツールの名前と引数をフィールドとして発生させます。例えば
await $.tool.call({ tool: 'Bash', command: 'ls' })、{ result }を返すtool.callスタブを登録します。
スタブが返すものを調べる
モッドがテストで行う mods API 呼び出しはすべて、キットが自分で答える少数を除いて、スタブが答える必要があります:$.ui.invalidate と $.state 呼び出し。$.clock 呼び出しの場合、mock.clock(on) を使用するか、モッドの $.clock.now() は no implementation for clock.now で失敗します。
このテーブルはモッドが最も使用するものをリストします。最初の列はモッドが行う呼び出しまたは next(e) で渡すイベントです。2 番目はその名前の下で on に渡す関数です。そのため、$.store.get 行は on('store.get', ($, e) => ({ value: saved.get(e.key) })) になります。スタブ内の '...' は入力するテキストをマークします:
| モッドが呼び出すまたは渡すもの | スタブ |
|---|---|
$.command.register、$.tool.register、$.ui.toast、$.ui.log、$.ui.status、$.ui.close、$.store.set |
() => ({ value: undefined })。ui.toast と ui.log の場合、テキストは e.text です。 |
$.store.get |
($, e) => ({ value: saved.get(e.key) }) |
$.fs.read |
($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' })。e.path は絶対パスとして到着するため、endsWith と比較します。 |
$.ui.open |
() => ({ value: { isPlaced: true } }) |
$.ui.ask |
tool.call スタブ。質問は AskUserQuestion ツールへの呼び出しとして到着するため:($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } })。モッドが他のツール呼び出しを渡す場合は最初に e.tool をチェックします。 |
$.model.complete |
() => ({ value: { isAnswered: true, text: '...', usage } }) |
$.process.run |
($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } })。e.argv は引数リストで、e.init は cwd と timeoutMs を保持します。 |
| 失敗すべき mods API 呼び出し | () => ({ deny: 'the reason' })。これはモッドで呼び出しを拒否させます。スローするスタブはスキップされます。 |
session.start |
() => ({ cwd: '/work' }) |
turn.start |
($, e) => ({ turnId: e.turnId }) |
tool.call |
() => ({ result: '...' }) |
turn.complete |
() => ({ text: '' })。$.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 はモッドが { sessionId } を渡した場合でも文字列として到着します。 |
session.receive |
($, e) => ({ text: e.text })。$.session.receive({ origin: { kind: 'peer-send-message' }, text }) で発生させます。 |
ui.render |
() => ({ type: 'Text', props: {}, children: ['...'] }) |
expect には toBe、toEqual、toMatch、toMatchObject、toContain、toBeDefined、toBeUndefined、toThrow のアサーションがあり、それらのいずれかの前に .not があります。
タイマーをテストする
タイマーで作業を実行するモッドには、テストが制御するクロックが必要です。そのため、テストは待機する代わりに時間を前に進めることができます。const clock = mock.clock(on) は 0 で開始し、テストが移動するときのみ移動するモッククロックを返します。別の時間で開始するには、ミリ秒で渡します。例えば mock.clock(on, { now: 5000 }) のように。クロックには次のメソッドがあります:
| メソッド | 何をするか |
|---|---|
await clock.advance(1000) |
時間をそのミリ秒数だけ前に移動し、期限が来たタイマーを実行します |
await clock.set(5000) |
時間をその値に移動します。advance のように |
clock.now() |
時間を返します。これはモッドの $.clock.now() が解決するものです |
await clock.settle() |
既に期限が来ているタイマー(例えば、ゼロ遅延の $.clock.after 呼び出しのチェーン)を実行します。時間を移動しません |
await clock.sleep(2000) |
スタブ内で、テストがそこまで進むまでそのスタブのみが答えるようにします。これは遅いモデルまたはプロセスをシミュレートする方法です |
このフックは countdown という名前のモッドに属し、秒数を取る /countdown コマンドを処理し、1 秒の $.clock.every タイマーを開始し、ゼロでトーストを表示します。grader と同様に、ファイルはテスト中のフックのみを保持し、コマンドを登録しません:
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 {}
})
}
このテストは /countdown 3 を実行し、モッククロックを移動するため、3 秒間の動作をチェックします。3 秒待つ必要はありません:
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'])
})
最初の expect はトーストが早く来ないことを示し、2 番目はそれが 1 回来ることを示します。各 advance は期限が来たタイマーが実行された後に解決するため、次の行のチェックはそれらの効果を見ます。
描画をテストする
テストはモッドの レンダリングサイト の 1 つを描画し、要素を押し、入力し、見つけることができます。$.ui.mount はサイトをモッドの ui.render フックを通して描画し、それぞれのメソッドを持つハンドルを返します。1 つのテストで複数のアプリをカバーするには、surface をアプリに設定して描画します。このテストは タブを使用してペインを構築する からペインを開き、タブを切り替え、ボタンを押し、ターミナルと 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)
})
シェルで hello-tabs ディレクトリから claude plugin test を実行します。テストは両方のアプリがカウント行を描画し、モッドが 2 を保存したときに成功します。最初のアプリから 2 番目のアプリへのカウントは、両方のマウントが同じ読み込まれたモジュールを使用するため、引き継がれます。
$.ui.mount が返すハンドルには次のメソッドがあり、モッドが与えた key で要素をアドレス指定します:
| メソッド | 何をするか |
|---|---|
press({ key: 'more' }) |
その key を持つ Button を押します |
input({ key: 'new-note', text: 'buy milk' }) |
テキストを key を持つ Input に入力し、Enter を押します。kind: 'change' を追加して、送信せずに入力します。 |
select({ key: 'size', value: 'large' }) |
その key を持つ Select でその値を持つオプションを選択します |
find({ key: 'more' }) または find({ type: 'Text', text: 'Count: 2' }) |
最初にマッチする要素を { type, props, children } として返すか、undefined を返します。text は文字列または正規表現です。 |
unmount() |
描画を削除します |
各メソッドはハンドラーが完了した後に解決するため、次の行で結果をチェックできます。props を Claude Code がそのサイトに渡すものに設定します。レンダリングサイトテーブル は各サイトの props をリストし、ビルドのタイプ はそれらのタイプを持ちます。
描画テストはフックが返すツリーをチェックし、そのアプリに対して有効かどうかをチェックします。アプリがそれをどのように描画するかはチェックしないため、実際のセッションで新しいレイアウトを見てください。
`/clear` の後に描画をテストする
各テストはすべての $.state 値がデフォルトで開始します。これは /clear がそれらを残す方法です。モッドが次に何をするかをテストするには、session.start をスキップし、source: 'clear' で classic.SessionStart を発生させ、モッドが描画するものをチェックします。
このテストは 保存された値を /clear の後に再度読み込む からモジュールをチェックします。描画をテストする からファイルに追加します。ここで PANE が定義されています。そのファイルの最初のテストはボタンがカウントを保存することを期待します。複数のセッションから保存する のボタンのように:
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()
})
テストは、モッドの classic.SessionStart フックがペインが描画される前に保存された 7 を $.state にコピーしたときに成功します。モジュールにそのフックがない場合、ペインは Count: 0 を描画し、find は undefined を返し、テストは toBeDefined で失敗します。
他のモッドを判断するモッドをテストする
組織が prependPlugins にリストするモッドは、別のモッドが読み込まれる前にそれを拒否できます。1 つをテストするには、モッドのティアを設定し、テストに 2 番目のモッドを与えて、モッドが許可または拒否します:
tier: テストファイルの上部で 1 回呼び出します。例えばtier('prepend')のように。モッドをprepend、append、またはbuiltinとして読み込みます。これは モッドが実行される順序 でのその場所です。それなしでは、モッドはuserとして読み込まれます。plugins: テスト本体の前にテストにオプションオブジェクトを渡します。そのplugins配列は、nameとregister関数を持つ、インラインで書いたモッドを保持します。別の場所に 1 つを読み込むには、tierを追加します。
このテストファイルは 管理ページからのポリシーモッド を最初に読み込みます。ポリシーモッドがプロセスを開始するモッドを拒否し、そうでないモッドを許可することをチェックします:
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' })
})
シェルで acme-guard ディレクトリから claude plugin test を実行します。両方のテストは管理ページが示すようにポリシーモッドで成功します。
キットはテストの最初の $ 呼び出しですべてのモッドを読み込みます。モッドが 1 つを拒否すると、その呼び出しはスローされ、メッセージは拒否されたモッド、それを拒否したモッド、および理由を名前で示します。2 番目のテストでは何も拒否されないため、reader はスタブに到達する前にツール呼び出しに答えます。
次のステップ
- モッドをトラブルシューティングする:モッドがセッションで何もしない理由を見つけます
- モッドリファレンス:スタブを書くための各イベントの入力と結果