Claude Media
Claude Code modのtestを書く — スタブでイベントを発火して検証

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の失敗や、エンジンが拒否するスタブ応答は、この版から失敗として扱われます。

この記事を共有:XはてブLinkedIn