LESSON 30分

ストーリー

高橋アーキテクト
リファクタリングの成功には、テストが絶対に必要だ
高橋アーキテクト
テストなしのリファクタリングは、安全ネットなしの綱渡り。テストがあるからこそ、自信を持ってコードの構造を変えられる。今日は、テストを使ってリファクタリングを安全に進める方法を学ぼう

リファクタリングにおけるテストの役割

リファクタリングは「外部の振る舞いを変えずに内部構造を改善する」作業です。テストは「外部の振る舞いが変わっていないこと」を証明する手段です。

リファクタリング前のテスト結果: ALL PASS ✓
    ↓ リファクタリング実施
リファクタリング後のテスト結果: ALL PASS ✓
    → 振る舞いが保持されている!

テストがない場合の第一歩

レガシーコードにはテストがないことが多いです。その場合、まずテストを書いてからリファクタリングします。

特性テスト(Characterization Test)

既存の振る舞いを「そのまま」記録するテストです。正しさではなく、現状の振る舞いを固定します。

// リファクタリング対象のレガシーコード
function calculateDiscount(price: number, customerType: string, quantity: number): number {
  let discount = 0;
  if (customerType === 'vip') {
    discount = price * 0.2;
    if (quantity > 10) {
      discount += price * 0.05;
    }
  } else if (customerType === 'regular') {
    if (quantity > 20) {
      discount = price * 0.1;
    } else if (quantity > 10) {
      discount = price * 0.05;
    }
  }
  if (price > 10000) {
    discount += 500;
  }
  return discount;
}

// 特性テスト:現在の振る舞いを記録する
describe('calculateDiscount (characterization)', () => {
  // VIP顧客
  test('VIP, quantity <= 10, price <= 10000', () => {
    expect(calculateDiscount(5000, 'vip', 5)).toBe(1000);
  });

  test('VIP, quantity > 10, price <= 10000', () => {
    expect(calculateDiscount(5000, 'vip', 15)).toBe(1250);
  });

  test('VIP, quantity > 10, price > 10000', () => {
    expect(calculateDiscount(20000, 'vip', 15)).toBe(5500);
  });

  // 一般顧客
  test('regular, quantity <= 10', () => {
    expect(calculateDiscount(5000, 'regular', 5)).toBe(0);
  });

  test('regular, quantity 11-20', () => {
    expect(calculateDiscount(5000, 'regular', 15)).toBe(250);
  });

  test('regular, quantity > 20, price > 10000', () => {
    expect(calculateDiscount(20000, 'regular', 25)).toBe(2500);
  });

  // 不明な顧客タイプ
  test('unknown type, price > 10000', () => {
    expect(calculateDiscount(15000, 'new', 5)).toBe(500);
  });
});

Red-Green-Refactor サイクル

テスト駆動リファクタリングは以下のサイクルで進めます。

ステップ 1: テストを書く(あるいは確認する)

// 既存のテストが通ることを確認
// npm test → ALL PASS

ステップ 2: 小さなリファクタリングを実施

// Extract Method: 割引計算を分離
function calculateDiscount(price: number, customerType: string, quantity: number): number {
  const typeDiscount = calculateTypeDiscount(price, customerType, quantity);
  const bulkDiscount = calculateBulkDiscount(price);
  return typeDiscount + bulkDiscount;
}

function calculateTypeDiscount(price: number, customerType: string, quantity: number): number {
  if (customerType === 'vip') {
    return calculateVipDiscount(price, quantity);
  }
  if (customerType === 'regular') {
    return calculateRegularDiscount(price, quantity);
  }
  return 0;
}

function calculateVipDiscount(price: number, quantity: number): number {
  const baseDiscount = price * 0.2;
  const volumeBonus = quantity > 10 ? price * 0.05 : 0;
  return baseDiscount + volumeBonus;
}

function calculateRegularDiscount(price: number, quantity: number): number {
  if (quantity > 20) return price * 0.1;
  if (quantity > 10) return price * 0.05;
  return 0;
}

function calculateBulkDiscount(price: number): number {
  return price > 10000 ? 500 : 0;
}

