目次を表示する

TDD実践ガイド 2026

最初のRed-Green-Refactor ── Moneyクラスをテストで駆動する

本章の方針

理論は前章で終わった。ここからは手を動かす。

本章では、SaaS課金管理システムの**金額(Money)**を題材に、最初の Red-Green-Refactor サイクルを回す。FizzBuzz のような教科書的な例ではなく、「このテストがないと本番で事故が起きる」と実感できる題材で始める。

ゴールは、通貨付き金額の計算を、テストで駆動しながらゼロから組み立てることだ。


始める前に:なぜ「金額」から始めるか

SaaS課金管理には、契約管理、請求書生成、通知など多くの機能がある。なぜ金額から始めるのか。

金額計算がTDDの最初の題材に最適な理由:
  1. 外部依存がない(DBもAPIも不要)
  2. 正解が明確(1000円 + 500円 = 1500円)
  3. バグの影響が直感的(金額のバグ = 実害)
  4. 小さく始められる(1クラスで完結する)

外部依存がなく、正解が明確で、小さく始められる──TDD の最初の一歩に必要な条件が全て揃っている。


サイクル1:等価性

🔴 Red:最初のテストを書く

まず、最も基本的な振る舞いから始める。「同じ金額・同じ通貨なら、同じ Money とみなせる」。

// money.test.ts
import { describe, test, expect } from 'vitest'
import { Money } from './money'

describe('Money', () => {
  test('同じ通貨・同じ金額のMoneyは等しい', () => {
    const a = Money.of(1000, 'JPY')
    const b = Money.of(1000, 'JPY')
    expect(a.equals(b)).toBe(true)
  })
})

この時点で money.ts は存在しない。テストを実行する。

$ npx vitest run

 FAIL  money.test.ts
 同じ通貨・同じ金額のMoneyは等しい
   Error: Cannot find module './money'

🔴 Red。テストが失敗した。これでいい。

ここで「テストが正しく失敗すること」を確認するのが、Red フェーズの目的だ。もしテストが通ってしまったら、テストが間違っている。

🟢 Green:テストを通す最小のコード

テストを通すために必要な最小限のコードを書く。

// money.ts
export class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string,
  ) {}

  static of(amount: number, currency: string): Money {
    return new Money(amount, currency)
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency
  }
}

テストを実行する。

$ npx vitest run

 money.test.ts
 同じ通貨・同じ金額のMoneyは等しい

 Tests  1 passed

🟢 Green。テストが通った。

ここで意識してほしいのは、書かなかったものだ。

書かなかったもの(意図的に):
  ・amount の負数チェック   → まだテストがない
  ・currency の列挙型       → まだテストがない
  ・add / subtract メソッド → まだテストがない

「将来必要になりそう」と思っても書かない。テストが要求するまで書かない。これが TDD の規律だ。

🔵 Refactor:改善の余地を探す

Green になったら、コードを見直す。今回はクラスが小さいので、大きなリファクタリングは不要だ。ただし、一つだけ確認する。

リファクタリングチェックリスト:
  □ 重複はないか?              → なし
  □ 命名は意図を表しているか?  → OK
  □ 不要な複雑さはないか?      → なし
→ リファクタリング不要。次のサイクルへ。

リファクタリングが不要な場合は、それでいい。「毎回必ず何かを変えなければ」と思う必要はない。


サイクル2:非等価性

🔴 Red

等価性の裏を確認する。通貨が違えば等しくない。

test('通貨が異なるMoneyは等しくない', () => {
  const jpy = Money.of(1000, 'JPY')
  const usd = Money.of(1000, 'USD')
  expect(jpy.equals(usd)).toBe(false)
})

テストを実行する。

$ npx vitest run

 同じ通貨・同じ金額のMoneyは等しい
 通貨が異なるMoneyは等しくない

 Tests  2 passed

──あれ、🟢 Green になった。

なぜ最初から通るテストにも意味があるか

「最初から通るなら書く必要ないのでは?」と思うかもしれない。違う。

このテストは 「通貨が違えば等しくない」という仕様を文書化している。将来、equals の実装を変更したとき、このテストが仕様の逸脱を検知する。テストは「今バグを見つける道具」ではなく、**「将来のリグレッションを防ぐ仕様書」**でもある。

ただし、Kent Beck の流派では「最初から通るテストは削除してよい」という立場もある。これはチームの判断に委ねられる領域だ。本記事では、仕様の明示化として残す立場を取る。


サイクル3:生成時の検証

🔴 Red

