本章の方針
Red-Green-Refactor の3フェーズのうち、最も軽視されているのが Refactor だ。
よくある風景:
🔴 Red → テストを書く(やる)
🟢 Green → コードを書く(やる)
🔵 Refactor → 「動いたからOK、次のテストへ」(スキップ)
これは TDD の最大の損失だ。Refactor フェーズこそ、TDD が「テスト手法」ではなく「設計手法」である理由そのものだ。本章では、Refactor フェーズで何を見て、何を変え、どう設計を発見するかを深掘りする。
なぜ Refactor をスキップしてしまうのか
3つの原因がある。
原因1:Green の達成感で満足する
テストが通った瞬間の「グリーン」は気持ちいい。その達成感で「次に行こう」と思ってしまう。しかし、Green の時点のコードは動くが汚い。最小限のコードでテストを通しただけだから、当然だ。
原因2:何をリファクタリングすべきか分からない
「重複を排除する」「命名を改善する」と言われても、具体的にどこをどう変えるか分からない。リファクタリングの観点が身についていないと、Green の後にコードを眺めても「これでいいのでは?」と思ってしまう。
原因3:リファクタリングが怖い
「動いているコードを変えて壊したくない」──この恐怖がリファクタリングを妨げる。しかし皮肉なことに、TDD で書いたコードはテストに守られている。安心してリファクタリングできるはずなのに、恐怖が勝ってしまう。
リファクタリングの観点チェックリスト
Green の後に必ず確認すべき観点を体系化する。
🔵 Refactor チェックリスト
[重複]
□ 同じコードが2箇所以上にないか?
□ 似たパターンのコードが3箇所以上にないか?
[命名]
□ 変数名・メソッド名は意図を正確に表しているか?
□ テストを読んだ人が、プロダクションコードの命名と混乱しないか?
[構造]
□ メソッドの行数は10行以内か?(超えたら分割を検討)
□ 1つのメソッドが1つの仕事をしているか?
□ ネストが3段以上ないか?
[知識の配置]
□ そのロジックは、そのクラスに置くのが適切か?
□ 別のクラスの内部状態を覗いていないか?
実践:Money のリファクタリング
Ch.3 で作った Money のコードを見直そう。サイクルを回した後の全コードはこうなっている。
// money.ts(Ch.3 完成時点)
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以上である必要があります')
}
if (!Number.isFinite(amount)) {
throw new Error('金額は有限な数値である必要があります')
}
return new Money(amount, validCurrency)
}
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)
}
}
チェックリストを適用する。
[重複]
□ 通貨チェック:add で this.currency !== other.currency をチェックしている。
subtract を追加したら同じチェックが重複する。
→ 今は add しかない。subtract ができてから抽出する(YAGNI)。
[命名]
□ of → 問題なし(ファクトリメソッドの慣習的な命名)
□ amount, currency → 問題なし
[構造]
□ of メソッドのバリデーションが 2 つ → 今は許容範囲
[知識の配置]
□ Currency の検証は validateCurrency に移した → 適切
今の時点では大きなリファクタリングは不要だ。だが、将来の変化に備えた気づきをメモしておく。
将来のリファクタリング候補(テストリストに追加):
△ 通貨チェックの共通化(subtract 追加時)
△ バリデーションの整理(バリデーション項目が5個を超えたら)
実践:Subscription のリファクタリング
Ch.5 で一度リファクタリングを行ったが、さらに改善の余地がある。
パターンの発見:コマンドメソッドの統一構造
各状態遷移メソッドを並べてみる。
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'
}
全てのメソッドが同じ構造だ:ガード → 状態変更。この構造を認識しておくことが重要だ。今は3メソッドだから抽象化は不要だが、もし状態遷移が10個に増えたら、遷移テーブルとして外出しする選択肢が見えてくる。
// 将来的な選択肢(今はやらない):遷移テーブル
const TRANSITIONS: Record<string, SubscriptionStatus[]> = {
activate: ['trial', 'past_due'],
cancel: ['trial', 'active', 'past_due'],
markPastDue: ['active'],
}
やらない判断も、リファクタリングの一部だ。 「構造が見えたが、今は時期尚早」という判断を記録しておくことに価値がある。
不正な遷移のエラーメッセージ改善
テストを見直すと、エラーメッセージの検証が甘い。
// 現状:エラーが投げられることだけ確認
expect(() => sub.activate()).toThrow()
// 改善:エラーメッセージの意図も確認
expect(() => sub.activate()).toThrow('canceled 状態では この操作は実行できません')
エラーメッセージはユーザー(開発者)向けのインターフェースだ。テストで「正しいエラーメッセージが出ること」を検証しておくと、将来のリファクタリングでエラーメッセージが壊れたときに気づける。
リファクタリングのタイミング
「いつリファクタリングすべきか」にはルールがある。
ルール:Red 中はリファクタリングしない
❌ Red 中にリファクタリング
テストが失敗している状態でコード構造を変えると、
「テストの失敗がリファクタリングのせいか、機能不足のせいか」
が区別できなくなる。
✅ Green になってからリファクタリング
テストが全て通っている状態でのみ構造を変える。
変更後にテストが失敗したら、リファクタリングのミス。
二つの帽子
Kent Beck はこれを**「二つの帽子(Two Hats)」** と呼ぶ。
帽子1:機能追加モード(Red → Green)
・新しい振る舞いを追加する
・テストを追加する
・構造は変えない
帽子2:リファクタリングモード(Refactor)
・構造を改善する
・テストは追加しない(振る舞いは変わらないから)
・振る舞いは変えない
同時にかぶってはいけない。
「機能を追加しながらリファクタリングもする」は、やりがちだが危険だ。2つの変更を同時に行うと、テスト失敗時の原因特定が困難になる。
リファクタリングの実践テクニック
具体的なリファクタリングパターンを3つ紹介する。
テクニック1:重複の抽出
// ❌ Before:同じガードが2箇所に
changePlan(newPlanId: string): void {
if (this._status !== 'active') {
throw new Error('この操作にはアクティブな契約が必要です')
}
// ...
}
applyDiscount(discount: Money): void {
if (this._status !== 'active') {
throw new Error('この操作にはアクティブな契約が必要です')
}
// ...
}
// ✅ After:ガードを共通化
private assertActive(): void {
this.assertStatusIn('active')
}
changePlan(newPlanId: string): void {
this.assertActive()
// ...
}
applyDiscount(discount: Money): void {
this.assertActive()
// ...
}
テクニック2:条件分岐の簡素化
// ❌ Before:ネストした条件
static of(amount: number, currency: string): Money {
if (amount < 0) {
throw new Error('金額は0以上である必要があります')
} else {
if (!Number.isFinite(amount)) {
throw new Error('金額は有限な数値である必要があります')
} else {
const validCurrency = validateCurrency(currency)
return new Money(amount, validCurrency)
}
}
}
// ✅ After:早期リターン(ガード節)
static of(amount: number, currency: string): Money {
if (amount < 0) {
throw new Error('金額は0以上である必要があります')
}
if (!Number.isFinite(amount)) {
throw new Error('金額は有限な数値である必要があります')
}
const validCurrency = validateCurrency(currency)
return new Money(amount, validCurrency)
}
テクニック3:テストのリファクタリング
プロダクションコードだけでなく、テストコードもリファクタリングの対象だ。
// ❌ Before:テストの Arrange が毎回重複
test('アクティブな契約をキャンセルできる', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.cancel()
expect(sub.status).toBe('canceled')
})
test('アクティブな契約のプランを変更できる', () => {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
sub.changePlan('plan-premium')
expect(sub.planId).toBe('plan-premium')
})
// ✅ After:共通のセットアップを抽出
describe('アクティブな契約', () => {
function createActiveSub(): Subscription {
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
return sub
}
test('キャンセルできる', () => {
const sub = createActiveSub()
sub.cancel()
expect(sub.status).toBe('canceled')
})
test('プランを変更できる', () => {
const sub = createActiveSub()
sub.changePlan('plan-premium')
expect(sub.planId).toBe('plan-premium')
})
})
ただし注意:テストのヘルパー関数はやりすぎると逆効果になる。テストを読んだ人が createActiveSub() の中身を追わないと何が起きているか分からなくなると、テストの可読性が落ちる。ヘルパーに抽出するのは「広く共有される定型パターン」だけにとどめる。
Refactor フェーズの「設計の発見」
Refactor フェーズの真の価値は、コードの中に隠れた設計を発見することにある。
Ch.3 で Money を作ったとき、テストに駆動されて「不変で、自己検証し、値で比較する型」が生まれた。Ch.5 で Subscription を作ったとき、「ライフサイクルを持ち、状態遷移をガードで守るオブジェクト」が生まれた。
これらの特徴は、筆者が事前に設計したものではない。テストを書き、Green にし、Refactor で構造を整えた結果、自然に立ち現れたパターンだ。
テストが駆動した設計の特徴:
Money:
✓ 不変(immutable)
✓ 値による等価性(value equality)
✓ 生成時の自己検証(self-validation)
→ テストが要求した結果、信頼できる「値の型」が生まれた
Subscription:
✓ 一意な識別子(identity)
✓ 状態遷移のライフサイクル
✓ 不変条件のガード(invariant protection)
→ テストが要求した結果、ライフサイクルを持つ「個体」が生まれた
Refactor フェーズは、この「テストが無意識に駆動した設計」を意識的に認識し、コードの構造として明確にするフェーズだ。重複を排除し、命名を改善し、責務を整理することで、隠れていたパターンが浮かび上がる。
これが、TDD が「テスト手法」ではなく「設計手法」である理由だ。
本章のまとめ
| 要点 | 内容 |
|---|---|
| Refactor をスキップする3つの原因 | Green の達成感、観点の欠如、変更への恐怖 |
| チェックリスト | 重複・命名・構造・知識の配置を確認する |
| 二つの帽子 | 機能追加とリファクタリングは同時にやらない |
| テストもリファクタリング対象 | ただし過度なヘルパー抽出は可読性を損なう |
| 設計の発見 | テストが無意識に駆動した設計を、Refactor で意識的に認識する |
次章では、TDD の次の壁に挑む。Money も Subscription も外部依存がないオブジェクトだった。データベースや外部APIに依存するコードを TDD で書くには、どうすればいいのか。