ステップ 3: テストを実行

// npm test → ALL PASS ✓
// 振る舞いが保持されている!

ステップ 4: さらにリファクタリング(ポリモーフィズム導入)

interface DiscountStrategy {
  calculate(price: number, quantity: number): number;
}

class VipDiscount implements DiscountStrategy {
  calculate(price: number, quantity: number): number {
    const baseDiscount = price * 0.2;
    const volumeBonus = quantity > 10 ? price * 0.05 : 0;
    return baseDiscount + volumeBonus;
  }
}

class RegularDiscount implements DiscountStrategy {
  calculate(price: number, quantity: number): number {
    if (quantity > 20) return price * 0.1;
    if (quantity > 10) return price * 0.05;
    return 0;
  }
}

class NoDiscount implements DiscountStrategy {
  calculate(_price: number, _quantity: number): number {
    return 0;
  }
}

class DiscountCalculator {
  private strategies: Map<string, DiscountStrategy> = new Map([
    ['vip', new VipDiscount()],
    ['regular', new RegularDiscount()],
  ]);

  calculate(price: number, customerType: string, quantity: number): number {
    const strategy = this.strategies.get(customerType) ?? new NoDiscount();
    const typeDiscount = strategy.calculate(price, quantity);
    const bulkDiscount = price > 10000 ? 500 : 0;
    return typeDiscount + bulkDiscount;
  }
}

ステップ 5: テストを実行して確認

// テストを新しいクラスに適合させる
describe('DiscountCalculator', () => {
  const calculator = new DiscountCalculator();

  test('VIP, quantity <= 10, price <= 10000', () => {
    expect(calculator.calculate(5000, 'vip', 5)).toBe(1000);
  });

  // ... 同じテストケースがすべて通る
});

テストで守るべきルール

ルール説明
1変更1テストリファクタリングの各ステップ後にテスト実行
テスト失敗時は巻き戻し失敗したら直前の変更を元に戻す
テスト追加はリファクタリング前にテストが足りないと気づいたら先に追加
テスト自体のリファクタリングは別でテストとコードのリファクタリングを同時にしない

Seam(継ぎ目)を見つける

レガシーコードにテストを書くとき、「Seam(継ぎ目)」を見つけることが重要です。Seam とは、コードの振る舞いを変えずに差し替え可能なポイントです。

// Seam がない(テスト困難)
class ReportService {
  generateReport(): string {
    const db = new PostgresDatabase();
    const data = db.query('SELECT * FROM reports');
    const smtp = new SmtpClient('smtp.example.com');
    smtp.send('admin@example.com', data.toString());
    return data.toString();
  }
}

// Seam を作る(テスト可能に)
class ReportService {
  constructor(
    private database: Database,      // Seam: 注入で差し替え可能
    private mailer: Mailer           // Seam: 注入で差し替え可能
  ) {}

  generateReport(): string {
    const data = this.database.query('SELECT * FROM reports');
    this.mailer.send('admin@example.com', data.toString());
    return data.toString();
  }
}

// テスト時
const mockDb = { query: jest.fn().mockReturnValue([{ id: 1 }]) };
const mockMailer = { send: jest.fn() };
const service = new ReportService(mockDb as any, mockMailer as any);

高橋アーキテクトのアドバイス:

「テストがあるからこそ”大胆に”リファクタリングできる。テストがないからといってリファクタリングを諦めるのではなく、まずテストを書くことから始めよう。それ自体がコードの理解を深めるプロセスだ」


まとめ

ポイント内容
テストの役割外部の振る舞いが変わっていないことを保証
特性テスト既存の振る舞いをそのまま記録するテスト
Red-Green-Refactorテスト確認→小さな変更→テスト確認の繰り返し
Seamテスト可能にするための差し替えポイント

チェックリスト

  • 特性テストを書いてレガシーコードの振る舞いを固定できる
  • Red-Green-Refactor サイクルを実践できる
  • Seam を見つけてテスト可能にできる

次のステップへ

次は演習です。実際にレガシーコードをテストを書きながらリファクタリングしてみましょう。


推定読了時間: 30分