金額計算でバグが起きる最大の原因は、不正な値がシステムに入り込むことだ。マイナスの金額を防ぐテストを書く。

test('負の金額でMoneyを生成できない', () => {
  expect(() => Money.of(-100, 'JPY')).toThrow('金額は0以上である必要があります')
})
$ npx vitest run

 負の金額でMoneyを生成できない
   AssertionError: expected function to throw an error

🔴 Red。Money.of(-100, 'JPY') はエラーを投げずに Money を生成してしまう。

🟢 Green

// money.ts(変更箇所のみ)
static of(amount: number, currency: string): Money {
  if (amount < 0) {
    throw new Error('金額は0以上である必要があります')
  }
  return new Money(amount, currency)
}
$ npx vitest run

 同じ通貨・同じ金額のMoneyは等しい
 通貨が異なるMoneyは等しくない
 負の金額でMoneyを生成できない

 Tests  3 passed

🟢 Green。

🔵 Refactor

ここで一つ気づく。Money.of にバリデーションロジックが直接書かれている。今は1つだが、バリデーションが増えると of メソッドが肥大化する。

──しかし、まだバリデーションは1つしかない。「将来増えるかもしれない」は、リファクタリングの理由にならない。今は見送る。

判断:リファクタリング不要
理由:バリデーションが1つの段階で抽象化するのは早すぎる

サイクル4:加算

🔴 Red

いよいよ金額の計算に入る。同じ通貨の加算から。

test('同じ通貨のMoneyを加算できる', () => {
  const a = Money.of(1000, 'JPY')
  const b = Money.of(500, 'JPY')
  const result = a.add(b)
  expect(result.equals(Money.of(1500, 'JPY'))).toBe(true)
})
$ npx vitest run

 同じ通貨のMoneyを加算できる
   TypeError: a.add is not a function

🔴 Red。

🟢 Green

// money.ts に add メソッドを追加
add(other: Money): Money {
  return Money.of(this.amount + other.amount, this.currency)
}

🟢 Green。

ここで重要な判断がある。「異なる通貨の加算を防ぐべきでは?」──その通り。だが、まだそのテストを書いていない。テストがない機能は実装しない。次のサイクルで扱う。


サイクル5:異なる通貨の加算を防ぐ

🔴 Red

SaaS課金で最も危険なバグの一つ:「ドルの割引を円の金額に適用してしまう」。これをテストで防ぐ。

test('異なる通貨のMoneyを加算するとエラー', () => {
  const jpy = Money.of(1000, 'JPY')
  const usd = Money.of(10, 'USD')
  expect(() => jpy.add(usd)).toThrow('通貨が一致しません')
})

🔴 Red。現在の add は通貨チェックをしていない。

🟢 Green

add(other: Money): Money {
  if (this.currency !== other.currency) {
    throw new Error('通貨が一致しません')
  }
  return Money.of(this.amount + other.amount, this.currency)
}

🟢 Green。

🔵 Refactor

ここで、コード全体を見渡してみよう。

// money.ts(サイクル5終了時点)
export class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string,
  ) {}

  static of(amount: number, currency: string): Money {
    if (amount < 0) {
      throw new Error('金額は0以上である必要があります')
    }
    return new Money(amount, currency)
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('通貨が一致しません')
    }
    return Money.of(this.amount + other.amount, this.currency)
  }
}

気づいたことがある。

観察:
  ・constructor は private → 外部から new Money() できない
  ・of で生成時にバリデーション → 不正な値は入り込めない
  ・equals は値ベースで比較 → 同じ金額・通貨なら「同じ」
  ・add は新しい Money を返す → 元のオブジェクトは変わらない(不変)

テストに駆動されてコードを書いてきただけなのに、ある特徴が自然に立ち現れている。

  • 生成時に自己検証する
  • 値が同じなら同一とみなす
  • 一度作ったら変更できない(不変)

なぜこうなったのか。テストが「正しくない値を拒絶すること」を要求し、「同じ値なら同一であること」を要求し、「演算は新しいインスタンスを返すこと」を要求したからだ。テストが設計を駆動した結果、信頼できる型が生まれた。

この特徴がどこかで見た何かに似ていると思った方──その勘は正しい。だが、今はTDDの話を続けよう。


サイクル6:通貨の型安全性

🔴 Red

currency: string では、タイポを防げない。'JPY''jpy''Jpy' は全て異なる文字列だ。

test('未知の通貨コードでMoneyを生成できない', () => {
  expect(() => Money.of(1000, 'INVALID')).toThrow('不正な通貨コードです')
})

