本章の方針
前章までの Money は状態を持たなかった。一度作ったら変わらない。テストしやすい理想的な型だ。
しかし、実務のコードの多くは状態を持つ。ユーザーの契約は「トライアル → アクティブ → キャンセル」と遷移する。注文は「作成 → 確認 → 出荷 → 完了」と進む。これらの状態遷移をテストで駆動する方法を、本章で学ぶ。
題材は、SaaS課金管理の**Subscription(契約)**だ。
状態遷移の整理:テストの前にやること
コードを書く前に、Subscription の状態遷移を整理する。テストリスト(Ch.4)の応用だ。
stateDiagram-v2
[*] --> trial : トライアル開始
trial --> active : アクティベート
trial --> canceled : キャンセル
active --> past_due : 支払い失敗
active --> canceled : キャンセル
past_due --> active : 支払い成功
past_due --> canceled : 猶予期間超過
canceled --> [*]
この図から、テストリストを導出する。
Subscription テストリスト:
[生成]
□ トライアルとして生成できる
□ 生成直後のステータスは trial
[正常な遷移]
□ trial → active(アクティベート)
□ active → canceled(キャンセル)
□ active → past_due(支払い失敗)
□ past_due → active(支払い成功)
□ past_due → canceled(猶予期間超過)
□ trial → canceled(トライアル中のキャンセル)
[不正な遷移(ガード)]
□ active → trial は不可
□ canceled → active は不可
□ canceled → trial は不可
□ trial → past_due は不可
状態遷移図を先に描くことで、テストリストが体系的に導出できる。これは TDD を状態を持つオブジェクトに適用するときの定石だ。
サイクル1:最小の生成
🔴 Red
最も簡単なテストから始める。
// subscription.test.ts
import { describe, test, expect } from 'vitest'
import { Subscription } from './subscription'
describe('Subscription', () => {
test('トライアルとして生成できる', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
expect(sub.status).toBe('trial')
})
})
🔴 Red。Subscription は存在しない。
🟢 Green
// subscription.ts
type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'canceled'
export class Subscription {
private constructor(
readonly customerId: string,
readonly planId: string,
private _status: SubscriptionStatus,
) {}
get status(): SubscriptionStatus {
return this._status
}
static startTrial(customerId: string, planId: string): Subscription {
return new Subscription(customerId, planId, 'trial')
}
}
🟢 Green。
Money の経験が活きている。private constructor + 静的ファクトリメソッドのパターンを自然に使った。テストが「トライアルとして生成」を要求したから、startTrial という名前のファクトリが生まれた。
サイクル2:正常な状態遷移
🔴 Red
トライアルからアクティブへの遷移。
test('トライアルをアクティベートできる', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
expect(sub.status).toBe('active')
})
🔴 Red。activate メソッドがない。
🟢 Green
activate(): void {
this._status = 'active'
}
🟢 Green。
ここで気づいてほしい──Money と違って、Subscription は状態を変更している。this._status = 'active' は破壊的操作だ。Money は不変だったが、Subscription は変わる。これは「値」と「ライフサイクルを持つ個体」の本質的な違いだ。
テストはまだこの違いについて何も言っていない。ただ、コードを書く側の判断として、Subscription は new するたびに別の個体であり、状態遷移するものだ、という理解が自然に立ち現れている。
サイクル3:不正な遷移のガード
ここからが本番だ。「正しく動く」テストは簡単だが、「間違った操作を防ぐ」テストが設計を強くする。
🔴 Red
キャンセル済みの契約をアクティベートしようとしたら?
test('キャンセル済みの契約はアクティベートできない', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.cancel()
expect(() => sub.activate()).toThrow('canceled 状態からは activate できません')
})
このテストを書くために、まず cancel メソッドも必要だ。テストの Arrange 部分で使うので、先にサイクルを回す。
// cancel を先に実装(別のミニサイクル)
test('アクティブな契約をキャンセルできる', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.cancel()
expect(sub.status).toBe('canceled')
})
🔴 Red → 🟢 Green(cancel メソッドを追加)。
戻って本来のテストを実行する。
$ npx vitest run
✗ キャンセル済みの契約はアクティベートできない
AssertionError: expected function to throw an error
🔴 Red。activate() は無条件に _status = 'active' にしてしまう。
🟢 Green
activate(): void {
if (this._status === 'canceled') {
throw new Error('canceled 状態からは activate できません')
}
this._status = 'active'
}
🟢 Green。
🔵 Refactor
もう一つ、不正な遷移を追加する。
test('アクティブな契約をアクティベートしても意味がない', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
expect(() => sub.activate()).toThrow()
})
このテストを通すために、activate のガードを拡張する。
activate(): void {
if (this._status !== 'trial' && this._status !== 'past_due') {
throw new Error(`${this._status} 状態からは activate できません`)
}
this._status = 'active'
}
ここで設計の判断が入った。activate できるのは trial と past_due の2状態だけ。「何ができないか」ではなく「何ができるか」のホワイトリストで制御している。
この判断は、テストに駆動された。「canceled からは不可」「active からも不可」──不正ケースのテストを積み重ねた結果、ホワイトリスト方式に辿り着いた。
サイクル4〜6:残りの状態遷移
同じパターンで残りの遷移を実装する。ここでは要点のみ示す。
test('支払い失敗でpast_dueに遷移する', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.markPastDue()
expect(sub.status).toBe('past_due')
})
test('past_dueから支払い成功でactiveに戻る', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.markPastDue()
sub.activate() // 支払い成功 = 再アクティベート
expect(sub.status).toBe('active')
})
test('トライアルからpast_dueには直接遷移できない', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
expect(() => sub.markPastDue()).toThrow()
})
各テストが、状態遷移図の「矢印がある遷移」と「矢印がない遷移」を一つずつ検証している。
サイクル7:プラン変更
状態遷移に加えて、Subscription にはもう一つ重要な振る舞いがある。プラン変更だ。
🔴 Red
test('アクティブな契約のプランを変更できる', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.changePlan('plan-premium')
expect(sub.planId).toBe('plan-premium')
})
🟢 Green
changePlan(newPlanId: string): void {
if (this._status !== 'active') {
throw new Error('プラン変更はアクティブな契約でのみ可能です')
}
this._planId = newPlanId
}
──ここでガードを最初から書いた。activate の経験で「ガードは最初から入れた方がいい」と学んだからだ。
これは TDD のルール違反だろうか?
厳密には、「アクティブでない契約のプラン変更はエラー」のテストをまだ書いていない。しかし、ここは判断の分かれ目だ。
2つの立場:
A. 厳密TDD:ガードのテストを先に書くべき。テストなしのコードは書かない。
B. 実践TDD:状態遷移のガードパターンは確立した。自信があるなら先に書いてよい。
本記事の立場:B
ただし、ガードのテストは「後で必ず書く」。書かないのではなく、順序の問題。
// ガードのテストも追加する
test('トライアル中はプラン変更できない', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
expect(() => sub.changePlan('plan-premium')).toThrow()
})
test('同じプランへの変更はエラー', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
expect(() => sub.changePlan('plan-basic')).toThrow('同じプランへの変更はできません')
})
全体リファクタリング:ガードの統一
サイクルを重ねた結果、各メソッドにガード条件が散在している。
// リファクタリング前:ガードが各メソッドに散らばっている
activate(): void {
if (this._status !== 'trial' && this._status !== 'past_due') {
throw new Error(`${this._status} 状態からは activate できません`)
}
this._status = 'active'
}
cancel(): void {
if (this._status === 'canceled') {
throw new Error('既にキャンセル済みです')
}
this._status = 'canceled'
}
markPastDue(): void {
if (this._status !== 'active') {
throw new Error(`${this._status} 状態からは past_due に遷移できません`)
}
this._status = 'past_due'
}
changePlan(newPlanId: string): void {
if (this._status !== 'active') {
throw new Error('プラン変更はアクティブな契約でのみ可能です')
}
if (this._planId === newPlanId) {
throw new Error('同じプランへの変更はできません')
}
this._planId = newPlanId
}
パターンが見えてきた。全てのメソッドで「現在のステータスが許可されたステータスか」をチェックしている。これをヘルパーメソッドに抽出する。
// リファクタリング後:ガードを統一
private assertStatusIn(...allowed: SubscriptionStatus[]): void {
if (!allowed.includes(this._status)) {
throw new Error(
`${this._status} 状態では この操作は実行できません(許可: ${allowed.join(', ')})`
)
}
}
activate(): void {
this.assertStatusIn('trial', 'past_due')
this._status = 'active'
}
cancel(): void {
this.assertStatusIn('trial', 'active', 'past_due')
this._status = 'canceled'
}
markPastDue(): void {
this.assertStatusIn('active')
this._status = 'past_due'
}
changePlan(newPlanId: string): void {
this.assertStatusIn('active')
if (this._planId === newPlanId) {
throw new Error('同じプランへの変更はできません')
}
this._planId = newPlanId
}
リファクタリング後、全テストを実行する。
$ npx vitest run
✓ subscription.test.ts (12 tests)
Tests 12 passed
全テストが通る。振る舞いは変わっていない。構造だけが改善された。
振り返り:テストが設計を駆動した過程
ここまでを振り返ると、テストに駆動されて以下の設計判断が自然に生まれた。
テストが駆動した設計判断:
1. private constructor + ファクトリメソッド
→ テストが「トライアルとして生成」を要求したから
2. 状態遷移のホワイトリストガード
→ 不正遷移のテストを積み重ねた結果
3. ガードの共通化(assertStatusIn)
→ Refactor フェーズでパターンを発見した結果
4. 識別子(customerId)と状態(status)の分離
→ テストが「同じ顧客の契約でも状態が違う」ことを表現したから
Money と Subscription を比較すると、面白い違いが見えてくる。
| Money | Subscription | |
|---|---|---|
| 生成後の変更 | 不可(不変) | 状態が遷移する |
| 同一性の判断 | 値が同じなら同一 | IDが同じなら同一 |
| テストの焦点 | 値の正しさ | 遷移の正しさ |
| 設計の特徴 | 自己検証 + 不変 | ライフサイクル + ガード |
テストの対象が変われば、テストが駆動する設計も変わる。「値」をテストすれば値を正しく扱う型が生まれ、「状態遷移」をテストすればライフサイクルを正しく制御するオブジェクトが生まれる。
テストは設計を映す鏡だ。
本章のまとめ
| 要点 | 内容 |
|---|---|
| 状態遷移TDDの定石 | 状態遷移図 → テストリスト → 正常遷移 → 不正遷移(ガード) |
| ガードの設計 | テストが不正ケースを積み重ねた結果、ホワイトリスト方式に辿り着く |
| 厳密TDD vs 実践TDD | パターンが確立したら、ガードを先に書くことも許容される |
| リファクタリング | ガードの重複を発見し、共通ヘルパーに抽出した |
次章では、Red-Green-Refactor の中で最も軽視されがちな「Refactor」フェーズを深掘りする。Green の先にある「設計の発見」とは何か。