Use Cases (更新: 2026/6/7)

Claude Codeにテストから書かせるTDD:Red-Green-Refactorの回し方

Claude CodeでTDDを回す手順を、テストファースト→失敗→最小実装→緑→整理の順で解説。Vitestのコピペで動くコードと依頼テンプレ付き。

Claude Codeにテストから書かせるTDD:Red-Green-Refactorの回し方

「クーポンの割引計算、作っといて」

そう頼んだら、Claude Codeは30秒でそれっぽい関数を出してきました。動く。テストも通る。めでたしめでたし……と思った2週間後、期限切れのクーポンが本番で通っていることに気づきました。テストは「正常系」しか見ていなかったんです。

速いコードと、壊れないコードは違う。これを僕は何度も痛い目で学びました。そして行き着いたのが、Claude Codeにテストから先に書かせるやり方、つまりTDDです。

この記事の要点

  • TDD(テスト駆動開発)は「先に失敗するテスト→最小実装で緑→整理」の3拍子。略してRed-Green-Refactor。
  • Claude Codeは丸投げすると「実装に合わせたテスト」を後付けしがち。だからテストを先に固定して見せるのがコツ。
  • テストするのは全部じゃない。「お金・権限・外部連携・削除・日付」の5つから守る。
  • 下に貼ったVitestのコードはコピペでそのまま回る(Red→Greenを再現できる)。
  • 依頼文に「正常系1・失敗系2・境界値2」と数を指定するだけで、出力がぐっと安定する。

TDDって、要するに信号機の3色

難しい言葉に聞こえますが、やることは信号機の色を順に踏むだけです。

  • Red(赤): まだ実装がない状態で、先にテストを書く。当然落ちる。
  • Green(緑): そのテストを通すためだけの、いちばん小さいコードを書く。
  • Refactor(整理): 動きは変えずに、重複や変な名前だけ直す。

赤を確認してから緑に進む。この順番が肝です。なぜなら、最初に赤を見ておかないと、そのテストが本当にバグを捕まえられるか分からないから。最初から緑のテストは、何も守っていない可能性があります。テスト用紙に答えが透けて見えている状態、と言えば伝わるでしょうか。

僕が冒頭でやらかしたのは、まさにこれ。「期限切れクーポンは弾く」というテストを一度も書いていなかった。だから実装も弾かなかった。テストがない振る舞いは、AIにとって「存在しない要件」なんです。

Claude CodeとTDDが噛み合う理由

人間がTDDを面倒くさがるのは、テストを並べる作業が地味だからです。境界値を1つずつ書き出す、失敗ログを読む、CIの設定をいじる。どれも退屈。

Claude Codeはこの地味な部分を会話で一気に片付けます。「0個・100個・101個・小数・空文字を入れて」と言えば、境界値のテストをまとめて書く。失敗ログを貼れば、原因を読んで直す。ここは本当に速い。

ただし弱点が1つ。ゴールが曖昧だと、Claude Codeは実装を先に書いて、それに合うテストを後から作ります。これだと「自分で書いた答えに自分でマルをつける」状態になり、バグを素通りさせます。だから人間がやるのは、テストを先に固定して、赤を見せること。順番を握るだけで、AIの速さが「壊れにくい差分」に変わります。

どこを任せて、どこを握るか

全部を任せるのでも、全部を握るのでもありません。線引きはこうです。

段階Claude Codeに任せる人間が握る
Red仕様から失敗テストを書く仕様を勝手に盛っていないか
Greenテストを通す最小実装余計な抽象化や副作用がないか
Refactor重複を消し、名前を整える動きが変わっていないか
CInpm testを自動で回す本番に近いNodeか
運用hooksやCLAUDE.mdで習慣化自動処理が遅すぎないか

真ん中の手を動かす作業はAIが速い。でも「仕様」と「合格ライン」は人間の担当です。ここを渡すと事故ります。

コピペで動かす:Vitestで価格計算をRed→Greenする

説明より動かすほうが早いです。最初の題材はクーポン付き価格計算。ECでも教材販売でも、お金に直結するロジックはTDDの効果がいちばん出ます。1円のズレや期限切れの見落としが、そのまま売上と信頼に響くからです。

