Claude Code hooksでテストを自動実行する — PostToolUseとStopの使い分け
Claude Code hooksでJest・pytestなど9つのテストFWを自動実行するコマンドと、PostToolUseとStopの使い分け・長時間テストの分離方法をまとめました。
Claude Code hooksでテストを自動実行する仕組み
PostToolUseとStop、この2つのhookイベントを組み合わせると、ファイルを編集させるたびに、あるいは応答を終えようとするたびにテストスイートを自動で走らせられます。編集直後に軽く確認する使い方と、応答を終える直前に全体を通す最終ゲートとしての使い方は、狙いも設定の書き方も違います。
この記事では、Jest・Vitest・Cypress・Python標準のunittest・go test・cargo test・JUnit5・RSpec・PHPUnitの9つについて、hookから叩く実行コマンドと、PostToolUseとStopどちらに寄せるかの目安を1本の早見表にまとめます。hookの設定階層やmatcherの書き方といった基本作法はPostToolUse hookでツール実行後の後処理を自動化するに譲り、本記事はテストの自動実行という用途に絞ります。
PostToolUseとStopはどちらのタイミングでテストを実行するか
PostToolUseとStopの発火タイミング
PostToolUse
ツールが成功した直後に発火し、tool_responseを受け取れます。呼び出しはすでに完了しているため、テストが落ちていても実行は止められません。できるのはdecision: "block"とreason、またはadditionalContextでClaudeにフィードバックを返すことだけです。編集のたびに「今のところ大丈夫か」を確かめる用途に向きます。
Stop
主エージェントが応答を終えるたびに発火します。セッション全体の終了ではありません。ユーザーの割り込みで止まったときは発火せず、APIエラーのときは代わりにStopFailureが発火します。decision: "block"か終了コード2を返すとClaudeは終われず、reason(終了コード2ならstderr)が続行の理由になります。テストが通るまで完了を認めない最終ゲート向きです。
この2つを混同すると、PostToolUseに「テストが通るまで止める」役割を期待して設定を書いてしまい、実行済みのツール呼び出しはブロックできないという壁に当たります。逆にStopだけに頼ると、編集のたびにフルスイートを走らせることになり、応答が終わるたびに待たされます。Stopでは、ブロックの代わりにhookSpecificOutput.additionalContextを返す手もあります。続行する点は同じですが、transcriptにはエラーでなく「Stop hook feedback」として載るので、「完了前にテストを走らせてください」のような誘導に向きます。Stop hookを使ったTDDの回し方はClaude Codeでテスト駆動開発(TDD)を回す手順で具体的な設定例まで扱っています。
テストの実行時間が長いときはasyncで待ち時間から分離する
コマンド型のhookのタイムアウトは既定で600秒(10分)です。これを超えるとhookは強制終了され、出力はまるごと捨てられます。PostToolUseならフィードバックが届かないだけで実害は小さいものの、Stopでこれが起きると、テストが失敗していてもブロック判定そのものが届かず、Claudeはそのまま応答を終えてしまいます。フルスイートに10分近くかかるプロジェクトでは、Stop hookのtimeoutを実際の実行時間に合わせて明示的に伸ばす必要があります。
コマンド型のhookには"async": trueという選択肢もあります。バックグラウンドで実行され、Claudeはテストの完了を待たずに次の作業へ進みます。結果は次の会話ターンで届き、セッションがアイドル中なら次のユーザー操作があるまで届きません。バックグラウンドで動き出したあとはタイムアウトも適用されなくなります。ただし公式ドキュメントは、asyncで動くhookはdecisionやcontinueといった振る舞いを制御するフィールドが効かないと明記しています。実行がすでに終わったあとに結果が届く以上、これは当然の制約です。
もう一つ"asyncRewake": trueという選択肢もあります。バックグラウンド実行そのものはasyncと同じですが、終了コード2で終わるとClaudeをその場で起こします。セッションがアイドルでも次のユーザー操作を待たずに割り込むのが、asyncとの最大の違いです。stderr(空ならstdout)がsystem reminderとしてClaudeに見えるため、長時間テストの失敗をバックグラウンドに置いたまま拾えます。asyncと違いtimeoutも効いたままなので、想定より長引いたテストは強制終了の対象になります。PostToolUseにテストのラッパースクリプトを付け、失敗時だけexit 2を返すようにしておくと、編集を続けながら失敗だけすぐ知ることができます。
#!/bin/bash
# .claude/hooks/run-tests-rewake.sh
RESULT=$(npx jest --ci 2>&1)
if [ $? -ne 0 ]; then
echo "$RESULT" >&2
exit 2
fi
exit 0{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests-rewake.sh",
"args": [],
"asyncRewake": true
}
]
}
]
}
}長時間テストの置き場所
async
編集のたびに手早く確認するだけで、失敗を急いで拾わなくてよいとき。結果は次のターンに届き、timeoutは効きません。
asyncRewake
バックグラウンドで走らせつつ、失敗(終了コード2)だけすぐ知りたいとき。timeoutは効いたままです。
同期のStop
テストが通るまで応答の完了を認めない最終ゲートにしたいとき。
asyncとasyncRewakeはどちらもPostToolUse側の「編集ごとに手早く確認する」用途と相性が良く、Stop側の「テストが通るまで完了させない」用途にはどちらも使えません。
非同期フックの結果は、セッションが動いている間しか届きません。-pフラグを使う非対話モードでは、セッション終了時に実行中の非同期フックを強制終了し、結果をcancelledとして扱います。CI上でclaude -pを使ってテストhookを回す構成では、この挙動を踏まえてタイムアウトや結果の受け取り方を設計する必要があります。
claude --help(v2.1.289)の説明では、--bareはsettingsとインストール済みプラグインのhookをまとめてスキップする最小モードです。テストhookを効かせたいCIには付けません。--output-format=stream-jsonと組み合わせて--include-hook-eventsを付けると、hookのライフサイクルイベントが出力ストリームに含まれ、hookが動いたかどうかを後から追えます。
テストフレームワーク別の自動実行コマンドと向く配置
言語やビルド手順によって、hookから叩くコマンドと相性の良い配置は変わります。インタプリタ言語の単体テストは秒単位で終わることが多くPostToolUseに向きます。
コンパイルを伴う言語やブラウザを起動するE2Eは、asyncを併用するか、Stopに寄せたほうが待ち時間を抑えやすくなります。
| フレームワーク | 言語 | 自動実行コマンド | 向く配置 | 備考 |
|---|---|---|---|---|
| Jest | 言語JavaScript/TypeScript | 自動実行コマンドnpx jest --ci | 向く配置PostToolUse | 備考--ciは新規スナップショットを保存せず失敗扱いにする |
| Vitest | 言語JavaScript/TypeScript | 自動実行コマンドnpx vitest run | 向く配置PostToolUse | 備考runなら環境によらず1回で終了する |
| Cypress | 言語JavaScript(E2E) | 自動実行コマンドnpx cypress run | 向く配置PostToolUse(async)またはStop(同期) | 備考ブラウザ起動を伴い他より実行時間が伸びやすい |
| unittest | 言語Python | 自動実行コマンドpython -m unittest discover | 向く配置PostToolUse | 備考標準ライブラリのみで動き追加インストールが不要 |
| go test | 言語Go | 自動実行コマンドgo test ./... | 向く配置PostToolUse | 備考./...で全パッケージを対象にする |
| cargo test | 言語Rust | 自動実行コマンドcargo test | 向く配置PostToolUse(async推奨) | 備考初回はコンパイルを伴うぶん実行時間が伸びる |
| JUnit5 | 言語Java(Maven/Gradle経由) | 自動実行コマンドmvn -q test または ./gradlew test | 向く配置Stop | 備考ビルドツール経由のためコンパイルを含み時間がかかりやすい |
| RSpec | 言語Ruby | 自動実行コマンドbundle exec rspec | 向く配置PostToolUse | 備考Gemfileに依存関係が閉じるため導入コストが低い |
| PHPUnit | 言語PHP | 自動実行コマンド./vendor/bin/phpunit | 向く配置PostToolUse | 備考Composerでインストール済みならそのまま呼べる |
コンパイルを伴うcargo testやJUnit5、ブラウザを起動するCypressは実行時間の分散が大きく、フルスイートをそのまま同期のPostToolUseに置くと編集のたびに待たされます。CypressをはじめとしたE2E寄りの構成は、Playwrightに特化した設定手順をClaude CodeでPlaywrightのE2Eテストをhookで自動実行するで扱っているので、ブラウザテストを本格的に組むならそちらも参照してください。モノレポでFWが混在する場合の切り分けはClaude Codeモノレポのテスト戦略をSKILL.mdで教える手順が参考になります。
設定例 — matcherとifでテスト対象を絞る
PostToolUse側は、EditまたはWriteにマッチさせたうえで、ifフィールドでテストファイルだけに絞り込めます。ifはパーミッションルールと同じ構文で、ファイルツールなら"Edit(*.ts)"のようにglobでパスを絞れます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-unit-tests.sh",
"args": [],
"if": "Edit(**/*.test.ts)",
"async": true
}
]
}
]
}
}Stop側は、フルスイートを同期実行し、失敗したら終了コード2で応答の完了を止めます。
#!/bin/bash
# .claude/hooks/verify-tests.sh
if ! npx jest --ci 2>&1; then
echo "テストが失敗しています。修正してから完了してください" >&2
exit 2
fi
exit 0{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-tests.sh",
"args": [],
"timeout": 900
}
]
}
]
}
}Stopゲートが止めて通すまで
- 1
Claudeが応答を終えようとする
Stopが発火し、
verify-tests.shが同期で走ります。 - 2
失敗したら終了コード2
stderrの文面がClaudeに渡り、応答は終わらず修正が続きます。
- 3
通るまで繰り返す
テストが通って終了コード0を返すと、応答が終わります。ブロックが続く回数には上限があります。
コマンド型はプロジェクトごとに実行コマンドを書き分ける必要がありますが、公式ドキュメントは実験的なtype: "agent"ハンドラーで代替する例も示しています。プロンプトで「テストスイートを実行して結果を確認する」よう指示し、実際にコマンドを叩いて確認するサブエージェントを起動する形です。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and check the results. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}この方式なら、モノレポでFWが混在していてもコマンドをハードコードせずに済みます。ただしagentハンドラーは実験的機能で、公式ドキュメントも本番運用ではコマンド型を優先するよう案内しています。timeoutや50ターンの設計はagent hookでテスト完了を検証させる設定にまとめています。
1セッションだけ「テストが通るまで続ける」条件が欲しいなら、設定ファイルを書かない/goalもあります。セッション限りのプロンプト型Stop hookの近道として用意されたコマンドで、毎ターンの後に別のモデルが条件を満たしたか判定します。
よくあるつまずき
Stopをセッション終了と勘違いする
Stopは主エージェントが1回の応答を終えるたびに発火し、セッション全体の終了を意味しません。stop_hook_activeフィールドを見ずに無条件でブロックし続けると、8回連続のブロックでClaude Codeが応答を強制的に終わらせます。上限は環境変数CLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられ、0で無効になります。ブロックを繰り返す前提の設計なら、この上限を踏まえて条件を絞ります。
Stopのタイムアウトを既定のままにしてすり抜ける
Stopにasyncを付けてしまう
asyncで動くhookはdecisionやcontinueが効きません。Stopを最終ゲートとして使うなら同期実行のままにします。asyncRewakeも終了コード2でClaudeを起こすだけで、decisionやcontinueを制御する経路ではない点は同じです。応答を止めさせる目的ならStopの同期実行一本に絞ります。
終了コード1で止まると思い込む
Stopがブロックするのは終了コード2だけです。テストランナーはたいてい失敗時に終了コード1を返しますが、JSON出力を伴わない終了コード1は非ブロッキングのエラーとして扱われ、応答はそのまま終わってしまいます。テストランナーの終了コードをそのまま返さず、失敗時だけexit 2に変換するラッパースクリプトを挟む必要があります。
失敗するunittestが1件ある状態で次のラッパーを通すと、ランナーは終了コード1、ラッパーは2を返します(出力は抜粋)。
# run-tests.sh
RESULT=$(python3 -m unittest discover 2>&1)
if [ $? -ne 0 ]; then echo "$RESULT" >&2; exit 2; fi
bash run-tests.sh; echo "wrapper exit: $?"
# AssertionError: 2 != 3
# FAILED (failures=1)
# wrapper exit: 2
# 素のランナー(python3 -m unittest)の終了コードは 1ウォッチモードとスナップショットの思い違い
Jestのウォッチモードは--watchを付けたときだけ入ります。--ciの効果はスナップショットの扱いで、新しいスナップショットを自動保存せずテストを失敗させ、--updateSnapshotを求めます。Vitestは引数なしのvitestが対話端末ならウォッチモード、CIや非対話端末なら1回実行です。端末のないhookでもrunを付けておけば、環境の判定に左右されません。
matcherを絞りすぎて記録漏れが起きる
Edit|Writeだけにマッチさせると、Bashで直接テストコードを書き換えたケースを取りこぼします。テスト起動漏れのリカバリまで含めた設定手順はPostToolUseFailure hookでツール失敗時だけ動くリカバリを書くにまとめています。コミット時のGitフックで落ちたテストをClaudeに直させる流れは、Claude Codeでhuskyとpre-commitを併用する記事が扱っています。
まとめ
Claude Code hooksでテストを自動実行するときは、編集直後の手早い確認はPostToolUse、完了を止める判断は同期のStopと、役割で分けるのが基本です。10分近くかかるスイートは、Stopならtimeoutを実測に合わせ、PostToolUseならasyncRewakeで失敗だけ拾います。