本章の方針
理論は前章で終わった。ここからは手を動かす。
本章では、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 のテストをさらに充実させながら、テスト設計の戦略を身につけよう。