まずVitestを入れます。公式の案内どおりnpm install -D vitestで、いまのVitestはメジャーバージョンが4系、Node 20以上が前提です。

npm install -D vitest

package.jsonはこうします。

{
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "vitest": "^4.0.0"
  }
}

ここからが本番。まだ実装がない状態で、先にテストを書きます。src/cart.test.tsとして保存してください。

import { describe, expect, it } from "vitest";
import { priceCart, ValidationError } from "./cart";

describe("priceCart", () => {
  // 正常系: クーポンなしの合計
  it("クーポンなしで小計と合計を出す", () => {
    const result = priceCart({
      items: [
        { sku: "book", unitPriceCents: 1200, quantity: 2 },
        { sku: "video", unitPriceCents: 3000, quantity: 1 },
      ],
    });

    expect(result).toEqual({
      subtotalCents: 5400,
      discountCents: 0,
      totalCents: 5400,
    });
  });

  // 正常系: 有効な割引クーポン
  it("有効な割引クーポンを適用する", () => {
    const result = priceCart(
      {
        items: [{ sku: "course", unitPriceCents: 10000, quantity: 1 }],
        coupon: { code: "SPRING20", percentOff: 20, expiresAt: "2026-06-30T00:00:00.000Z" },
      },
      { now: new Date("2026-06-07T00:00:00.000Z") },
    );

    expect(result.totalCents).toBe(8000);
    expect(result.discountCents).toBe(2000);
  });

  // 失敗系: 期限切れクーポンは弾く(僕が本番で見落としたケース)
  it("期限切れクーポンを拒否する", () => {
    expect(() =>
      priceCart(
        {
          items: [{ sku: "course", unitPriceCents: 10000, quantity: 1 }],
          coupon: { code: "OLD20", percentOff: 20, expiresAt: "2026-05-01T00:00:00.000Z" },
        },
        { now: new Date("2026-06-07T00:00:00.000Z") },
      ),
    ).toThrow(ValidationError);
  });

  // 境界値: 数量0や負数は弾く
  it("数量0や負数を拒否する", () => {
    expect(() =>
      priceCart({ items: [{ sku: "book", unitPriceCents: 1200, quantity: 0 }] }),
    ).toThrow("quantity must be positive");
  });
});

ここでnpm testを打つと、./cartが存在しないので落ちます。これが赤です。 Claude Codeにはこの落ちたログを見せてから実装させます。依頼文はこう。

いまRed(赤)の段階です。src/cart.test.tsを先に書きました。
src/cart.ts はまだありません。

お願い:
1. npm test を実行して、失敗ログを見せてください。
2. そのテストを通す最小の src/cart.ts だけを書いてください。
3. DB・UI・外部API・将来用の抽象化は足さないでください。
4. Greenになった後で、重複と命名だけ整理してください。

Claude Codeが出してくる緑の最小実装はこんな形です。src/cart.tsとして保存すれば、上のテストとセットで通ります。

export class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

type CartItem = { sku: string; unitPriceCents: number; quantity: number };
type Coupon = { code: string; percentOff: number; expiresAt: string };
type CartInput = { items: CartItem[]; coupon?: Coupon };
type PriceOptions = { now?: Date };

export function priceCart(input: CartInput, options: PriceOptions = {}) {
  if (input.items.length === 0) {
    throw new ValidationError("cart must contain at least one item");
  }

  const subtotalCents = input.items.reduce((sum, item) => {
    if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
      throw new ValidationError("quantity must be positive");
    }
    if (!Number.isInteger(item.unitPriceCents) || item.unitPriceCents < 0) {
      throw new ValidationError("unitPriceCents must be a non-negative integer");
    }
    return sum + item.unitPriceCents * item.quantity;
  }, 0);

  // 日付は外から渡す(now)。テストで時刻を固定できる
  const discountCents = calculateDiscount(subtotalCents, input.coupon, options.now ?? new Date());

  return { subtotalCents, discountCents, totalCents: subtotalCents - discountCents };
}

function calculateDiscount(subtotalCents: number, coupon: Coupon | undefined, now: Date) {
  if (!coupon) return 0;
  if (coupon.percentOff <= 0 || coupon.percentOff > 100) {
    throw new ValidationError("percentOff must be between 1 and 100");
  }
  if (new Date(coupon.expiresAt).getTime() < now.getTime()) {
    throw new ValidationError("coupon expired");
  }
  return Math.round(subtotalCents * (coupon.percentOff / 100));
}

