mod をテストする
イベントを発火させ、Claude Code の応答をスタブ化し、ボタンを押す Claude Code mod の自動テストを、セッション、サインイン、ネットワークなしで作成します。
mod の自動テストを作成し、シェルから claude plugin test で実行できます。テストはフックが処理するイベントを発火させ、フックが何をしたかを確認するため、問題がセッションに到達する前に見つけられます。最初の例では、mod を作成するの mod をテストします。
テストを書く
テストは mod を読み込み、Claude Code と同じ方法でそのフックにイベントを送り、フックが何をしたかを確認します。セッション、サインイン、ネットワークは必要ありません。テストはシェルから claude plugin test で実行し、各テストファイルは claude-code/testing モジュールにあるテストライブラリであるテストキットをインポートします。
各テストファイルには first-mod.test.ts のように .test.ts で終わる名前を付け、プラグインディレクトリ内の任意の場所に保存します。すべてのテストファイルには少なくとも 1 つの test() が必要で、ない場合は declares no test(): nothing ran で実行が失敗します。テストファイルは mod 自身のファイルや同じ階層の .ts ヘルパーをインポートできるため、ゲームのルールのような単純な関数はキットを使わずに単体テストできます。
このテストは 2 つのツール呼び出しを発生させ、mod を作成するの /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' }))
// Fire 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 は mod の tool.call フックを通過し、フックはカウントに 1 を加えて呼び出しをスタブに渡しました。ls は実行されず、ファイルも読み込まれていません。次に $.command.run が mod の command.run フックに送られ、answer はそのフックが返したオブジェクトです。
テストが失敗するとコマンドはステータス 1 で終了するため、CI で使用できます。実行するシェルで自分の mod を読み込めない場合は、claude plugin test: hooks modules are turned off で始まる行を理由とともに出力し、ステータス 1 で終了します。
Claude Code の応答をスタブする
テストではモデル、ストア、ツールは一切実行されないため、mod が Claude Code からの応答を期待する箇所では、テストがスタブで応答を提供します。そのために、テスト関数は 2 つの引数を受け取ります。
$:Claude Code として振る舞う、テスト専用の$です。フックが受け取る mods API ではありません。各メソッドは同名のイベントを発生させ、それを mod のフックに通し、結果に解決されます。$.tool.call({ tool: 'Bash', command: 'ls' })はtool.callを発生させます。$.command.run、$.prompt.submit、$.session.start、$.turn.completeも同様に動作し、$.classic.Stopやその他の$.classicメソッドは設定フックイベントを発生させます。テストからui.closeのような mods API 呼び出しを直接発生させることはできません。ペインを閉じるボタンを押すなど、mod を通じてトリガーしてください。on:これを呼び出してスタブを登録します。スタブは Claude Code の代わりに応答するフックです。mods API 呼び出し用のスタブには$.を付けずに名前を付けます。たとえばstore.getとして登録したスタブは、mod の$.store.getに応答します。mod が$.model.completeや$.store.getを呼び出すと、スタブが応答を提供します。
この例はモデル呼び出しをスタブします。このフックは grader という名前の mod に属し、文をモデルに送って応答が PASS で始まるかどうかを報告する /grade コマンドを処理します。このファイルにはテスト対象のフックしか含まれていないため、mod を作成すると同様に、mod には plugin.json と hooks.json も必要です。セッションで /grade と入力できるようにするには、mod でコマンドを登録する必要もあります。
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 フィールドを持つオブジェクトを返し、そのフィールドには mod 内で呼び出しが解決される値が入ります。{ value: 7 } は $.store.get を 7 に解決させます。turn.step や tool.call のような Claude Code のイベント用のスタブは、{ result: 'ok' } のように、そのイベント自身の結果を返します。表に示すとおり、$.session.send と $.prompt.fill もイベントの結果を受け取ります。一般的な名前ごとにどちらの形式を取るかは、スタブが返す値を調べるに示しています。次のエラーは、スタブが間違っているか欠けていることを意味します。失敗したテストの出力には the engine reported: という見出しのブロックが含まれ、各エラーはそこに表示されます。
returned neither { value } nor { deny }:mods API 呼び出し用のスタブが値をそのまま返しましたno implementation forの後に名前が続くもの:mod がその呼び出しを行いましたが、応答するスタブがありません
キットは、名前空間全体に応答するインメモリのモックもエクスポートしています。mock.clock(on) は $.clock に応答し、mock.store(on, { count: 7 }) はそれらのエントリから始まるストアで $.store に応答し、mock.env(on, { CI: 'true' }) はそれらの変数で $.env.get に応答します。mock.clock はテストが進めるモッククロックを返すため、タイマーのテストで待つ必要はありません。mock.store は何も返さないため、mod が保存した内容を確認するには、描画のテストのように 2 つの store スタブを自分で書いてください。
テストキットのルールに従う
テストキットには独自のルールがいくつかあり、これに違反すると、テストを書き始めたばかりのユーザーが最初に遭遇するエラーが発生します。
-
テストで
$を最初に呼び出す前に、すべてのスタブを登録します。 その後にonを呼び出すと、on("ui.render") after the test first called $のようなエラーがスローされます。 -
session.startは自動では実行されません。 各テストはモジュールが新たに読み込まれ、フックが 1 つも呼び出されていない状態で始まるため、モジュールレベルの変数は初期値を保持しています。フックが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 })) // Fire 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)を返すフックには、応答するスタブが必要です。ui.renderフックが、たとえば Claude がアイドル状態のときに何も描画しないためにnext(e)を返す場合、それをマウントすると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'] }))スタブを登録するとマウントが成功し、フックが
next(e)を返したときには必ずui.find({ type: 'Text' })がその要素を返します。 -
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 } }) // Fire 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スタブを登録します。
スタブが返す値を調べる
テストで mod が行うすべての mods API 呼び出しには、Claude Code の代わりに応答するスタブが必要です。例外は、キット自身が応答する少数の呼び出し、つまり $.ui.invalidate と $.state の呼び出しです。$.clock の呼び出しには mock.clock(on) を使用してください。そうしないと、mod の $.clock.now() が no implementation for clock.now で失敗します。
この表は、mod が最もよく使用するものを示しています。1 列目は、mod が行う呼び出し、または next(e) で渡すイベントです。2 列目はその名前で on に渡す関数で、たとえば $.store.get の行は on('store.get', ($, e) => ({ value: saved.get(e.key) })) になります。スタブ内の '...' は、ユーザーが記入するテキストを示します。
| mod が呼び出す、または渡すもの | スタブ |
|---|---|
$.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 |
質問は AskUserQuestion ツールへの呼び出しとして届くため、tool.call スタブを使用します:($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } })。mod がほかのツール呼び出しも渡す場合は、最初に 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' })。これにより mod 内で呼び出しが拒否されます。スローするスタブは代わりにスキップされます。 |
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 })。mod が { sessionId } を渡した場合でも、e.to は文字列として渡されます。 |
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 を付けられます。
タイマーをテストする
タイマーで処理を実行する mod には、テストが制御できるクロックが必要です。そうすれば、テストは待つ代わりに時間を進められます。const clock = mock.clock(on) は、0 から始まり、テストが動かしたときにだけ進むモッククロックを返します。別の時刻から始めるには、mock.clock(on, { now: 5000 }) のようにミリ秒単位で渡します。クロックには次のメソッドがあります。
| メソッド | 動作 |
|---|---|
await clock.advance(1000) |
指定したミリ秒だけ時間を進め、期限が来た各タイマーを実行します |
await clock.set(5000) |
advance と同様に、その値まで時間を進めます |
clock.now() |
時刻を返します。これは mod の $.clock.now() が解決される値です |
await clock.settle() |
遅延ゼロの $.clock.after 呼び出しの連鎖など、すでに期限が来ているタイマーを、時間を進めずに実行します |
await clock.sleep(2000) |
スタブ内で使用すると、テストがそこまで時間を進めたときにだけそのスタブが応答するようになります。これにより、遅いモデルやプロセスをシミュレートできます |
このフックは countdown という名前の mod に属し、秒数を受け取る /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 は期限が来たタイマーの実行後に解決されるため、次の行のチェックではその結果を確認できます。
描画をテストする
テストでは、mod の描画箇所のいずれかを描画し、描画された要素を押したり、入力したり、検索したりできます。$.ui.mount は mod の 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 を実行します。両方のアプリがカウント行を描画し、mod が 2 を保存していれば、テストは成功します。両方のマウントが同じ読み込み済みモジュールを使用するため、カウントは最初のアプリから 2 番目のアプリへ引き継がれます。
$.ui.mount が返すハンドルには次のメソッドがあり、要素に付けた key で要素を指定します:
| メソッド | 動作 |
|---|---|
press({ key: 'more' }) |
そのキーを持つ Button を押します |
input({ key: 'new-note', text: 'buy milk' }) |
そのキーを持つ Input にテキストを入力し、Enter を押します。送信せずに入力するには kind: 'change' を追加します。 |
select({ key: 'size', value: 'large' }) |
そのキーを持つ Select で、その値のオプションを選択します |
find({ key: 'more' }) または find({ type: 'Text', text: 'Count: 2' }) |
最初に一致した要素を { type, props, children } として返すか、undefined を返します。text には文字列または正規表現を指定できます。 |
unmount() |
描画を削除します |
各メソッドはハンドラーの処理が完了した後に解決されるため、次の行で結果を確認できます。props には、その箇所に対して Claude Code が渡す値を設定します。描画箇所の表に各箇所の props が記載されており、ビルドの型にそれらの型が含まれています。
描画テストでは、フックが返すツリーと、それがそのアプリにとって有効かどうかを確認します。アプリが実際にどのように表示するかは確認しないため、新しいレイアウトは実際のセッションでも確認してください。
`/clear` の後の描画をテストする
各テストは、すべての $.state の値がデフォルトの状態で開始されます。これは /clear の後と同じ状態です。mod がその次に何をするかをテストするには、session.start をスキップし、source: 'clear' を指定して classic.SessionStart を発火させ、mod が何を描画するかを確認します。
このテストは、/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', () => ({}))
// Fire the event that follows /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 で失敗します。
ポリシー mod をテストする
組織が prependPlugins に登録している mod は、別の mod を読み込まれる前に拒否できます。これをテストするには、自分の mod の階層を設定し、その mod が許可または拒否する対象となる 2 つ目の mod をテストに渡します。
tier: テストファイルの先頭でtier('prepend')のように 1 回呼び出すと、自分の mod をprepend、append、builtinのいずれかとして読み込みます。これは mod が実行される順序における位置です。これを指定しない場合、mod はuserとして読み込まれます。plugins: テスト本体の前に、オプションオブジェクトをtestに渡します。そのplugins配列には、インラインで記述した mod を入れます。各 mod にはnameとregister関数を持たせます。user以外の階層で読み込むには、その mod にtierを追加します。
このテストファイルは、管理ページのポリシー mod を最初に読み込みます。そして、ポリシー mod がプロセスを開始する mod を拒否し、プロセスを開始しない mod を許可することを確認します。
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 を実行します。ポリシー mod が管理ページに示されているとおりであれば、両方のテストが成功します。
キットは、テスト内で $ が最初に呼び出された時点ですべての mod を読み込みます。自分の mod がいずれかの mod を拒否すると、その呼び出しが例外をスローし、メッセージには拒否された mod、それを拒否した mod、そして指定した理由が示されます。2 つ目のテストでは何も拒否されないため、ツール呼び出しがスタブに届く前に reader が応答します。
次のステップ
- mod のトラブルシューティング:セッション内で mod が何もしない原因を調べます
- mod リファレンス:スタブを作成するための、各イベントの入力と結果