AIとのTDD(テスト駆動開発)とは、実装より先に「落ちるテスト」をAIに書かせ、落ちることを実行して確かめてから、テストを変えずに実装だけを書かせる進め方です。 テストが「完成の定義」になるので、AIは自分でテストを実行し、通るまで直すことができます。人が見るのは、最初のテストが仕様どおりかどうかと、途中でテストが書き換えられていないかの2点に絞れます。

この記事には、Red・Green・Refactor の各段階でそのまま貼れる指示文6本、それを常設する CLAUDE.md と .claude/rules/testing.md の全文、実装の段階でテストファイルの編集を止める PreToolUse フックと、テストが通るまで応答を終わらせない Stop フックの settings.json 全文を載せました。後半では、Java 21 で動かした Red → Green → Refactor の出力と、AIがTDDを崩す典型8種の見つけ方をまとめています。

確認環境:Ubuntu 24.04/OpenJDK 21.0.10/bash 5.2.21/jq 1.7/git 2.43.0。Claude Code の設定は公式ドキュメント(code.claude.com/docs)を 2026年10月7日に開いて確認し、フックのスクリプトは Claude Code が渡す形式の JSON を標準入力に流して実行しました。


AIとTDDの早見表

段階 AIへの指示の要点 次へ進む目安 仕組みで守ること
Red(書く) テストだけ書く。実装は書かない テストが仕様を表している 規約を rules で常設
Red(確かめる) 実行して、落ちた出力を見せる 意図した理由で落ちている 人がテストをコミット
Green テストは変えず、実装だけで通す 全件 PASS テストへの編集をフックで拒否
Green(確かめる) 実行した出力を見せる 件数が減っていない 通るまで終了させない
Refactor 挙動を変えずに整理する 全件 PASS のまま 同上
仕上げ テストの差分がないことを示す src/test の差分が空 Stop フックで差分を検査

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「AIとTDD|テストを先に書かせる指示文とフック設定例」

指示文は「お願い」、CLAUDE.md は「毎回読まれる前提」、フックは「必ず実行される検査」です。 Claude Code の公式ドキュメントは、CLAUDE.md を「コンテキストとして扱われ、強制的な設定ではない」と説明し、アクションを止めたいときは PreToolUse フックを使うよう案内しています。この記事でも、守ってほしいことは指示と CLAUDE.md に書き、必ず守らせたいこと(テストを書き換えない、通るまで終わらない)はフックに任せる、という分け方にします。


Red・Green・Refactorでそのまま貼れる指示文6本

[ ] の中を自分の対象に置き換えて使います。題材は、後半で実際に動かす送料計算です。

1. Red:テストだけを書かせる

[送料計算 ShippingFee.calc(重さg, 注文金額円, 遠隔地か)] のテストを先に書いてください。

仕様:
- 1000g 以下は 500円、5000g 以下は 800円、5000g を超えた分は 1kg ごとに 150円を加算(1kg 未満は切り上げ)
- 遠隔地は 400円を加算
- 注文金額が 5000円以上なら送料は 0円。ただし遠隔地の加算 400円は残る
- 重さが 0g 以下、または注文金額が負のときは IllegalArgumentException

守ること:
- 書くのは [src/test/] のテストだけです。実装は書かないでください。
- コンパイルを通すために必要なら、[src/main/] には「未実装」の例外を投げるだけのスタブを置いてください。
- 期待値は上の仕様から決めてください。境界は「ちょうど」と「1つ超えた値」を対で入れてください。
- 仕様から期待値が1つに決まらない箇所があれば、テストを書かずに質問してください。

2. Red:落ちることを実行して示させる

いま書いたテストを [./run-tests.sh] で実行し、出力をそのまま貼ってください。

- すべてのテストが「未実装」を理由に落ちていることを確認してください。
- コンパイルエラーで止まっている場合は Red ではありません。スタブを直して、テストが実行される状態にしてください。
- 実装前なのに通ってしまうテストがあれば、何も検査できていないテストなので、理由を報告してください。
- この段階ではまだ実装を書かないでください。

3. Green:テストを変えずに実装だけを書かせる

[src/main/] の実装だけを変更して、すべてのテストを通してください。