🔴 Red。

🟢 Green

// currency.ts
const SUPPORTED_CURRENCIES = ['JPY', 'USD', 'EUR'] as const
export type Currency = typeof SUPPORTED_CURRENCIES[number]

export function validateCurrency(code: string): Currency {
  if (!SUPPORTED_CURRENCIES.includes(code as Currency)) {
    throw new Error('不正な通貨コードです')
  }
  return code as Currency
}
// money.ts(変更箇所)
import { type Currency, validateCurrency } from './currency'

export class Money {
  private constructor(
    readonly amount: number,
    readonly currency: Currency,
  ) {}

  static of(amount: number, currency: string): Money {
    const validCurrency = validateCurrency(currency)
    if (amount < 0) {
      throw new Error('金額は0以上である必要があります')
    }
    return new Money(amount, validCurrency)
  }

  // ...(他のメソッドは変更なし)
}

🟢 Green。

🔵 Refactor

Currency を分離したことで、Money.of のバリデーションが2箇所に分散した(通貨は validateCurrency、金額は of 内)。整理する。

static of(amount: number, currency: string): Money {
  const validCurrency = validateCurrency(currency)
  if (amount < 0) {
    throw new Error('金額は0以上である必要があります')
  }
  if (!Number.isFinite(amount)) {
    throw new Error('金額は有限な数値である必要があります')
  }
  return new Money(amount, validCurrency)
}

──Number.isFinite のチェックを追加した。あれ?テストを書いていないのにコードを追加した。

これは TDD のルール違反だ。先にテストを書かなければならない。元に戻す。

// ❌ テストなしで追加したコードを取り消す
static of(amount: number, currency: string): Money {
  const validCurrency = validateCurrency(currency)
  if (amount < 0) {
    throw new Error('金額は0以上である必要があります')
  }
  return new Money(amount, validCurrency)
}

Number.isFinite チェックが必要だと思ったなら、まずテストを書く

test('Infinity でMoneyを生成できない', () => {
  expect(() => Money.of(Infinity, 'JPY')).toThrow()
})

test('NaN でMoneyを生成できない', () => {
  expect(() => Money.of(NaN, 'JPY')).toThrow()
})

🔴 Red を確認してから、コードを追加する。これが規律だ。


ここまでのテスト一覧

6サイクルを終えた時点でのテスト一覧を確認する。

describe('Money', () => {
  test('同じ通貨・同じ金額のMoneyは等しい', ...)
  test('通貨が異なるMoneyは等しくない', ...)
  test('負の金額でMoneyを生成できない', ...)
  test('同じ通貨のMoneyを加算できる', ...)
  test('異なる通貨のMoneyを加算するとエラー', ...)
  test('未知の通貨コードでMoneyを生成できない', ...)
  test('Infinity でMoneyを生成できない', ...)
  test('NaN でMoneyを生成できない', ...)
})

8本のテストが、Money の振る舞いを仕様書のように記述している。新しくチームに入ったメンバーは、このテストファイルを読むだけで「Money がどう振る舞うべきか」を理解できる。


TDDで「書かなかったもの」を振り返る

ここで重要な問いかけをしたい。テストに駆動されなかった機能──つまり書かなかったものを確認しよう。

テストが要求しなかったので書かなかったもの:
  ・subtract(減算)メソッド    → まだ必要になっていない
  ・multiply(乗算)メソッド    → まだ必要になっていない
  ・toString / format メソッド  → まだ必要になっていない
  ・JSONシリアライズ            → まだ必要になっていない
  ・通貨変換                    → まだ必要になっていない

これらは「将来必要になりそう」な機能だ。でも、TDD では今必要なものだけを書く。必要になったとき、テストを書けば、その機能は正しく追加される。

「YAGNI(You Ain’t Gonna Need It)」──必要になるまで作らない。TDD はこの原則を自然に守らせる。テストがないものは存在しない。テストが要求したものだけが存在する。


本章のまとめ

要点内容
6サイクル等価性 → 非等価性 → 生成検証 → 加算 → 通貨チェック → 型安全性
生まれた設計自己検証・値ベース等価・不変──テストが駆動した自然な帰結
規律テストなしにコードを追加しない。リファクタリング中に「つい」書いたら戻す
YAGNIテストが要求していない機能は書かない

次章では、「次に何をテストすべきか」を決める技法──テストリストの考え方を学ぶ。Money のテストをさらに充実させながら、テスト設計の戦略を身につけよう。