Claude Code modのtestを書く — スタブでイベントを発火して検証
Claude Code modを、セッション・サインイン・ネットワーク無しで検証する方法です。claude plugin testで$.tool.callや$.command.runを発火し、onのスタブで応答を差し替えます。
Claude Codeのmodは、claude plugin testで自動テストできます。テストはmodを読み込み、Claude Codeが送るはずのイベントをhookへ流し、hookが返した結果を確かめます。セッションもサインインもネットワークも要りません。
テストファイルは*.test.tsという名前で、claude-code/testingのテストキットを読み込みます。モデルやストアやツールは動かないので、modが「Claude Codeの答え」を待つ場所には、テスト側がスタブ(代役の応答)を用意します。この記事では、発火の書き方、スタブの返し方、タイマーと描画のテスト、最初につまずく規則を順に扱います。modの作り方そのものはClaude Codeのmodを80行で作るにあります。
最小のテスト — ツール呼び出しを2回発火して/tallyを確かめる
最初の例は、modが数えたツール呼び出しの回数を/tallyコマンドで返すかどうかの検証です。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) => {
// Claude Codeの代わりにツール呼び出しへ答える。実際のツールは動かない
on('tool.call', () => ({ result: 'ok' }))
// modのtool.call hookが数えるツール呼び出しを2回発火する
await $.tool.call({ tool: 'Bash', command: 'ls' })
await $.tool.call({ tool: 'Read', file_path: 'README.md' })
// /tallyを実行し、hookが返したテキストを確かめる
const answer = await $.command.run({ command: 'tally', args: '' })
expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})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 hookを通り、カウントを1つ増やしてからスタブへ渡ります。lsは実行されず、ファイルも読まれません。$.command.runはmodのcommand.run hookへ届き、answerはそのhookが返したオブジェクトです。
ここでの$は、modのhookが受け取るmods APIではなく、Claude Codeの役を演じるテスト専用の$です。メソッドごとに同名のイベントを発火します。
$.tool.call/$.command.run/$.prompt.submit/$.session.start/$.turn.complete$.classic.Stopなど$.classicのメソッドは、settingsのhookイベントを発火する
ui.closeのようなmods APIの呼び出しは、テストから直接は発火できません。閉じるボタンを押すなど、modを経由して起こします。
スタブはどの形で返すか — valueとresultとdeny
スタブはonで登録します。名前は$.を外して書くので、modの$.store.getに答えるスタブはon('store.get', …)です。
返す形は、何に答えるかで2系統に分かれます。
| 答える相手 | 返す形 | 例 |
|---|---|---|
modが呼ぶmods API($.store.getなど) | 返す形{ value: … } | 例{ value: 7 }で$.store.getが7に解決する |
Claude Codeのイベント(tool.callなど) | 返す形そのイベント自身の結果 | 例{ result: 'ok' } |
| 失敗させたい呼び出し | 返す形{ deny: '理由' } | 例modの呼び出しが拒否される |
$.model.completeを差し替える例を見ます。graderというmodが、/gradeで受け取った文をモデルに送り、返信がPASSで始まれば「Passed」を返します。
export function register(on) {
on('command.run', { command: 'grade' }, async ($, e) => {
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' }
})
}テストは、モデル呼び出しを固定の返信に置き換えます。呼び出し側の書き方はmodel.completeの記事にあります。
import { expect, test } from 'claude-code/testing'
test('a passing grade is reported', async ($, on) => {
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 },
},
}))
const answer = await $.command.run({ command: 'grade', args: 'The cat sat on the mat.' })
expect(answer.text).toBe('Passed')
})逆の分岐を確かめるには、textをFAILで始めたテストをもう1本足し、Try againを期待します。
スタブを間違えたときのエラー
失敗したテストの出力にはthe engine reported:で始まるブロックが付き、スタブの誤りはそこに出ます。
returned neither { value } nor { deny }: mods APIのスタブが、{ value }で包まずに値をそのまま返したno implementation forに続く名前: modがその呼び出しをしたのに、答えるスタブがない
mods APIのスタブは、返すものがなくても{ value: undefined }と書きます。$.command.register、$.tool.register、$.ui.toast、$.ui.log、$.ui.status、$.ui.close、$.store.setが該当します。
よく使う呼び出しのスタブ早見
modが呼ぶ、またはnext(e)で渡す先 | スタブの返し方 |
|---|---|
$.store.get | スタブの返し方($, e) => ({ value: saved.get(e.key) }) |
$.fs.read | スタブの返し方($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }) |
$.process.run | スタブの返し方($, e) => ({ value: { exitCode: 0, stdout: '…', stderr: '' } }) |
$.ui.open | スタブの返し方() => ({ value: { isPlaced: true } }) |
$.ui.ask | スタブの返し方tool.callのスタブ。質問はAskUserQuestionツールの呼び出しとして届く |
tool.call | スタブの返し方() => ({ result: '…' }) |
prompt.submit | スタブの返し方($, e) => ({ text: e.text }) |
session.start | スタブの返し方() => ({ cwd: '/work' }) |
turn.start | スタブの返し方($, e) => ({ turnId: e.turnId }) |
turn.complete | スタブの返し方() => ({ 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 | スタブの返し方() => ({ value: 'abc123' }) |
$.agent.list | スタブの返し方() => ({ value: [] }) |
session.send | スタブの返し方() => ({ isDelivered: true }) |
session.receive | スタブの返し方($, e) => ({ text: e.text }) |
下の4行は、他のセッションやエージェントとやりとりするmodで使います。session.sendのスタブでは、modが{ sessionId }で宛先を渡しても、e.toは文字列で届きます。session.receiveは、テスト側から$.session.receive({ origin: { kind: 'peer-send-message' }, text })で発火します。$.session.messages、$.prompt.read、$.ui.copyは、modが会話の履歴や入力欄、クリップボードに触れるときの代役です。
$.fs.readのe.pathは絶対パスで届くので、比較にはendsWithを使います。$.ui.askは専用のスタブではなくtool.callのスタブで受けます。mod内で他のツール呼び出しも通す場合は、先にe.toolを確かめてから答えを返します。
使える検証はexpectのtoBe、toEqual、toMatch、toMatchObject、toContain、toBeDefined、toBeUndefined、toThrowと、その前に付ける.notです。スタブやhookの中でexpectが外れると、そのhookはエンジンに飛ばされ、テストは失敗します。出力にはin the test's store.set hookのように対象が出ます。
用意済みのモック4種
時計・ストア・環境変数・会話への追記は、キットのモックで代用できます。
| モック | 何に答えるか |
|---|---|
mock.clock(on) | 何に答えるか$.clock。テストが進める時計を返す |
mock.store(on, { count: 7 }) | 何に答えるか$.store。初期値を持つストア |
mock.env(on, { CI: 'true' }) | 何に答えるか$.env.get |
mock.session(on) | 何に答えるか$.session.appendで足された行。appended()で古い順に読める |
mock.storeは何も返さないので、modが何を保存したかは読み出せません。保存内容を確かめたいときは、store.getとstore.setのスタブを自分で書きます。mock.sessionはClaude Code v2.1.293以降が必要です。$.session.appendを呼ぶmodでclaude plugin testが失敗していた問題は、この版で直り、追記された行をmock.sessionで読み戻せるようになりました(v2.1.293の変更点)。
最初につまずくテストキットの規則
初めてテストを書く人が踏みやすい規則が5つあります。
スタブは、$への最初の呼び出しより前にすべて登録します。後からonを呼ぶと、on("ui.render") after the test first called $のようなエラーで落ちます。
session.startは自動では走りません。テストごとにmodは読み込み直されます。hookが呼ばれた記録も、モジュール変数への書き込みも、初期状態に戻ります。session.startが用意するものに頼るhookは、先に発火します。
// next(e)でhookが渡したイベントへの答え
on('session.start', () => ({ cwd: '/work' }))
// hookが呼ぶ$.command.registerへの答え
on('command.register', () => ({ value: undefined }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })2つ目のスタブを書き忘れると、$.command.registerはno implementation for command.registerで拒否され、キットはそのhookを飛ばします。この時点ではテストは失敗しません。後続のチェックが落ちたときに、the engine reported:の中で飛ばされたhookとして初めて見えます。「何も起きないのにエラーもない」という症状の原因になりやすい箇所です。
next(e)を返すhookにも、答えるスタブが要ります。例えばui.renderが、Claudeが待機中は何も描かずnext(e)を返すとき、マウントするとno implementation for ui.renderで失敗します。Claude Codeが描く要素の代役を、プレーンなデータで返します。
on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))turn.stepのスタブは非同期ジェネレーターで書きます。yieldがモデルの返信の1片になり、returnが要求全体の結果です。テストは、ストリームを最後まで読んで結果を取り出します。
on('turn.step', async function* ($, e) {
yield { kind: 'text', index: 0, text: 'ok' }
return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: null }
})
const stream = $.turn.step({ turnId: 't', index: 0, model: 'claude-test', messageCount: 1 })
let step = await stream.next()
while (step.done !== true) step = await stream.next()
const result = step.valueループを抜けたresultは、modのturn.step hookが手を入れた後の結果です。この例ならresult.answerは'ok'です。
ツール呼び出しは、ツール名と引数をフィールドに並べて発火します。await $.tool.call({ tool: 'Bash', command: 'ls' })のように書き、{ result }を返すtool.callのスタブを置きます。
タイマーはmock.clockで進める
$.clock.everyのようにタイマーで動くmodは、実時間を待たずに時計を進めて検証します。mock.clock(on)が返す時計は0から始まり、テストが動かしたときだけ進みます。開始時刻はmock.clock(on, { now: 5000 })のようにミリ秒で渡せます。
| メソッド | 動き |
|---|---|
await clock.advance(1000) | 動き指定ミリ秒だけ進め、期限が来たタイマーを走らせる |
await clock.set(5000) | 動きその時刻まで進める |
clock.now() | 動き現在時刻。modの$.clock.now()が返す値 |
await clock.settle() | 動き時刻を動かさず、すでに期限の来たタイマーを走らせる |
await clock.sleep(2000) | 動きスタブの中で使い、テストがそこまで進めたときに初めて答える。遅いモデルやプロセスの再現に使う |
/countdown 3で1秒ごとのタイマーを起動し、0になったらトーストを出すmodを例にします。
export function register(on) {
on('command.run', { command: 'countdown' }, async ($, e) => {
let left = Number(e.args)
const timer = $.clock.every(1000, () => {
left -= 1
if (left === 0) {
timer.cancel()
$.ui.toast('Time is up')
}
})
return {}
})
}テストは3秒分の挙動を、待たずに確かめます。
import { expect, mock, test } from 'claude-code/testing'
test('the countdown ends with a toast', async ($, on) => {
const clock = mock.clock(on)
const toasts: string[] = []
on('ui.toast', ($, e) => {
toasts.push(e.text)
return { value: undefined }
})
await $.command.run({ command: 'countdown', args: '3' })
await clock.advance(2000)
expect(toasts).toEqual([])
await clock.advance(1000)
expect(toasts).toEqual(['Time is up'])
})1つ目のexpectでトーストが早く出ないこと、2つ目で1回だけ出ることを確かめています。advanceは期限が来たタイマーの実行を待って戻るので、次の行の検証にはその効果が反映されています。
描画は$.ui.mountで押して探す
paneやバンドの描画は、$.ui.mountでmodのui.render hookを通して描かせ、返ってきたハンドルで操作します。surfaceにアプリ名を渡せば、1つのテストでターミナルとDesktopアプリの両方を確かめられます。
import { expect, test } from 'claude-code/testing'
// ui.render hookに渡る、アプリ以外の入力
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) => {
// $.storeをMapで代用し、modが保存した値を読めるようにする
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 }
})
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
await ui.press({ key: 'tab-two' })
await ui.press({ key: 'more' })
expect(await ui.find({ type: 'Text', text: /^Count: \d+$/ })).toBeDefined()
await ui.unmount()
}
// 各アプリで1回ずつ押したので2
expect(saved.get('count')).toBe(2)
})2つのアプリで数えた値が合算されて2になるのは、どちらのマウントも同じ読み込み済みモジュールを使うからです。
ハンドルのメソッドは、modが付けた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() | 動き描画を取り除く |
どのメソッドも、modのハンドラーが終わってから戻ります。結果は次の行でそのまま確かめられます。
このテストが見るのは、hookが返した要素の木と、それがアプリに対して正しい形かどうかです。アプリが実際にどう塗るかは見ません。新しいレイアウトは、実際のセッションでも目で確かめます。ui.faultで描画の失敗を拾うmodの書き方は、ui.faultの記事にあります。
/clear後の描画を確かめる
テストごとに、$.stateの値は既定値で始まります。/clearの直後と同じ状態です。続きの挙動を見るには、session.startを飛ばして、source: 'clear'のclassic.SessionStartを発火します。上のhello-tabsのファイルに足す形で、保存済みの7が/clearの後に戻るかを検証できます。
test('the saved count comes back after /clear', async ($, on) => {
on('store.get', () => ({ value: 7 }))
on('classic.SessionStart', () => ({}))
await $.classic.SessionStart({ source: 'clear' })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
await ui.press({ key: 'tab-two' })
expect(await ui.find({ type: 'Text', text: 'Count: 7' })).toBeDefined()
})modにclassic.SessionStartのhookがなければ、paneはCount: 0を描き、findはundefinedを返してtoBeDefinedで落ちます。
ポリシーmodを検査する — tierとplugins
組織がprependPluginsに載せたmodは、他のmodを読み込み前に拒否できます。これを検査するには、自分のmodの階層と、許可・拒否される側の相手modをテストに渡します。次の例のacme-guardは拒否する側のポリシーmodで、このテストはacme-guard自身のtestsに置く想定です。
tier: ファイルの先頭でtier('prepend')のように呼ぶ。prepend、append、builtinから選び、省略時はuserとして読み込まれるplugins:testの第2引数にオプションとして渡す配列。インラインで書いたmod(nameとregister)を入れる。tierを足すと、user以外の階層で読み込める
import { expect, test, tier } from 'claude-code/testing'
tier('prepend')
// $.process.runを呼ぶので、ポリシーに拒否される側
const runner = {
name: 'runner',
register(on) {
on('tool.call', async ($, e, next) => {
await $.process.run(['ls'])
return { result: 'runner answered' }
})
},
}
// ポリシーが止める呼び出しをしない側
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 {
// $への最初の呼び出しでmodが読み込まれるので、拒否はここで投げられる
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' })
expect(out).toEqual({ result: 'reader answered' })
})キットは、テストの$への最初の呼び出しですべてのmodを読み込みます。拒否が起きるとその呼び出しが投げ、メッセージには拒否されたmod・拒否したmod・理由が並びます。2本目では何も拒否されないので、readerがスタブより先にツール呼び出しへ答えます。
CIに載せるときの注意
claude plugin testは、テストが1本でも落ちると終了コード1で終わるので、そのままCIのステップにできます。実行するディレクトリを引数に渡すこともでき、省略すると今いるディレクトリの*.test.tsと*.test.tsxを拾います。
CIで引っかかりやすい点は3つです。
- テストが0本のファイル:
declares no test(): nothing ranで失敗する。test()が1つも無いファイルは置けない - hooksモジュールが無効: 読み込めない環境では
claude plugin test: hooks modules are turned offで始まる1行と理由を出し、終了コード1で終わる - 5秒の制限: 1つのテストは、
timeoutMsを設定しない限り5秒で打ち切られる。遅いモデルの再現には、実時間を待たずclock.sleepを使う
テストファイルからは、modの自前のファイルや隣の.tsヘルパーを読み込めます。ゲームのルールのような通常の関数は、キットを使わず単体テストとして書けます。
まとめ
modのテストは、発火する側の$と、答える側のonの2つで組み立てます。mods APIのスタブは{ value }、Claude Codeのイベントのスタブはそのイベント自身の結果、失敗させたいときは{ deny }。この3つの形を取り違えたときに出るreturned neither { value } nor { deny }とno implementation forが、最初の詰まりどころです。
hookの追加と同時にsession.startやnext(e)の経路が増えるmodでは、スタブの書き漏れが「エラーなしで何も起きない」形で現れます。テストが静かに通るときほど、the engine reported:のブロックを失敗させて確かめる価値があります。modを配る前の検証には、claude plugin validateで呼び出し範囲を読んでからclaude plugin testで挙動を確かめる流れが組めます。テストが黙って通らないようにする変更は、v2.1.292の変更点に載っています。登録したhook内のexpectの失敗や、エンジンが拒否するスタブ応答は、この版から失敗として扱われます。