ここで絶対にやらないこと。「ついでに汎用的にして」と頼まないでください。緑は最小で十分です。先に設計を広げると、テストが守っていない抽象化や、誰も使わない設定、まだ来ていない将来要件が混ざります。広げるのはテストを足してからです。

ライブラリを増やしたくないなら node:test

外部ライブラリを足したくないNodeプロジェクトもあります。その場合は標準のnode:testが便利です。これはNode 20で安定版になった組み込みのテストランナーで、node --testで起動し、*.test.js*.test.tsのようなファイル名を自動で拾います。

次のファイルはlimit.test.mjsとしてそのまま走ります。CLIの引数チェックを、端の値で固める例です。

import test from "node:test";
import assert from "node:assert/strict";

// テスト対象: 件数指定をパースする関数
export function parseLimit(value, fallback = 20) {
  if (value === undefined || value === "") return fallback;
  const parsed = Number(value);
  if (!Number.isInteger(parsed)) throw new TypeError("limit must be an integer");
  if (parsed < 1 || parsed > 100) throw new RangeError("limit must be between 1 and 100");
  return parsed;
}

test("空や未指定のときは既定値を使う", () => {
  assert.equal(parseLimit(undefined), 20);
  assert.equal(parseLimit("", 50), 50);
});

test("1から100まで受け付ける", () => {
  assert.equal(parseLimit("1"), 1);
  assert.equal(parseLimit("100"), 100);
});

test("小数と範囲外は弾く", () => {
  assert.throws(() => parseLimit("1.5"), /integer/);
  assert.throws(() => parseLimit("0"), /between 1 and 100/);
  assert.throws(() => parseLimit("101"), /between 1 and 100/);
});
node --test limit.test.mjs

依頼するときは「境界値を足して」と曖昧に言わない。「1・100・0・101・小数・空文字・未指定を入れて」と具体的な数を渡します。境界値というのは仕様の端っこにある値のこと。バグは真ん中の普通の値ではなく、この端で起きます。軽く回す検証ループだけ欲しいときはClaude Codeの軽量ハーネスで型チェックとテストだけ回すも合わせてどうぞ。

バグ修正こそTDDの出番

新機能より、TDDが効くのはバグ修正です。冒頭の「期限切れクーポンが通った」障害なら、まず再発防止のテストを赤で固定します。

既存APIのバグ修正をTDDでやってください。

背景:
- 期限切れクーポンが POST /checkout で通ってしまった。
- 直した後も、正常クーポンとクーポンなし購入は壊したくない。

Red:
- 期限切れクーポンで 400 を期待するテストを追加。
- いまの実装でそのテストが落ちることを確認(ログを見せる)。

Green:
- 最小変更でテストを通す。

Refactor:
- 日付比較の重複だけ関数に切り出す。

完了条件:
- 追加したテスト名・失敗ログ・修正ファイル・実行コマンドを報告。

この依頼は「実装して」ではなく「再発を止めて」という目的を渡しています。差分がレビューしやすくなり、同じ事故が二度と起きない保険にもなります。API全体のテスト設計はClaude CodeでAPIテストを自動化する、ブラウザ越しの本物の操作を守りたいならE2Eテストで何を守るかが地続きです。

CIで赤を取りこぼさない

ローカルで緑でも、CIで落ちるなら未完成です。GitHub ActionsではNodeのバージョンを明示し、npm cinpm testを分けます。

name: test
on:
  pull_request:
  push:
    branches: [main]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

CI設定もTDDの一部として頼みます。「package.jsonのtestがCIで動くか」「Node 22を使うか」「npm ciの後にnpm test」「PR本文向けに変更を短くまとめて」——この4点を渡すと、抜けが減ります。

何をテストするか迷ったら、この5つから

初心者がいちばん詰まるのが「どこまで書けばいいの?」です。全行テストしようとすると疲れて続きません。かといって正常系だけだとTDDの意味がない。

最初は5つだけ守ってください。お金・権限・外部連携・削除・日付。価格計算、ログイン、Webhook、削除API、期限切れ判定。落ちたときの被害が大きい順です。Claude Codeに渡すときも「正常系1つ、失敗系2つ、境界値2つ」と数で指定すると、出力が安定します。

