目次を表示する

TDD実践ガイド 2026

リファクタリング ── Greenの先にある「設計の発見」

本章の方針

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 で書くには、どうすればいいのか。