Claude CodeでPlaywrightのE2Eテストをhookで自動実行する
PlaywrightのE2EスイートをPostToolUse hookとStop hookで自動実行し、失敗をClaude Codeに検知させて直させる設定手順とつまずきどころです。
Claude CodeにPlaywrightのE2Eスイートを組み込むと、編集のたびに手でテストを走らせて結果をコピペで報告する作業がなくなります。hookがファイル変更を検知してテストを自動実行し、失敗内容をClaude自身の文脈に返します。あとはClaudeがその内容を読んで修正案を出す、という流れが1セッションの中で完結します。
この記事はPlaywrightをテストフレームワークとして使う前提で、hookによる自動実行と失敗の押し戻しに絞って扱います。ブラウザ操作をテストコードなしで自然文の指示から動かしたい場合はPlaywright MCPサーバーの使い方が別の入口です。hookの仕組み全体はClaude Code Hooks完全ガイドにまとめているので、本記事ではE2E自動実行の実装だけに集中します。
前提条件
以下がそろっている状態を前提にします。
- Node.jsの最新22.x・24.x・26.xいずれか(Playwright公式のシステム要件)
- Claude Codeのプロジェクトディレクトリ(
.claude/hooks/にスクリプトを置く) - jqコマンド(hookスクリプトでJSON入力をパースするため)
hookは.claude/settings.jsonに書いたその場では動きません。インタラクティブセッションでは、そのプロジェクトフォルダに対するワークスペーストラストのダイアログを一度承認するまで、ユーザー設定・プロジェクト設定を問わずすべてのhookが保留されます。claude -pのようなヘッドレス実行ではこのダイアログ自体が出ず、リポジトリに.claude/settings.jsonを含めていればコミットされたhookがそのまま動きます。クラウド上のセッションはローカルの~/.claude/settings.jsonを読みません。
hookスクリプトはClaude Codeを動かしているユーザーの権限でそのままシェルコマンドを実行します。tool_inputから取り出したFILE_PATHのような値は、必ずダブルクォートで囲んで展開します("$FILE_PATH"であって$FILE_PATHではない)。パスに..が含まれていないかも確認してから使います。
信頼していないリポジトリをclaude -pで開くときは、先に.claude/配下の設定を目視します。--settings '{"disableAllHooks": true}'で一時的にhookを無効化してから、中身を確認する方法もあります。
Playwrightをプロジェクトに導入する
まだPlaywrightを入れていない場合は、公式のセットアップコマンドで一括導入します。
npm init playwright@latestTypeScript / JavaScriptの選択、テストフォルダ名(既定はtests、既にtestsがあればe2e)、GitHub Actionsワークフローの追加をプロンプトで聞かれます。ブラウザバイナリのインストールも同じ流れで選びます。完了するとplaywright.config.tsとテストフォルダ配下のexample.spec.tsが生成されます。既存プロジェクトに追加した場合は、依存関係が今のpackage.jsonにそのまま追記されます。
動作確認は次の2つのコマンドで足ります。
npx playwright test
npx playwright show-reportplaywright testは既定でChromium・Firefox・WebKitをヘッドレス・並列で実行します。show-reportは失敗・成功・スキップ・flakyを一覧できるHTMLレポートを開きます。ここまでが動けば、hookから同じコマンドを呼ぶ準備ができています。
編集のたびにE2Eを自動実行するhookを作る
まずは「編集を検知したら裏でテストを回し、結果をClaudeに戻す」構成です。PostToolUseイベントにasync: trueを付けると、テストの実行を待たずにClaudeは作業を続けられます。結果はテストが終わり次第、次の会話ターンでadditionalContextとして渡されます。
mkdir -p .claude/hooks
cat > .claude/hooks/run-e2e-async.sh <<'SH'
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# テスト対象外の拡張子は素通りさせる
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]]; then
exit 0
fi
RESULT=$(npx playwright test 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
MSG="E2Eテストは全て成功しました($FILE_PATH の変更後)"
else
MSG="E2Eテストが失敗しました($FILE_PATH の変更後): $(echo "$RESULT" | tail -n 30)"
fi
jq -nc --arg msg "$MSG" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'
SH
chmod +x .claude/hooks/run-e2e-async.sh.claude/settings.json側は次のとおりです。matcherをEdit|Writeに絞ることで、Read・Grepのような読み取り系ツールではテストが走らないようにします。asyncを付けなければ同期実行になり、テストが終わるまでClaudeは結果を待ちます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-e2e-async.sh",
"args": [],
"async": true
}
]
}
]
}
}このままだと、テストの結果はClaudeが次のターンを迎えるまで届きません。数十秒で終わる小さいスイートなら気になりませんが、フルのE2Eスイートが数分かかる場合、失敗に気づくのが遅れます。ここでasync: trueの代わりにasyncRewake: trueを使うと、バックグラウンド実行のまま、終了コード2でClaudeを即座に呼び戻せます。標準エラー出力(空なら標準出力)がシステムリマインダーとしてClaudeに渡されるので、テストが失敗した瞬間だけ割り込みが入り、成功時は静かに終わります。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-e2e-async.sh",
"args": [],
"asyncRewake": true
}
]
}
]
}
}スクリプトは非同期版と別物になります。JSON出力をやめ、失敗時は標準エラー出力にメッセージを書いてexit 2で終えます。成功時は何も出さずexit 0です。
cat > .claude/hooks/run-e2e-rewake.sh <<'SH'
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]]; then
exit 0
fi
RESULT=$(npx playwright test 2>&1)
if [ $? -ne 0 ]; then
echo "E2Eテストが失敗しました($FILE_PATH の変更後)" >&2
echo "$RESULT" | tail -n 30 >&2
exit 2
fi
exit 0
SH
chmod +x .claude/hooks/run-e2e-rewake.sh上のJSONのcommandは、このスクリプト名に差し替えます。asyncのときはtimeoutが効きませんが、asyncRewakeではtimeoutが効く点が違いです。長いスイートならtimeoutを明示します。
テストが通るまでClaude Codeを終了させないhook
「編集後にE2Eを自動実行し、失敗をClaude Codeに修正させる」運用を強制したい場合、PostToolUseの非同期通知だけでは足りません。PostToolUseはツールがすでに実行された後に発火するイベントで、テストが失敗していてもツール呼び出しそのものを止める力を持たないからです。ここで効くのがStop hookです。Claudeがターンを終えようとするたびに同期で発火し、テストが失敗していればexit 2でターンの終了そのものをブロックできます。
cat > .claude/hooks/e2e-gate.sh <<'SH'
#!/bin/bash
INPUT=$(cat)
# すでにStop hookの指摘で続行中なら、修正の機会を1回渡したうえで通す
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
RESULT=$(npx playwright test 2>&1)
if [ $? -ne 0 ]; then
echo "E2Eテストが失敗しています。修正してから終了してください。" >&2
echo "$RESULT" | tail -n 40 >&2
exit 2
fi
exit 0
SH
chmod +x .claude/hooks/e2e-gate.sh{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/e2e-gate.sh",
"args": [],
"timeout": 900
}
]
}
]
}
}exit 2で渡した標準エラー出力は、Claudeが継続すべき理由として会話に読み込まれます。JSONでdecision: "block"とreasonを返した場合と同じ経路です。失敗ではなく指示として返したいときは、JSONのhookSpecificOutput.additionalContext(hookEventNameはStop)を使います。この形だとStop hook feedbackとして表示され、hookエラーの通知は出ません。Stop hookにはPostToolUseのようなmatcherがなく、ターンを終えるたびに発火します。コマンドhookの既定タイムアウトは600秒なので、スイートがそれを超えるならtimeoutを明示します。
stop_hook_activeの分岐は、このスクリプトの要です。入力のこの値は、Stop hookの指摘を受けて続行中のときにtrueになります。上のスクリプトは続行中なら素通しするため、失敗を伝えたあとの1回の修正を経たらターンが終わります。毎回テストを走らせ直したいなら分岐を外します。その場合は連続8回のブロックで打ち切られ、上限はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられ、0にすると上限そのものがなくなります。上限なしにすると失敗が直らないかぎりループが続くため、スクリプト側でstop_hook_activeやトランスクリプトを見て打ち切る条件を持たせます。
フルのE2Eスイートを毎回ChromiumとFirefoxとWebKitの3ブラウザで回すと、Stop hookのたびに待ち時間が積み上がります。npx playwright test --project=chromiumのようにブラウザを1つに絞ると、gateとして使うhookの体感速度が変わります。変更されたファイルに対応するspecだけをnpx playwright test tests/checkout.spec.tsのように指定する方法もあります。全ブラウザでの網羅的な実行はCIの別ジョブに任せ、hook側は編集直後のすばやいフィードバックに徹する、という役割分担が実務的です。
実際に動かして確かめた出力
npxをテスト失敗を返すスタブに差し替え、Claude Code v2.1.287の環境で上の3本のスクリプトに入力JSONを渡して終了コードを見ました。Playwright本体は動かしていないので、確かめたのはスクリプトの分岐とhookの出力形式です。
echo '{"tool_input":{"file_path":"src/app.ts"}}' | .claude/hooks/run-e2e-async.sh{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"E2Eテストが失敗しました(src/app.ts の変更後): Running 3 tests using 1 worker\n 1) checkout.spec.ts:8:5 › pay button › shows total\nError: expect(received).toHaveText(expected)\n 1 failed"}}README.mdのような対象外の拡張子を渡すと、何も出力せずexit 0で戻ります。asyncRewake版は同じ入力で、標準エラーに失敗メッセージとログ末尾を出してexit 2になります。成功するスタブに替えると無出力のexit 0です。
Stop gateは入力のstop_hook_activeで挙動が分かれました。
| 入力 | テスト結果 | 終了コード |
|---|---|---|
{"stop_hook_active":false} | テスト結果失敗 | 終了コード2(標準エラーに失敗内容) |
{"stop_hook_active":true} | テスト結果失敗 | 終了コード0(テストを走らせず素通し) |
4方式のどれを選ぶか
失敗の伝わり方で選ぶ
同期PostToolUse
テスト完了までClaudeが待ちます。小さいスイートで、結果をすぐ使いたいときの形です。
非同期PostToolUse(async)
作業は止まらず、結果は次の会話ターンに届きます。成功も失敗も同じ経路で伝わります。
asyncRewake
裏で走らせ、失敗(exit 2)のときだけClaudeが呼び戻されます。成功時は静かです。
Stop(同期)
ターンの終了をブロックします。「通るまで終わらせない」を実現できるのはこの方式だけです。
Claudeが何度修正してもテストが落ちる場合は、いったんhookの外に出て人間側で状況を見るのが早い場合もあります。npx playwright test --uiで開くUIモードなら、hookのログだけでは追いにくい失敗原因を画面で追えます。
よくあるつまずき
matcherを絞らずCPUを無駄に使う
matcherを空や"*"のままにすると、Read・Glob・Bashなどファイル編集と無関係なツール呼び出しのたびにE2Eスイートが起動します。Edit|Writeに絞り、さらにスクリプト側で拡張子や対象ディレクトリを見て早期exit 0するのが安全です。
CI・コンテナでブラウザバイナリが無く落ちる
ヘッドレス実行用のブラウザバイナリが入っていないと、playwright testはhookの中でエラー終了します。事前にnpx playwright install --with-depsを実行して、Chromium・Firefox・WebKitと依存パッケージを揃えておきます。
非対話セッションでasyncフックが打ち切られる
claude -pのような非対話モードでは、セッション終了時に実行中の非同期hookが強制終了され、結果はcancelled扱いになります。CIジョブの中でセッションが短命な場合、フルのE2Eスイートを裏で完走させたいなら、hookスクリプトの中から完全に独立したプロセスとして起動する必要があります。
Playwright本体とブラウザバイナリのバージョンがずれる
@playwright/testパッケージだけを更新してブラウザバイナリを更新し忘れると、hookの中のplaywright testが起動時エラーで落ちることがあります。テストの中身とは無関係な失敗を、Claudeに報告し続ける形になります。
パッケージを更新したらnpm install -D @playwright/test@latestを実行し、続けてnpx playwright install --with-depsも実行します。npx playwright --versionでインストール済みの版を確認しておくと、ずれに気づきやすくなります。
まとめ
スイートが数十秒で終わるならasync、数分かかるならasyncRewake、通るまで終わらせない完了条件にしたいならStop hookが向きます。Stop gateはstop_hook_activeの扱いで再試行の回数が変わるため、先にそこを決めておきます。ファイル編集直後の後処理全般はPostToolUse hookでツール実行後の後処理を自動化するにまとめています。モノレポでテスト戦略自体をSKILL.mdでClaudeに教える構成はClaude Codeモノレポのテスト戦略をSKILL.mdで教える手順が参考になります。他のフレームワークでの実行コマンドとPostToolUse・Stopの向く配置はテストFW別の自動実行コマンド早見表にまとめています。