題材ごとの目安はこう。

  • 価格計算: 通常購入 / 期限切れクーポン / 0個注文 / 100%割引 / 端数丸め
  • CLI入力: 未指定 / 最小値 / 最大値 / 小数 / 範囲外
  • API: 認証なし / 不正JSON / 存在しないID / 重複リクエスト

そしてテスト名は「仕様として読める日本語か英語」にします。workstest1では半年後に何を守っているか分かりません。期限切れクーポンを拒否するのように、期待する振る舞いをそのまま名前にする。Claude Codeが曖昧な名前を出したら、実装より先に名前を直させます。テスト全体の優先順位の付け方はClaude Codeで決めるテスト戦略に詳しくまとめてあります。

僕がTDDでやらかした失敗3つ

正直に書きます。最初は赤を確認せずに進めて、後から「このテスト、最初から緑だったぞ」と気づいたことが何度もありました。答えを見てから問題を解いていたわけで、バグを1つも捕まえていなかった。

2つ目は、実装の中身に寄りすぎたテスト。内部関数の呼ばれた回数や配列の並びまで縛ったら、ちょっと整理しただけでテストが全部赤くなって、リファクタが怖くなりました。見るべきは合計・割引・エラーといった「利用者に見える結果」だけで十分でした。

3つ目は、new Date()をテストの中で直に呼んだこと。その月は通ったのに、翌月になったら期限判定のテストが落ちた。上のコードのようにnowを外から渡す形にしたら、いつ実行しても同じ結果になりました。これを「日付を注入する」と言います。

よくある質問

Q. Claude Codeにテストも実装もまとめて頼んじゃダメ? A. ダメではないですが、それだと実装に都合のいいテストが付くだけになりがちです。先にテストを書いて赤を見せ、それから実装を頼む。この一手間でバグの検出力がまるで変わります。

Q. テストを先に書くと、かえって遅くなりませんか? A. 最初の数回は遅く感じます。でもバグの手戻りが消えるぶん、トータルでは速いです。特にお金や認証まわりは、本番で事故ると修正+謝罪+データ復旧で何倍も時間を食います。

Q. VitestとJest、node:test、どれを使えばいい? A. 新規のNodeプロジェクトならVitestが手軽です。依存を一切増やしたくないなら標準のnode:test。既存がJestならそのままで構いません。TDDの回し方自体はどれでも同じです。

Q. カバレッジ100%を目指すべき? A. 目指さなくていいです。数字を追うと、意味の薄いテストで埋めたくなります。それより「お金・権限・外部連携・削除・日付」が確実に守れているかを見てください。

Q. Claude Codeが「通すために古いテストを直しました」と言ってきたら? A. 警戒してください。期待値を勝手に変えると、守っていたはずの仕様が消えます。削除や変更が要るなら、必ず理由を先に説明させ、人間が判断します。

まとめ:速さを「壊れにくい差分」に変える

TDDは儀式ではありません。Claude Codeの速さを、壊れにくいコードに変えるための実務的な足場です。

やることは信号機の3色だけ。先に失敗するテストを書いて赤を見せる、最小実装で緑にする、動きを変えずに整える。守る範囲は「お金・権限・外部連携・削除・日付」の5つから。依頼文には「正常系1・失敗系2・境界値2」と数を入れる。これだけでClaude Codeの出力は驚くほど安定します。

冒頭の期限切れクーポン事件のあと、僕はPR本文に3行だけ残すようにしました。「赤で落ちたテスト名」「緑にしたコマンド」「まだ見ていない範囲」。大げさなドキュメントじゃなく、AIが速く動いた証拠を人間が確認できる形にするためのメモです。これを始めてから、レビューも引き継ぎもずっと楽になりました。1つの価格計算、1つのCLI、1つのAPIバグから始めれば十分です。

手元に置く依頼テンプレやレビュー観点をまとめて整えたいなら教材一覧が近道です。チームの既存リポジトリにTDDとCI、権限設計まで入れるなら研修・導入相談で実コード前提に組み立てられます。

#Claude Code #TDD #テスト駆動開発 #テストファースト #Vitest
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。