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はツールが成功した直後に発火します。tool_responseを受け取れる一方、対象のツール呼び出しはすでに完了しているため、テストが落ちていてもその場で実行を止めることはできません。できるのはdecision: "block" + reasonやadditionalContextでClaudeにフィードバックを返すことだけです。編集のたびに手早くフィードバックしたい、いわば「今のところ大丈夫か」の確認に向きます。
Stopは主エージェントが応答を終えようとするたびに発火します。ユーザーの割り込みで終わったときは発火しません。ここが誤解されやすい点で、Stopはセッション全体の終了ではなく、Claudeが1回の応答を終えるたびに毎回チェックされるイベントです。decision: "block"(または終了コード2)を返すとClaudeは応答を終えられず、reasonに書いた内容を理由に会話が続きます。テストが通るまで完了を認めない、最終ゲートとして使う設計に向きます。
この2つを混同すると、PostToolUseに「テストが通るまで止める」役割を期待して設定を書いてしまい、実行済みのツール呼び出しはブロックできないという壁に当たります。逆にStopだけに頼ると、編集のたびにフルスイートを走らせることになり、応答が終わるたびに待たされます。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
}
]
}
]
}
}長時間テストの分け方は3択です。編集のたびに手早く確認するだけで急いで拾わなくてよいならasync、バックグラウンドで走らせつつ失敗だけはすぐ知りたいならasyncRewake、テストが通るまで応答の完了を認めない最終ゲートにしたいなら同期実行のStop、という順で使い分けます。asyncとasyncRewakeはどちらもPostToolUse側の「編集ごとに手早く確認する」用途と相性が良く、Stop側の「テストが通るまで完了させない」用途にはどちらも使えません。
非同期フックの結果は、セッションが動いている間しか届きません。-pフラグを使う非対話モードでは、セッション終了時に実行中の非同期フックを強制終了し、結果をcancelledとして扱います。CI上でclaude -pを使ってテストhookを回す構成では、この挙動を踏まえてタイムアウトや結果の受け取り方を設計する必要があります。
テストフレームワーク別の自動実行コマンドと向く配置
言語やビルド手順によって、hookから叩くコマンドと相性の良い配置は変わります。インタプリタ言語の単体テストは秒単位で終わることが多くPostToolUseに向き、コンパイルを伴う言語やブラウザを起動するE2Eはasyncを併用するか、Stopに寄せたほうが待ち時間を抑えやすくなります。
| フレームワーク | 言語 | 自動実行コマンド | 向く配置 | 備考 |
|---|---|---|---|---|
| Jest | 言語JavaScript/TypeScript | 自動実行コマンドnpx jest --ci | 向く配置PostToolUse | 備考--ciでウォッチモードに入らず1回で終了する |
| Vitest | 言語JavaScript/TypeScript | 自動実行コマンドnpx vitest run | 向く配置PostToolUse | 備考runを省くとウォッチモードのまま終了しない |
| Cypress | 言語JavaScript(E2E) | 自動実行コマンドnpx cypress run | 向く配置Stop(async併用可) | 備考ブラウザ起動を伴い他より実行時間が伸びやすい |
| 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
}
]
}
]
}
}コマンド型はプロジェクトごとに実行コマンドを書き分ける必要がありますが、公式ドキュメントは実験的な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ハンドラーは実験的機能で、公式ドキュメントも本番運用ではコマンド型を優先するよう案内しています。
よくあるつまずき
Stopをセッション終了と勘違いする
Stopは主エージェントが1回の応答を終えるたびに発火し、セッション全体の終了を意味しません。stop_hook_activeフィールドを見ずに無条件でブロックし続けると、Claude Codeが8回連続のブロックで強制的に上書きして応答を終わらせます。ブロックを繰り返す前提の設計なら、この上限を踏まえて条件を絞ります。
Stopのタイムアウトを既定のままにしてすり抜ける
Stopにasyncを付けてしまう
asyncで動くhookはdecisionやcontinueが効きません。Stopを最終ゲートとして使うなら同期実行のままにします。asyncRewakeも終了コード2でClaudeを起こすだけで、decisionやcontinueを制御する経路ではない点は同じです。応答を止めさせる目的ならStopの同期実行一本に絞ります。
終了コード1で止まると思い込む
Stopがブロックするのは終了コード2だけです。テストランナーはたいてい失敗時に終了コード1を返しますが、JSON出力を伴わない終了コード1は非ブロッキングのエラーとして扱われ、応答はそのまま終わってしまいます。テストランナーの終了コードをそのまま返さず、失敗時だけexit 2に変換するラッパースクリプトを挟む必要があります。
ウォッチモードのまま終了しない
Jestに--ciを付けずに叩くと、hookのプロセスがウォッチモードのまま居座りタイムアウトを待つだけになります。Vitestもrunサブコマンドを付けないと同じ結果になります。
matcherを絞りすぎて記録漏れが起きる
Edit|Writeだけにマッチさせると、Bashで直接テストコードを書き換えたケースを取りこぼします。テスト起動漏れのリカバリまで含めた設定手順はPostToolUseFailure hookでツール失敗時だけ動くリカバリを書くにまとめています。
まとめ
Claude Code hooksでテストを自動実行するときは、PostToolUseとStopの役割の違いをまず切り分けます。PostToolUseは編集直後の手早い確認、Stopは応答完了前の最終ゲートです。実行時間が伸びやすいフレームワークは、待ち時間だけ分離したいならPostToolUseをasyncに、バックグラウンドに置いたまま失敗だけすぐ拾いたいならasyncRewakeにし、Stopは同期実行のままtimeoutを実際の実行時間に合わせて調整します。Jest・Vitest・unittest・go test・RSpec・PHPUnitのような秒単位で終わる単体テストはPostToolUseに、cargo testやJUnit5のようにコンパイルを伴うテスト、Cypressのようにブラウザを起動するテストはasync・asyncRewake併用かStopに寄せると、待ち時間と検証の厳しさを両立しやすくなります。