- [src/test/] のファイルは変更しないでください。テストを消す・コメントアウトする・期待値を変えることもしないでください。
- テストが誤っていると考えた場合は、変更せずに、どのテストがなぜ誤りだと考えるかを報告して止まってください。
- まずは通すための素直な実装で構いません。整理は次の段階で行います。
- テストに出てくる入力値をそのまま条件に書く実装(特定の値だけ通す分岐)は書かないでください。

4. Green:通ったことを証拠で示させる

[./run-tests.sh] を実行し、出力の最後の行(成功件数と失敗件数)と終了コードを貼ってください。
続けて [git diff --stat -- src/test] を実行し、出力が空であることを示してください。
成功件数が Red のときの総件数([11] 件)と同じであることも確認してください。

5. Refactor:挙動を変えずに整理させる

テストが通っている状態を保ったまま、[src/main/java/ShippingFee.java] を読みやすく整理してください。

- 挙動は変えないでください。[src/test/] も変更しないでください。
- 変更は小さく分け、1つ変えるたびに [./run-tests.sh] を実行してください。
- 対象は、数値の定数化、条件分岐の関数への切り出し、名前の見直しです。
- 機能の追加や、仕様にない入力チェックの追加はしないでください。必要だと考えたら提案として挙げてください。

6. 仕上げ:テストが弱まっていないかを別の目で確かめさせる

サブエージェントを使って、[Red のコミット 0707dce] から現在までの差分をレビューしてください。

確認すること:
- [src/test/] に差分がないこと(git diff --stat の出力を貼る)
- 仕様の各行に対応するテストがあること(仕様の行 → テスト名の対応表)
- 実装に、テストの入力値だけを通す分岐がないこと
- 実装の境界の条件を1か所だけ変えたとき(例:<= を < に)、落ちるテストがあること。確認後は元に戻すこと

報告は、正しさと仕様に関わる指摘に絞ってください。書き方の好みは不要です。

6本目は、Claude Code の公式ベストプラクティスにある「新しいコンテキストのサブエージェントに差分をレビューさせる」進め方を、TDD に当てはめたものです。同じページには、1つのセッションにテストを書かせ、別のセッションにそれを通すコードを書かせる方法も紹介されています。テストを書いた側の考え方に実装が引っ張られにくくなるので、仕様が込み入っているときに向いています。


常設する設定:CLAUDE.mdとrulesの全文

毎回の指示文を短くするために、段階ごとのルールを CLAUDE.md に、テストコードの書き方を .claude/rules/testing.md に置きます。

CLAUDE.md

# テスト駆動で進める

## コマンド
- テスト実行: `./run-tests.sh`(失敗が1件でもあれば終了コード 1)

## 進め方(Red → Green → Refactor)
- いまの段階は `.claude/tdd-phase` に書いてある(red / green / refactor)。このファイルは人が切り替える。編集しない。
- red: `src/test/` にテストだけを書く。`src/main/` はコンパイルに必要な最小のスタブまで。テストを実行し、落ちた出力を見せる。
- green: `src/main/` だけを変更して、テストを通す。`src/test/` は変更しない。
- refactor: 挙動を変えずに `src/main/` を整理する。`src/test/` は変更しない。変更のたびにテストを実行する。

## 守ること
- 期待値は仕様から決める。実装を実行して出た値を期待値にしない。
- テストが誤っていると思ったら、変更せずに理由を報告して止まる。
- 「通りました」と書くときは、実行したコマンドと出力の最後の行を添える。

.claude/rules/testing.md

paths を付けたルールは、一致するファイルを Claude が読み書きするときだけ読み込まれます。テストの規約を CLAUDE.md に全部書かずに済み、実装だけを触る作業では文脈を使いません。

---
paths:
  - "src/test/**/*"
---

# テストコードの規約

- 1つの検査は1つの事実だけを確かめる。名前は「入力 は 期待する結果」の形にする(例: `1001g は 800円`)。
- 境界は「ちょうど」と「1つ超えた値」を必ず対で書く。
- 期待値の根拠(仕様のどの文か)をコメントか検査名に残す。
- 計算だけの処理にモックは使わない。モックは時計・外部通信・ファイルなど、結果が毎回変わるものに限る。
- 検査を消す、コメントアウトする、期待値をゆるめる変更はしない。必要だと考えたら理由を報告する。
- 例外を確かめる検査は、例外の型まで指定する。

