← ブログに戻る

Claude Code TDD hook 4例強制

この記事を含む総合ガイド Claude Code 実戦運用ガイド

私は 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 の順序は、この「毎回必ず」に該当します。

ハーネスの品質層でCLAUDE.mdとhookの位置を分ける図

「ほぼ毎回」と「例外なく毎回」の距離

私が書いた書籍 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 側の「テストから書いて」は残しておいてよい。もう強制はしない、ただの説明資料に格下げする形です。

PreToolUse hook が Edit を捕まえ、対応するテストの有無で判定する図

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 2exit 2、stderr に reason
Edit on src/billing.py (対応 test あり)exit 0exit 0
Bash ツール呼び出しexit 0 (無関係)exit 0
Edit on tests/test_billing.pyexit 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 の推奨に残す

関連記事

AIコードレビューを仕組み化する技術 関連書籍 AIコードレビューを仕組み化する技術 AIコードレビュー 自動化 | hooks 設計・CodeRabbit 導入・Conventional Comments・GitHub Actions パイプライン 書籍ページを見る →