Claude Code TDD hook 4例強制
私は CLAUDE.md に「まずテストから書いてください」と書いていました。書いた本人が偉そうに書いていたので、Claude は当然そのとおりにやってくれるものと思っていました。実際は、素直に守るときもあれば、実装から書き始めるときもある。私が悪いのか、Claude が悪いのか、しばらく分からずにいました。
答えは Anthropic 公式ドキュメントに書いてありました。CLAUDE.md は「強制」ではないのだそうです。
Anthropic 自身が「CLAUDE.md は enforcement ではない」と書いている
Claude Code の memory ドキュメントには、こう書いてあります。
Claude treats them as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead.
同じページの下のほうにも念押しがあります。
Settings rules are enforced by the client regardless of what Claude decides to do. CLAUDE.md instructions shape Claude’s behavior but are not a hard enforcement layer.
私が書いた CLAUDE.md は、モデルにとっては「読み物」です。読んだうえで従うかどうかはモデルが決める。私はこれを「命令」だと思っていました。契約書だと思って渡したものが、相手にとっては業界紙のコラムだった、みたいな話です。
同ページには続けて、こんな一節もあります。
If the instruction is something that must run at a specific point, such as before every commit or after each file edit, write it as a hook instead.
つまり「毎回必ず起きてほしい」ものは、CLAUDE.md ではなく hook で書けと公式が明言しています。TDD の順序は、この「毎回必ず」に該当します。

「ほぼ毎回」と「例外なく毎回」の距離
私が書いた書籍 AIコードレビューを仕組み化する技術 の第1章に、この距離を並べた表を入れました。強制方法ごとの実行率を推定した表です。
| 強制方法 | 実行率 |
|---|---|
| AGENTS.md に「テストを書いてから PR」と記載 | 80-90% |
| pre-commit フックでテスト実行を強制 | 100% |
| AGENTS.md に「Conventional Comments を使う」と記載 | 50-70% |
| PR テンプレートにラベルのリマインドを記載 | 70-80% |
80-90% は一見高く見えます。しかしテスト強制なら、90% は月 100 PR で 10 本の未検証コードが本番に流れることを意味します。10 本というと、CI の休暇届が入るくらいの量です。
書籍ではこれを、SmartScope の一節を引きながらこう書きました。
CLAUDE.md に「リンターを実行せよ」と書くのと、Hook でリンター実行を強制するのは、「ほぼ毎回」と「例外なく毎回」の違い。
TDD も同じです。「ほぼ毎回」で許せる規約なら CLAUDE.md でよい。「例外なく毎回」で守りたいなら hook 側に持っていく必要があります。
モデルの提案とツール実行の権威を分ける
Anthropic ドキュメントの同じページには、CLAUDE.md と設定 (settings) の役割の違いも表で明示されています。settings 側は「client によって Claude の判断に関係なく強制される」、CLAUDE.md 側は「Claude の挙動を”形作る”が、hard enforcement layer ではない」。
この線引きが意味しているのは、モデルの「提案」とツール実行の「権威」を分けろということです。CLAUDE.md はモデルの入力に混ぜる文脈で、そこに書かれた TDD の順序も「モデルへの説明」までしか届かない。実際に Edit / Write を「させない」ためには、モデルの外側で判定するレイヤーが要ります。
Claude Code の hook はこの外側レイヤーそのものです。プロンプト (モデル側の提案) と、hook (決定論的な gate) を分ける。プロンプトは説得のためのもの、hook は強制のためのもの、と役割を分けて設計します。
Claude Code の PreToolUse hook で強制するとどうなるか
Anthropic 公式の hooks リファレンス から、PreToolUse hook の骨格を持ってきます。settings.json の書き方はこうです。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-test-first.py",
"timeout": 30
}
]
}
]
}
}
matcher で Edit|Write を絞ると、Claude がその 2 つのツールを呼ぶ直前に、指定したスクリプトが起動します。スクリプトが exit code 2 で終わると、ツール呼び出しがブロックされます。exit 0 なら通ります。
hook スクリプトが受け取る stdin の JSON は、公式仕様上こういう形です。
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/home/user/proj/src/billing.py",
"old_string": "def add_tax(price):",
"new_string": "def add_tax(price, rate=0.1):"
}
}
これを読んで、「TDD 順序が守られているか」を判定させます。守られていなければ拒否、守られていれば通過。CLAUDE.md 側の「テストから書いて」は残しておいてよい。もう強制はしない、ただの説明資料に格下げする形です。

