Claude Codeでテスト駆動開発(TDD)を回す手順 — 失敗するテストからStop hookでの締め方まで
Claude CodeでのTDDは、失敗するテストを先に用意し、その合否をClaude自身が読める状態にすることから始まります。プロンプトの型、/goalとStop hookでの締め方、つまずきやすい点をまとめます。
Claude CodeのTDDは合否の判定をClaudeに渡すこと
Claude Codeにおけるテスト駆動開発とは、実装より先に失敗するテストを用意し、その合否をClaude自身が読み取れる状態にしたうえで実装を任せる進め方です。テストを先に書く点は人間が回すTDDと変わりません。ただし狙いが違います。人間のTDDが設計のリズムを作るためのものだとすれば、こちらはClaudeが「終わった」と判断する基準を外側から与えるためのものです。
理由ははっきりしています。Claudeは作業が完了したように見えた時点で止まります。自分で走らせて結果を読める検証手段がないと、「完了したように見えるか」が唯一の判断材料になります。すると間違いは人間が気づくまで待つことになり、検証ループの担当が人間に戻ってきます。合否を返す仕組みを渡すと、このループは閉じます。実装し、検証を走らせ、結果を読み、通るまで直す。ここまでが手を離れます。
検証手段はテストである必要すらありません。ビルドの終了コード、リンター、出力をフィクスチャと突き合わせるスクリプト、デザインと比較するブラウザーのスクリーンショット。会話のなかでClaudeが読み取れるシグナルを返すものなら、どれも同じ役割を果たします。そのなかでテストが扱いやすいのは、粒度を細かく刻めることと、失敗メッセージがそのまま次の指示になることです。Android開発ではgradleのビルド終了コードが同じ役割を担い、Claude CodeでJetpack Compose開発を進める手順で扱っています。
始める前に用意するもの
前提は4つです。どれも数分で片付きますが、抜けると後の手順が空振りします。
-
テストが1コマンドで走ること。
npm testでもpytest -qでも構いませんが、引数なしで走って終了コードを返す形にしておきます。Claudeが繰り返し叩くのはこのコマンドです。手順が複数に分かれていると、そのたびに判断が挟まって精度が落ちます。Kotlin Multiplatformプロジェクトなら./gradlew allTestsが同じ役割を果たします(Claude CodeでKotlin Multiplatformを使うを参照)。 -
テストコマンドをCLAUDE.mdに書くこと。CLAUDE.mdは会話の開始時に毎回読み込まれるファイルで、Claudeがコードから推測できない情報を置く場所です。
/initを実行すると、ビルドシステムやテストフレームワークを検出した雛形が生成されます。読み込まれたかどうかは/contextで確認できます。
書く内容は絞ります。長いCLAUDE.mdは肝心の指示が埋もれて無視されるので、各行について「これを消したらClaudeが間違えるか」を基準に判断します。テスト周りなら、この程度で足ります。
# テスト
- テストは `npm test` で走らせる
- 全体ではなく単一ファイルのテストを優先する(速度のため)
- 新しいテストはモックを使わず実物で書く-
テストコマンドの実行を許可しておくこと。既定では、システムを変更しうる操作のたびに承認を求められます。テストは何十回も走るので、
/permissionsでテストコマンドを許可リストに入れるか、承認そのものを分類器に任せる自動モードを使います。 -
締め方を使うならバージョンを確認すること。後述する
/goalはv2.1.139以降で使えます。claude --versionで確認できます。
手順1:失敗するテストだけを書かせる
最初のターンでは、テストだけを書かせて実装には手を付けさせません。ここを曖昧にすると、テストと実装が同じターンで生まれ、テストが実装に引きずられます。
src/auth/session.ts のセッション期限切れ時の挙動にテストを書いてください。
期限切れトークンでrefreshを呼ぶと AuthExpiredError が投げられ、
既存セッションが破棄されることを確認したい。
モックは使わず、テストだけを書いて実装には触れないでください。
書き終えたらテストを走らせて、失敗することを見せてください。指示に3つ入れています。検証条件を具体例で渡すこと、モックを避けること、実装に触れないことです。対象ファイル・シナリオ・モックの扱いを先に決めて頼むのは、Claude Codeのベストプラクティスにある「テストの範囲を絞る」依頼の形そのものです。
「走らせて失敗を見せてください」も外せません。失敗を確認しない限り、そのテストが本当に対象の挙動を見ているかは分かりません。通ってしまった場合の扱いは後半で触れます。
テストが期待どおり落ちたら、この時点で一度コミットします。実装が入る前のテストだけの状態が残っていると、後から「テストが実装に合わせて緩められていないか」を差分で確認できます。
不確かなのが「どういうテストが必要か」の段階なら、先にプランモードを挟む手もあります。Shift+Tab を押すと default → acceptEdits → plan の順に切り替わります。プランモード中は、ファイルを読み、調べるシェルコマンドも走らせますが(読み取り専用以外のコマンドは、環境によって承認を求められます)、ソースの編集はしません。使いどころはPlan modeの使い分けにまとめています。
手順2:実装は文脈を分けて走らせる
テストが揃ったら実装です。ここで効くのが、テストを書いたセッションとは別の文脈で実装させることです。
新しい文脈は、自分が直前に書いたコードへの肩入れを持ちません。同じセッションで続けると、テストを書いたときの前提がそのまま残ります。片方にテストを書かせ、もう片方にそれを通すコードを書かせる分担は、レビューを別セッションに渡すのと同じ考え方です。
分け方は3通りあり、隔離の強さが違います。
実装を分ける3つの方法
サブエージェント
コンテキストだけを隔離します。作業ツリーはメインの会話と同じです。
別セッション(`/clear`後)
コンテキストだけを隔離します。
/clearで会話を空にして、実装を新しく始めます。git worktree
コンテキストと作業ツリーの両方を隔離するので、別の作業と並行して動かせます。
サブエージェントに任せる場合は、そう明示します。
サブエージェントを使って、test/auth/session.test.ts が通るように
src/auth/session.ts を実装してください。テストファイルは変更しないこと。「テストファイルは変更しないこと」を毎回添えます。これがないと、実装が難しいときにテスト側を緩めて通してしまう経路が残ります。サブエージェントは自分のコンテキストでファイルを読み、結果だけを返すので、メインの会話が実装の探索で埋まらない利点もあります。詳しい定義方法はサブエージェントの作り方を参照してください。
手順3:テストの通過をターンの終了条件にする
ここまでは指示で回す方法です。指示は毎回書く必要があり、書き忘れれば効きません。締め方を仕組みに移すと、離席していても止まる場所が変わります。
セッションを走らせ続ける手段は3つあり、次のターンが始まるきっかけが違います。
| 手段 | 次のターンが始まるとき | 止まるとき |
|---|---|---|
/goal | 次のターンが始まるとき前のターンが終わったとき | 止まるときモデルが条件の充足を確認したとき |
/loop | 次のターンが始まるとき一定の時間が経過したとき | 止まるとき自分で止めるか、Claudeが完了と判断したとき |
| Stop hook | 次のターンが始まるとき前のターンが終わったとき | 止まるとき自分が書いたスクリプトまたはプロンプトが判断したとき |
TDDで使うのは1行目と3行目です。/loop は時間間隔で再実行する仕組みなので、テストの合否とは連動しません。
/goal で条件を満たすまで走らせる
/goal は完了条件を設定するコマンドです。ターンが終わるたびに小さく速いモデル(既定はHaiku)が条件を満たしたかを判定し、満たしていなければ制御を返さず次のターンを始めます。条件が満たされると自動的に解除されます。判定モデルを変えるなら ANTHROPIC_DEFAULT_HAIKU_MODEL を使いますが、この変数は要約など裏で動く他の処理のモデルも一緒に切り替えます。
/goal test/auth のテストが全て通り、npm run lint が終了コード0で終わること条件を書くときの制約が1つあります。判定するモデルはコマンドを実行せず、ファイルも独自には読みません。会話にすでに現れた内容だけを見て判断します。したがって条件は、Claude自身の出力で証明できる形にします。「test/authのテストが全て通る」が成立するのは、Claudeがテストを走らせた結果が記録として残るからです。条件は4,000文字まで書けます。
走らせすぎを防ぐなら、条件のなかに上限を入れておきます。「または20ターンで停止」のような句を足すと、進捗をそれに対して報告するようになります。状況は引数なしの /goal で確認でき、/goal clear で途中解除できます(stop off reset none cancel も同じ働きです)。
CIやバッチで非対話に回す
/goal は非対話モードでも使えます。-p で渡すと、1回の起動でループを最後まで回します。
claude -p "/goal test/auth のテストが全て通ること" \
--output-format stream-json --verbose既定のテキスト出力だと条件を満たすまで何も表示されないので、ターン数が多いゴールは止まっているように見えます。--output-format stream-json --verbose を付けると、ループの進行がメッセージ単位で流れます。途中で打ち切るときはCtrl+Cです。
claude --help(v2.1.287)には、CIで効いてくる指定が2つ載っています。--bare は設定ファイルやプラグインのhookを自動では読まない最小モードです。Stop hookを使うなら --settings でファイルかJSONを渡す必要があります。また、認証にはサブスクリプションのログインを使わず、ANTHROPIC_API_KEY または --settings で渡すapiKeyHelperを使います(Bedrockなどは各自の認証です)。--include-hook-events は、--output-format=stream-json のときにhookのライフサイクルイベントを出力へ含めます。hookが発火したかどうかを、ログから追えます。
Stop hookで決定論的に締める
/goal はセッション限りの設定です。プロジェクト全体に効かせるならStop hookを使います。Stop hookはClaudeが応答を終えるタイミングで発火し、ターンの終了を止めて会話を続けさせられます。
設定は .claude/settings.json に書きます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-tests.sh",
"timeout": 600
}
]
}
]
}
}スクリプト側は、テストが落ちたら終了コード2で抜けます。入力のJSONを読むのに jq を使うので、未導入なら brew install jq(macOS)や apt-get install jq で入れておきます。終了コード2はブロック扱いになり、標準エラー出力の内容がそのまま理由としてClaudeに渡ります。終了コード1は非ブロック扱いなので、止めたいときに1で抜けると素通りします。
#!/usr/bin/env bash
input=$(cat)
# すでにStop hookで継続中かどうかは stop_hook_active で分かる
continuing=$(printf '%s' "$input" | jq -r '.stop_hook_active // false')
if ! npm test --silent; then
if [ "$continuing" = "true" ]; then
echo "テストがまだ通っていません。直せないなら理由を述べて終了してください。" >&2
else
echo "テストが失敗しています。原因を直してから終了してください。" >&2
fi
exit 2
fihookの入力には stop_hook_active が渡ります。Stop hookによる継続の結果として今のターンが始まったかどうかを示す値です。止め続けても解決しない条件かどうかを、ここで見分けます。テスト通過を条件にする限り、落ちている間はブロックし続けるのが狙いどおりなので、上の例では文言を変えるだけにしています。何回か試して直らないなら諦めさせたい場合は、ここで終了コード0で抜けます。終了コード2のまま継続が8回続くと、9回目のブロックはClaude Code側で上書きされ、ターンはそのまま終わります。
スクリプトはClaude Codeを介さずに単体で動かして確かめられます。標準入力へhookの入力に見立てたJSONを流し、終了コードを見るだけです。npm test の代わりに常に失敗する sh -c 'exit 1' を置いて、v2.1.287の環境で stop_hook_active の値ごとに実行した結果が次のとおりです。
$ echo '{"stop_hook_active": false}' | ./require-tests.sh; echo "exit=$?"
テストが失敗しています。原因を直してから終了してください。
exit=2
$ echo '{"stop_hook_active": true}' | ./require-tests.sh; echo "exit=$?"
テストがまだ通っていません。直せないなら理由を述べて終了してください。
exit=2どちらも終了コードは2で、違うのは標準エラー出力の文言だけです。hookを設定ファイルへ登録する前にこの形で通しておくと、「終了コード1で抜けていて素通りしていた」といった取り違えを先に潰せます。
終了コードの代わりに、終了コード0で {"decision": "block", "reason": "..."} を標準出力に返す書き方もできます。CLAUDE.mdの記述が助言にとどまるのに対し、hookは条件を満たさない限り必ず走る点が違います。書き方の全体像はhooksの設定ガイドに、設定ファイルのキー一覧はsettings.jsonリファレンスにあります。
編集ごとにテストを走らせる(PostToolUse)
Stop hookが動くのはターンの終わりです。1ターンのあいだに10ファイル編集すれば、テストが走るのは10回の編集が終わったあと1回だけになります。編集のたびに結果を返したいなら、ツールの実行後に発火するPostToolUseを使います。
matcherはツール名に対して当たるので、ファイルを書き換える2つを指定します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.sh"
}
]
}
]
}
}挙動がStop hookと決定的に違う点が1つあります。PostToolUseはツールが成功したあとに発火するので、終了コード2で抜けても編集は取り消されません。標準エラー出力の内容が、すでに完了した操作へのフィードバックとしてClaudeに渡るだけです。テストが落ちたことを早く知らせる用途には向きますが、危険な編集を止める用途には使えません。止めたいならツール実行前のPreToolUseです。同じ仕組みはFastAPIの型ヒントをmypyで検証する用途にも使え、具体例をClaude CodeでFastAPIバックエンドを構築するにまとめました。
並列で複数ファイルを編集する場合は、粒度をもう一段変えられます。PostToolBatchは並列呼び出しのまとまりが解決したあとに1回だけ発火し、こちらは終了コード2で次のモデル呼び出しの前にループを止められます。編集1件ずつテストを回すと重いプロジェクトでは、こちらのほうが現実的です。matcherを書けるのはPostToolUseで、PostToolBatchとStopはmatcherに対応しません。
締め方の使い分け早見表
4つの締め方は、設定の手間と効く範囲が違います。同じ場面で全部を使う必要はありません。
| 締め方 | 効く範囲 | 準備 | 判定するもの |
|---|---|---|---|
| プロンプトに書く | 効く範囲そのターンのみ | 準備なし | 判定するものClaude自身 |
/goal | 効く範囲そのセッション | 準備コマンド1行 | 判定するもの別の小さいモデル |
| Stop hook | 効く範囲設定ファイルの適用範囲 | 準備スクリプト作成 | 判定するもの自分で書いた判定 |
| 検証サブエージェント | 効く範囲呼んだときだけ | 準備プロンプトまたは定義ファイル | 判定するもの別文脈のモデル |
離席して戻ったら終わっている、という状態を作るのは下2つです。プロンプトに書く方法は準備が要らないぶん、書き忘れると何も効きません。
よくあるつまずき
書かせたテストが最初から通ってしまう
失敗を確認する手順を飛ばすと起きます。原因は2つに割れます。対象の挙動がすでに実装済みか、テストが対象を見ていないかです。前者なら実装は不要で、後者はテストの作り直しになります。どちらかを確かめずに実装へ進むと、通っているテストの下で壊れたコードが増えます。バグ報告が起点なら、再現テストを先に書かせて修正を戻すと落ちることまで確かめる手順もあります。
モックで通してしまう
モックを使うテストは、実物を通さない分だけ、本番の挙動とずれる余地が残ります。プロンプトに「モックを避ける」と明示するのが最短の対処です。避けられない依存があるときは、どこまでを実物で通すかを先に決めておきます。テストケースの入力値だけに反応するハードコードを防ぐには、テストだけ通すハードコードを防ぐプロンプトが使えます。
/goal の条件が判定できない形になっている
「実装が正しいこと」「バグがないこと」のような条件は判定できません。判定するモデルはツールを呼ばず、会話に現れた内容だけを見るからです。テストの実行結果、ビルドの終了コード、ファイル数、空になったキューのように、Claudeの出力として残るものを条件にします。
/goal が途中で止まった、または消えた
止まり方は3通りあり、残り方が違います。Claudeがツールを使わずに評価役へ答えるだけのターンが数回続くと、ループは止まって警告が出ますが、ゴールは設定されたままで、次のプロンプトで評価が再開します。条件を満たせないと評価役が判断した場合は、理由つきでゴールが解除されます。認証の失敗・クレジット残高の枯渇・自動圧縮でも収まらないコンテキストあふれ・使えないモデルの4つは、Goal cleared after an unrecoverable error という警告とともにゴールを解除するので、原因を直して /goal をもう一度実行します。
hookを入れたのに何も起きない
.claude/settings.json に書いたつもりが、別の階層に置かれている場合に起きます。プロジェクト設定として読まれるのはプロジェクトルート、つまりリポジトリのルートの .claude/ です。設定済みのhookは /hooks で一覧できます。
置き場所が正しいのに動かないなら、hookそのものが無効化されている可能性があります。設定のどこかの階層で disableAllHooks が有効になっていると、すべてのhookが止まります。管理者設定に allowManagedHooksOnly がある場合も、利用者・プロジェクト・プラグインのhookが遮断されます。
/goal はこれに加えてもう1つ条件があります。評価役がhookの仕組みの一部なので、信頼ダイアログを承認したワークスペースでしか動きません。/goal については、上記3つのどれに当たっても理由をコマンドが返すので、黙って何も起きないわけではありません。
巻き戻しでテストが戻らない
/rewind やEsc2回で戻せるのは、そのセッションのチェックポイントに記録された変更だけです。Bashコマンド経由の変更や外部プロセスの変更は記録されません。テストの生成をシェルスクリプト任せにしていると、巻き戻しても消えないファイルが残ります。
手順2でサブエージェントに実装させた場合も戻りません。サブエージェントの編集はファイル編集ツールを通っていても、通常はセッションのチェックポイントに記録されません。例外は、context: fork のスキルをフォアグラウンドで走らせた場合で、こちらは自分のターン中に作業ツリーを編集するので戻ります。この記事の進め方はサブエージェントを使うので、ここは実質的にgit頼みになります。テストだけをコミットしてから実装に入る手順1の締めは、この意味でも効いてきます。なおEsc2回が巻き戻しになるのは入力欄が空のときで、書きかけの文字があるとそちらが消えるだけです。
CLAUDE.mdが長すぎてテスト規約が無視される
同じ指示を何度出しても守られないときは、ファイルが長くなりすぎて指示が埋もれている可能性があります。CLAUDE.mdは毎回読み込まれるので、常に効かせたい短い規約だけを置き、たまにしか使わない手順はスキルへ移します。どの階層に何を置くかはメモリの3階層で整理しています。
よくある質問
既存のテストがないプロジェクトでも始められますか
これから触る範囲だけにテストを置く形なら始められます。全体のカバレッジを先に上げる必要はありません。触る関数の周辺だけ失敗するテストを用意し、そこから広げていく進め方が現実的です。すでにある既存コードにあとからテストを足していく具体的な手順はClaude Codeでテストを書かせる実践手順にまとめています。
テストが通ったあとにコードの質まで見てもらえますか
差分だけを新しい文脈で読ませる方法があります。/code-review は差分を別コンテキストのサブエージェントで読み、バグと、再利用・単純化・効率の改善点を返します。ローカルの/code-review以外にもultrareviewや管理Code Review機能、GitHub Actionsといった実行経路があり、使い分けはClaude Codeコードレビュー — 4つの実行経路の使い分けで比較しています。
自動テストのないUI変更でも同じ形にできますか
スクリーンショットを合否のシグナルとして使えます。実装後にスクリーンショットを撮って元のデザインと比較し、差分を列挙して直す、という指示の形です。合否を返す何かがあれば構造は変わりません。ハードウェア開発でも考え方は同じです。ESP32のシリアル出力を合否のシグナルに使う具体例はESP32のシリアルデバッグ手順にまとめています。REPLで評価しながら書く言語なら、常駐REPLの評価結果が反復用のシグナルになります。ClojureをREPL駆動で書く設定がその例です。
まとめ
離席して戻ったら終わっている状態を作るなら、その場限りの完了条件は /goal、リポジトリ全体で毎回の締めを揃えたいときはStop hookが候補になります。全体像はClaude Codeの基礎ガイドにあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。