公式ドキュメントによると、ルールのフロントマターで読まれるフィールドは paths だけで、src/**/* のようなグロブで対象を指定します。CLAUDE.md は1ファイル200行以下が目安とされています。出典:https://code.claude.com/docs/ja/memory


フックで強制する:settings.jsonとスクリプトの全文

段階は .claude/tdd-phase という1行のファイルで表し、人が切り替えます。 中身は red・green・refactor のいずれかです。フックはこのファイルを読んで、Green と Refactor の間だけテストを守ります。

echo red > .claude/tdd-phase        # テストを書く段階
git add src/test && git commit -m "test: 送料計算のテストを追加(Red)"
echo green > .claude/tdd-phase      # 実装の段階(ここからテストは編集できない)
echo refactor > .claude/tdd-phase   # 整理の段階

.claude/tdd-phase は .gitignore に入れておきます。Red の確認が済んだらテストを先にコミットしておくのがポイントで、あとで「テストが変わっていないか」をコミットとの差分で調べられるようになります。

.claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-tests.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-green.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

テストへの編集を止めるPreToolUseフック

.claude/hooks/guard-tests.sh に保存し、chmod +x で実行可能にします。

#!/bin/bash
# guard-tests.sh : Green / Refactor の間、テストと段階ファイルへの Edit / Write を止める
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"   # Windows の区切りを / にそろえる

ROOT="${CLAUDE_PROJECT_DIR:-$(echo "$INPUT" | jq -r '.cwd')}"
PHASE=$(cat "$ROOT/.claude/tdd-phase" 2>/dev/null || echo "none")

# 段階ファイルそのものは、どの段階でも人が切り替える
if [[ "$FILE_PATH" == *".claude/tdd-phase" ]]; then
  echo "Blocked: .claude/tdd-phase は人が切り替えるファイルです" >&2
  exit 2
fi