TDD 強制 hook のサンプルコード
具体例を出します。「src/**/<name>.py を編集するなら、tests/test_<name>.py が先に存在すること」というルールの Python 実装です。
#!/usr/bin/env python3
"""PreToolUse hook: block implementation edit if paired test file is missing."""
import json
import sys
from pathlib import Path
def main() -> int:
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
return 0
tool = payload.get("tool_name", "")
if tool not in ("Edit", "Write"):
return 0
file_path = payload.get("tool_input", {}).get("file_path", "")
if not file_path:
return 0
p = Path(file_path)
parts = p.parts
if "src" not in parts:
return 0
if p.suffix != ".py":
return 0
root_parts = parts[: parts.index("src")]
project_root = Path(*root_parts) if root_parts else Path(".")
test_file = project_root / "tests" / f"test_{p.stem}.py"
if not test_file.exists():
sys.stderr.write(
f"TDD gate: refusing {tool} on {file_path}\n"
f" paired test {test_file} does not exist yet.\n"
f" write the failing test first, then edit the implementation.\n"
)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
私はこれを 4 つの入力に対して手元で走らせて、期待どおりに反応するかを確認しました。
| 入力 | 期待 | 結果 |
|---|---|---|
Edit on src/billing.py (対応 test なし) | exit 2 | exit 2、stderr に reason |
Edit on src/billing.py (対応 test あり) | exit 0 | exit 0 |
Bash ツール呼び出し | exit 0 (無関係) | exit 0 |
Edit on tests/test_billing.py | exit 0 (テスト作成自体は通す) | exit 0 |
拒否したいのは「実装先行の Edit / Write」だけです。テスト自身の Edit、Bash、Read などは通す。ここを狭くしないと、hook が邪魔になってしまって外されます。
もう少し丁寧に書きたい場合は、公式ドキュメントに載っているもう一つの経路、JSON レスポンス版が使えます。exit 2 の代わりに、標準出力に次の JSON を出す形です。
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "TDD gate: paired test does not exist",
"additionalContext": "write tests/test_billing.py first, then retry the Edit"
}
}
additionalContext は system reminder として transcript に入るので、Claude は「なぜ拒否されたのか」を読んで次の一手を選べます。exit 2 でも動くのですが、こちらのほうが会話が続きやすい。
どこまで hook にし、どこは CLAUDE.md に残すか
書籍でも書いたのですが、全部を hook にすると開発が止まります。境界の引き方が要ります。
| 種類 | どこで守るか |
|---|---|
| フォーマット、リンター、型チェック、テスト順序 | 機械的に判定できる。hook で強制 |
| Conventional Comments のラベル選び、PR サイズ | 人間の判断が要る。CLAUDE.md で推奨 |
| CLAUDE.md や AGENTS.md の中身そのもの | ドキュメント。git 側で review |
TDD の「実装より先にテストが存在する」という順序は、機械的に判定できる側です。テストファイルがあるかないか、それだけで判定できます。「良いテストか」は判定できませんが、それは別レイヤーで見る話です。
私自身、これまで pre-commit hook (git 側の hook) は private-lint による機密漏れ検査だけで運用していて、Claude Code 側の PreToolUse hook は入れていませんでした。この記事を書くにあたって初めて require-test-first.py を設計し、上の 4 ケースで動作を確かめました。すぐ本番の CLAUDE.md 指示から TDD の一節を消せるか、というと消さない予定です。指示は残しつつ、決定論的なゲートを重ねる、という方向のほうが安全だからです。Anthropic 公式の言い方に合わせるなら、Claude の挙動を「形作る」層と、判断に関係なく「強制する」層の両輪です。
書き終えたあと少し憂鬱なのは、私自身が「テスト先」を毎回守れているかというと、hook のほうがきっと律儀ということです。忘れっぽい自分より、決定論的な JSON パーサのほうを信用する。それが、hook でしか守れないものが世の中にあるということの、たぶん一番正直な言い直しです。
まとめ
- Anthropic は CLAUDE.md を「context, not enforced configuration」と定義している。指示は指針であって強制ではない
- 「例外なく毎回」で守りたい規約は、PreToolUse hook 側に持たせる。公式ドキュメントがその推奨経路を明示している
- Claude Code の PreToolUse hook は matcher と exit code 2 でツール呼び出しをブロックできる。動作は 4 ケースで確認した通り
- 全部を hook にせず、機械判定できるものだけを hook にする。あとは CLAUDE.md の推奨に残す
関連記事
- Claude Code vs ChatGPT Codex: Official Agent Modes Compared — 同じ「AI エージェントに何を任せ、何を外側で担保するか」の話を、Claude Code と Codex を並べて考えた記事
関連書籍 AIコードレビューを仕組み化する技術 AIコードレビュー 自動化 | hooks 設計・CodeRabbit 導入・Conventional Comments・GitHub Actions パイプライン 書籍ページを見る → この記事は役に立ちましたか?