case "$PHASE" in
  green|refactor)
    if [[ "$FILE_PATH" == *"/src/test/"* || "$FILE_PATH" == src/test/* ]]; then
      echo "Blocked: 現在は $PHASE 段階です。$FILE_PATH は変更せず、src/main 側の実装を直してください。テストの誤りに気づいた場合は、変更せずに理由を報告してください" >&2
      exit 2
    fi
    ;;
esac

exit 0

Claude Code は、Edit や Write を実行する前にこのスクリプトを呼び、ツールの引数を JSON で標準入力に渡します。終了コード 2 で終わるとツール呼び出しは中止され、標準エラーに書いた文が Claude に理由として伝わります。 公式ガイドが勧める方法どおり、同じ形の JSON をパイプで流して動作を確かめました。

$ echo green > .claude/tdd-phase
$ echo '{"tool_name":"Edit","tool_input":{"file_path":"src/test/java/ShippingFeeTest.java"}}' | .claude/hooks/guard-tests.sh; echo "終了コード: $?"
Blocked: 現在は green 段階です。src/test/java/ShippingFeeTest.java は変更せず、src/main 側の実装を直してください。テストの誤りに気づいた場合は、変更せずに理由を報告してください
終了コード: 2
$ echo '{"tool_name":"Edit","tool_input":{"file_path":"src/main/java/ShippingFee.java"}}' | .claude/hooks/guard-tests.sh; echo "終了コード: $?"
終了コード: 0
$ echo '{"tool_name":"Write","tool_input":{"file_path":".claude/tdd-phase"}}' | .claude/hooks/guard-tests.sh; echo "終了コード: $?"
Blocked: .claude/tdd-phase は人が切り替えるファイルです
終了コード: 2
$ echo red > .claude/tdd-phase
$ echo '{"tool_name":"Edit","tool_input":{"file_path":"src/test/java/ShippingFeeTest.java"}}' | .claude/hooks/guard-tests.sh; echo "終了コード: $?"
終了コード: 0

テストが通るまで終了させないStopフック

.claude/hooks/require-green.sh に保存します。

#!/bin/bash
# require-green.sh : Green / Refactor の間、テストが通るまで応答を終わらせない
INPUT=$(cat)

# すでにこのフックの指示で作業を続けている場合は、そのまま終了を認める
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

ROOT="${CLAUDE_PROJECT_DIR:-$(echo "$INPUT" | jq -r '.cwd')}"
cd "$ROOT" || exit 0
PHASE=$(cat .claude/tdd-phase 2>/dev/null || echo "none")

case "$PHASE" in
  green|refactor) ;;
  *) exit 0 ;;   # red 段階や通常の作業では何もしない
esac

# Bash 経由の書き換えも拾えるよう、コミット済みのテストとの差分を見る
if [ -n "$(git status --porcelain -- src/test)" ]; then
  echo "src/test に未コミットの変更があります。テストを元に戻し(git checkout -- src/test)、実装側を直してください" >&2
  git status --porcelain -- src/test >&2
  exit 2
fi

if ! OUTPUT=$(./run-tests.sh 2>&1); then
  echo "テストが通っていません。実装を直して ./run-tests.sh を再実行してください。失敗の内容:" >&2
  echo "$OUTPUT" | grep -v '^PASS' | tail -n 20 >&2
  exit 2
fi

exit 0

Stop は Claude が応答を終えるたびに発火するイベントで、終了コード 2 を返すと停止が取り消され、会話が続きます。動作を3通り確かめました。1つ目は全件通っている状態、2つ目は実装の境界を1か所変えた状態、3つ目はシェル経由でテストの期待値を書き換えた状態です。

$ echo '{"hook_event_name":"Stop","stop_hook_active":false}' | .claude/hooks/require-green.sh; echo "終了コード: $?"
終了コード: 0
$ echo '{"hook_event_name":"Stop","stop_hook_active":false}' | .claude/hooks/require-green.sh; echo "終了コード: $?"
テストが通っていません。実装を直して ./run-tests.sh を再実行してください。失敗の内容:
FAIL 1000g は 500円 : 期待 500 / 実際 800
FAIL 遠隔地は +400円 : 期待 900 / 実際 1200
FAIL 4999円 は通常料金 : 期待 500 / 実際 800
結果: 8 件成功 / 3 件失敗
終了コード: 2
$ git diff --stat -- src/test
 src/test/java/ShippingFeeTest.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ echo '{"hook_event_name":"Stop","stop_hook_active":false}' | .claude/hooks/require-green.sh; echo "終了コード: $?"
src/test に未コミットの変更があります。テストを元に戻し(git checkout -- src/test)、実装側を直してください
 M src/test/java/ShippingFeeTest.java
終了コード: 2

フックまわりで押さえておく公式の仕様

項目 公式ドキュメントの記載 この設定での扱い
matcher ツール名で絞る。大文字と小文字を区別。Edit|Write は編集系ツールだけ PreToolUse に指定
終了コード 2 PreToolUse はツール呼び出しを中止、Stop は停止を取り消して続行 両方のスクリプトで使用
終了コード 1 多くのイベントで「止めないエラー」として扱われ、処理は進む 止めたい箇所は必ず exit 2
stop_hook_active Stop フックの指示で続行中なら true。確認して早めに終了する 冒頭で確認
連続ブロックの上限 Stop フックが進展のないまま8回連続で止めると上書きされる 上限に頼らず冒頭で確認
シェル経由の変更 Edit|Write の matcher は Bash での書き換えを見ない Stop フックで git の差分を検査
権限モードとの関係 PreToolUse は権限モードのチェックより前に発火する 追加設定なし
タイムアウト command フックの既定は10分。timeout(秒)で変更 Stop に120秒を指定
確認方法 /hooks で登録済みのフックを一覧できる 設定後に確認

出典:https://code.claude.com/docs/ja/hooks-guide 、https://code.claude.com/docs/ja/hooks (2026年10月7日確認)

stop_hook_active を確認して早めに終わる書き方は、公式ガイドのトラブルシューティングに載っている形です。この書き方では、フックが1回続行させたあとの次の停止はそのまま通ります。「1回は必ず直させる」仕組みであり、最終的に通ったかどうかは、指示文4の出力か、もう一度 ./run-tests.sh を実行して確かめます。

検査を Claude に任せる書き方も公式ガイドにあります。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
          }
        ]
      }
    ]
  }
}

テストの実行コマンドが決まっているなら、スクリプトで合否を決める command フックのほうが結果が安定します。公式ガイドも、本番のワークフローでは command フックを優先するよう案内しています。


実際に回す:Java 21でRed→Green→Refactor

ここからは、同じ題材を実際に動かした出力です。JUnit を使わず、main と自前の検査メソッドだけで書いてあるので、Java があればそのまま試せます。

テスト(先に書く)

src/test/java/ShippingFeeTest.java

/** JUnit を使わず、main と自前の検査メソッドだけで書いたテスト。 */
public class ShippingFeeTest {
    static int passed = 0;
    static int failed = 0;

    static void assertEquals(String name, int expected, int actual) {
        if (expected == actual) {
            passed++;
            System.out.println("PASS " + name);
        } else {
            failed++;
            System.out.println("FAIL " + name + " : 期待 " + expected + " / 実際 " + actual);
        }
    }

    static void assertThrows(String name, Class<? extends Exception> type, Runnable body) {
        try {
            body.run();
            failed++;
            System.out.println("FAIL " + name + " : 例外が出なかった");
        } catch (Exception e) {
            if (type.isInstance(e)) {
                passed++;
                System.out.println("PASS " + name);
            } else {
                failed++;
                System.out.println("FAIL " + name + " : " + e.getClass().getSimpleName() + " " + e.getMessage());
            }
        }
    }

    /** 1ケースが例外で止まっても、残りのケースを続けて実行する。 */
    static void check(String name, int expected, int weightGram, int orderYen, boolean remote) {
        try {
            assertEquals(name, expected, ShippingFee.calc(weightGram, orderYen, remote));
        } catch (Exception e) {
            failed++;
            System.out.println("FAIL " + name + " : " + e.getClass().getSimpleName() + " " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        // 重さの区分(境界値)
        check("1000g は 500円", 500, 1000, 3000, false);
        check("1001g は 800円", 800, 1001, 3000, false);
        check("5000g は 800円", 800, 5000, 3000, false);
        check("5001g は 950円(1kg 切り上げで +150)", 950, 5001, 3000, false);
        check("7000g は 1100円", 1100, 7000, 3000, false);
        // 遠隔地の加算
        check("遠隔地は +400円", 900, 1000, 3000, true);
        // 送料無料の境界
        check("4999円 は通常料金", 500, 1000, 4999, false);
        check("5000円 以上は 0円", 0, 1000, 5000, false);
        check("5000円 以上でも遠隔地加算 400円は残る", 400, 1000, 5000, true);
        // 入力の検査
        assertThrows("0g は IllegalArgumentException", IllegalArgumentException.class,
                () -> ShippingFee.calc(0, 3000, false));
        assertThrows("注文金額が負は IllegalArgumentException", IllegalArgumentException.class,
                () -> ShippingFee.calc(1000, -1, false));

        System.out.println("結果: " + passed + " 件成功 / " + failed + " 件失敗");
        if (failed > 0) System.exit(1);
    }
}

実行用のスクリプト run-tests.sh です。

#!/bin/bash
# コンパイルしてテストを実行する。失敗が1件でもあれば終了コード 1
set -e
cd "$(dirname "$0")"
rm -rf out
javac -d out src/main/java/*.java src/test/java/*.java
# ロケール未設定の環境でも日本語が読めるよう、標準出力を UTF-8 に固定
java -Dstdout.encoding=UTF-8 -cp out ShippingFeeTest

Red:スタブだけ置いて、落ちることを確かめる

src/main/java/ShippingFee.java

public class ShippingFee {
    /** 送料を計算する。重さはグラム、注文金額は円。 */
    public static int calc(int weightGram, int orderYen, boolean remote) {
        throw new UnsupportedOperationException("未実装");
    }
}
$ ./run-tests.sh; echo "終了コード: $?"
FAIL 1000g は 500円 : UnsupportedOperationException 未実装
FAIL 1001g は 800円 : UnsupportedOperationException 未実装
FAIL 5000g は 800円 : UnsupportedOperationException 未実装
FAIL 5001g は 950円(1kg 切り上げで +150) : UnsupportedOperationException 未実装
FAIL 7000g は 1100円 : UnsupportedOperationException 未実装
FAIL 遠隔地は +400円 : UnsupportedOperationException 未実装
FAIL 4999円 は通常料金 : UnsupportedOperationException 未実装
FAIL 5000円 以上は 0円 : UnsupportedOperationException 未実装
FAIL 5000円 以上でも遠隔地加算 400円は残る : UnsupportedOperationException 未実装
FAIL 0g は IllegalArgumentException : UnsupportedOperationException 未実装
FAIL 注文金額が負は IllegalArgumentException : UnsupportedOperationException 未実装
結果: 0 件成功 / 11 件失敗
終了コード: 1

11件すべてが「未実装」を理由に落ちています。これが、意図した理由で落ちている Red です。 スタブを置かずに実行すると、次のようにコンパイルエラーで止まり、テストは1件も実行されません。

src/test/java/ShippingFeeTest.java:35: error: cannot find symbol
            assertEquals(name, expected, ShippingFee.calc(weightGram, orderYen, remote));
                                         ^
  symbol:   variable ShippingFee
  location: class ShippingFeeTest
…(省略)…
3 errors

終了コードはどちらも 1 ですが、コンパイルエラーの状態では「テストが正しく失敗を検出できるか」がまだ分かりません。指示文2で「コンパイルエラーは Red ではない」と書いているのはこのためです。

Green:テストを変えずに通す

public class ShippingFee {
    /** 送料を計算する。重さはグラム、注文金額は円。 */
    public static int calc(int weightGram, int orderYen, boolean remote) {
        if (weightGram <= 0) throw new IllegalArgumentException("weightGram");
        if (orderYen < 0) throw new IllegalArgumentException("orderYen");
        int fee;
        if (weightGram <= 1000) {
            fee = 500;
        } else if (weightGram <= 5000) {
            fee = 800;
        } else {
            int over = weightGram - 5000;
            int kg = over / 1000;
            if (over % 1000 != 0) kg++;
            fee = 800 + kg * 150;
        }
        if (orderYen >= 5000) fee = 0;
        if (remote) fee += 400;
        return fee;
    }
}
$ ./run-tests.sh; echo "終了コード: $?"
PASS 1000g は 500円
PASS 1001g は 800円
PASS 5000g は 800円
PASS 5001g は 950円(1kg 切り上げで +150)
PASS 7000g は 1100円
PASS 遠隔地は +400円
PASS 4999円 は通常料金
PASS 5000円 以上は 0円
PASS 5000円 以上でも遠隔地加算 400円は残る
PASS 0g は IllegalArgumentException
PASS 注文金額が負は IllegalArgumentException
結果: 11 件成功 / 0 件失敗
終了コード: 0

Refactor:通ったまま整理する

public class ShippingFee {
    static final int LIGHT_MAX_GRAM = 1000;
    static final int STANDARD_MAX_GRAM = 5000;
    static final int LIGHT_FEE = 500;
    static final int STANDARD_FEE = 800;
    static final int EXTRA_FEE_PER_KG = 150;
    static final int REMOTE_SURCHARGE = 400;
    static final int FREE_SHIPPING_ORDER_YEN = 5000;

    /** 送料を計算する。重さはグラム、注文金額は円。 */
    public static int calc(int weightGram, int orderYen, boolean remote) {
        if (weightGram <= 0) throw new IllegalArgumentException("weightGram");
        if (orderYen < 0) throw new IllegalArgumentException("orderYen");
        int base = orderYen >= FREE_SHIPPING_ORDER_YEN ? 0 : feeByWeight(weightGram);
        return base + (remote ? REMOTE_SURCHARGE : 0);
    }

    private static int feeByWeight(int weightGram) {
        if (weightGram <= LIGHT_MAX_GRAM) return LIGHT_FEE;
        if (weightGram <= STANDARD_MAX_GRAM) return STANDARD_FEE;
        int extraKg = Math.ceilDiv(weightGram - STANDARD_MAX_GRAM, 1000);
        return STANDARD_FEE + extraKg * EXTRA_FEE_PER_KG;
    }
}
$ ./run-tests.sh; echo "終了コード: $?"
PASS 1000g は 500円
PASS 1001g は 800円
PASS 5000g は 800円
PASS 5001g は 950円(1kg 切り上げで +150)
PASS 7000g は 1100円
PASS 遠隔地は +400円
PASS 4999円 は通常料金
PASS 5000円 以上は 0円
PASS 5000円 以上でも遠隔地加算 400円は残る
PASS 0g は IllegalArgumentException
PASS 注文金額が負は IllegalArgumentException
結果: 11 件成功 / 0 件失敗
終了コード: 0

3つの段階をコミットで分けておくと、テストが変わっていないことを1行で示せます。

$ git log --oneline
ccc1aa7 refactor: 定数と重さ区分の関数に分ける
144a5b3 feat: 送料計算を実装(Green)
0707dce test: 送料計算のテストを追加(Red)
$ git diff --stat 0707dce ccc1aa7 -- src/test

git diff --stat の出力が空なので、Red のコミットから Refactor のコミットまで、src/test は1行も変わっていません。

JUnitで書く場合

JUnit を使うプロジェクトでは、同じテストを次のように書きます。JUnit の公式ユーザーガイド(2026年10月7日時点で 6.1.3、実行には Java 17 以上)に載っている @Test・assertEquals・assertThrows の書き方に沿ったコードです。この記事の出力はすべて前述の main 版で取得しています。

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class ShippingFeeJUnitTest {

    @Test
    void upTo1000gIs500yen() {
        assertEquals(500, ShippingFee.calc(1000, 3000, false));
    }

    @Test
    void over1000gIs800yen() {
        assertEquals(800, ShippingFee.calc(1001, 3000, false));
    }

    @Test
    void over5000gAdds150yenPerStartedKg() {
        assertEquals(950, ShippingFee.calc(5001, 3000, false));
    }

    @Test
    void remoteAreaAdds400yen() {
        assertEquals(900, ShippingFee.calc(1000, 3000, true));
    }

    @Test
    void freeShippingKeepsRemoteSurcharge() {
        assertEquals(400, ShippingFee.calc(1000, 5000, true));
    }

    @Test
    void zeroWeightIsRejected() {
        assertThrows(IllegalArgumentException.class, () ->
            ShippingFee.calc(0, 3000, false));
    }
}

出典:https://docs.junit.org/6.1.3/writing-tests/intro.html 、https://docs.junit.org/6.1.3/writing-tests/assertions.html


AIがTDDを崩す典型8種と、検出方法・直した指示

TDD が崩れるのは、ほとんどが「テストを通すこと」が目的にすり替わったときです。 通ったという報告だけでは見分けられないので、検出方法を決めておきます。

崩れ方 検出方法 直した指示
テストを実装に合わせて弱める(期待値を書き換える) git diff --stat -- src/test が空でない。Stop フックが止める テストは変更しない。誤りだと考えたら理由を報告して止まる
期待値を実装からコピーする 期待値の根拠が仕様にない。実装の条件を1か所変えても落ちるテストがない 期待値は仕様から決め、根拠を検査名かコメントに残す
モックだらけにする テストの大半がモックの設定で、計算結果を検査していない 計算だけの処理にモックを使わない。モックは時計・通信・ファイルに限る
テストと実装を一度に書く Red の出力(落ちた実行結果)が示されていない 先にテストだけ書き、落ちた出力を貼ってから次へ進む
テストを消す・スキップする 結果の行の総件数が Red のときより少ない 件数を減らさない。総件数を Red のときと比べて報告する
コンパイルエラーを Red と呼ぶ 失敗の理由が cannot find symbol などで、テストが実行されていない スタブを置き、テストが実行されたうえで落ちる状態にする
テストの入力値だけを通す実装を書く 実装に、テストと同じ数値の組が条件として並ぶ 特定の値だけ通す分岐を書かない。別の入力のテストを足して確かめる
Refactor で挙動やテストを変える Refactor のコミットに src/test の差分がある。件数や期待値が変わる 挙動は変えない。機能追加は提案として分ける

2行目の「実装の条件を1か所変えても落ちるテストがない」は、手で1か所だけ変えて試せます。今回の題材では、境界の <= を < に変えると、Stop フックの確認で示したとおり3件が落ちました。実装を少し変えたのにテストが全部通るなら、そのテストは境界を検査できていないという合図です。確認後は git checkout -- src/main で元に戻します。


TDDが向く作業と、別の確かめ方が合う作業

TDD は「正しい結果を先に言葉で決められる」作業で力を発揮します。そうでない作業には、別の確かめ方を選びます。

作業 向き 理由と進め方
仕様が決まっている計算(料金、日数、集計) 向く 入力と期待値を先に表にできる
入出力の変換(パーサ、フォーマッタ) 向く 入力と出力の例がそのままテストになる
不具合の修正 向く 再現するテストを先に書き、落ちることを確かめてから直す
境界の多い判定(権限、状態の遷移) 向く 境界を対で並べやすい
API の入出力の約束 向く リクエストとレスポンスの例を先に決められる
既存コードの整理の前 向く 現在の挙動を固定してから変える
探索的な UI の試作 別の方法 見た目はスクリーンショットの比較で確かめる
仕様がまだ決まっていない調査 別の方法 先に使い捨てで試し、決まった部分からテストにする
外部サービスの挙動の調査 別の方法 実際に呼んだ記録を残し、分かった仕様をテストにする
性能の調整 別の方法 計測の手順と目標値を先に決め、計測結果で判断する
1回きりのスクリプト 別の方法 出力を目で確認し、繰り返し使うようになったらテストを足す

不具合の修正と UI の変更については、Claude Code の公式ベストプラクティスにも例があります。前者は「問題を再現する失敗するテストを書き、修正する」、後者は「結果のスクリーンショットを撮り、元のものと比較する」です。どちらも、Claude が自分で実行して合否を読める検査を先に渡すという同じ考え方で、TDD はそのうち「テスト」を検査に使う形だと捉えると、使い分けがしやすくなります。出典:https://code.claude.com/docs/ja/best-practices


よくある質問

Q. テストもAIが書くなら、テスト自体が誤っていることはありませんか? A. あります。そのため、人が見る場所を Red の段階に集めます。テストが仕様の各行に対応しているか、期待値が仕様から決まっているかを、実装が始まる前に確認します。Green 以降はテストが変わらない仕組みにしておけば、確認するのは最初の1回で済みます。

Q. フックを入れると、テストを直したいときに困りませんか? A. .claude/tdd-phase を red に戻せば編集できます。テストを変えるのは仕様が変わったときなので、段階を人が切り替えるという一手間が、そのまま「仕様の変更を人が承認した」という記録になります。

Q. CLAUDE.md に書くだけでは足りませんか? A. 多くの場合は CLAUDE.md と指示文で十分に守られます。ただ、公式ドキュメントが説明するとおり CLAUDE.md は強制ではないので、長い作業の途中で抜けることがあります。離席して任せる作業や、テストの書き換えが大きな手戻りになる作業では、フックを足しておくと安心です。

Q. 既存の仕様→テストケース表→テストコードという流れとは何が違いますか? A. テストを作る手順は同じです。この記事で加えているのは、実装の前に落ちることを実行で確かめる循環と、実装の段階でテストを変えさせない仕組みの2つです。


まとめ

  • 先にテストだけを書かせ、落ちた出力を見てから実装に進む。 コンパイルエラーは Red に数えない
  • Green と Refactor では「テストは変更しない。誤りだと考えたら報告して止まる」を指示に入れる
  • CLAUDE.md と rules は前提を伝えるもの、フックは必ず実行される検査。 必ず守らせたいことはフックに任せる
  • PreToolUse フックは終了コード 2 でテストへの Edit/Write を止め、Stop フックはテストが通るまで応答を終わらせない。 シェル経由の書き換えは git の差分で拾う
  • Red の時点でテストをコミットする。 git diff --stat -- src/test が空であることが、テストを変えていない証拠になる
  • 崩れ方は8種を表で点検する。 実装の境界を1か所変えて、落ちるテストがあるかを試す
  • 正しい結果を先に決められる作業に TDD を使い、見た目や性能は別の確かめ方を選ぶ

参考・出典

確認日はすべて 2026年10月7日です。

  • Claude Code のベストプラクティス:https://code.claude.com/docs/ja/best-practices
  • hooks でアクションを自動化する:https://code.claude.com/docs/ja/hooks-guide
  • Hooks リファレンス:https://code.claude.com/docs/ja/hooks
  • Claude があなたのプロジェクトを記憶する方法(CLAUDE.md・rules):https://code.claude.com/docs/ja/memory
  • JUnit ユーザーガイド(6.1.3):https://docs.junit.org/6.1.3/writing-tests/intro.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「AIとTDD|テストを先に書かせる指示文とフック設定例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?