はじめに

多くの開発現場では、テストが存在しない既存コード、いわゆるレガシーコードと日々格闘しています。機能追加やバグ修正のたびに「この変更は他の箇所に影響しないだろうか」という不安を抱えながら作業を進め、デプロイ後に予期せぬ障害が発生することも珍しくありません。

Michael Feathers氏は著書『Working Effectively with Legacy Code(レガシーコード改善ガイド)』の中で、レガシーコードを「テストのないコード」と定義しました。この定義が示すように、問題の本質はコードの古さではなく、変更に対する安全網(テスト)が存在しないことにあります。

本記事では、テストのないレガシーコードに対して安全にテストを追加し、TDD(テスト駆動開発)のサイクルに乗せていくための実践的な手法を解説します。特性化テスト(Characterization Test)、シーム(Seam)の発見、Sprout/Wrapパターンなど、現場で即座に活用できるテクニックを網羅的に学べます。

レガシーコードとは何か

Michael Feathers による定義

Michael Feathers氏は、レガシーコードを以下のように定義しています。

レガシーコードとは、単にテストのないコードである。

この定義は一見シンプルですが、非常に本質を突いています。テストがないコードは、変更による影響を検証する手段がないため、開発者は常に不安を抱えながら作業することになります。

レガシーコードの特徴

レガシーコードには、以下のような共通した特徴があります。

特徴 影響
テストがない 変更の影響を検証できない
依存関係が複雑 クラスやメソッドを分離してテストできない
責務が集中している 1つのクラス・メソッドが多くの処理を担当
グローバル状態への依存 実行結果が外部状態に左右される
ドキュメントがない コードの意図を把握しにくい

なぜレガシーコードへのテスト追加は難しいのか

レガシーコードにテストを追加しようとすると、多くの場合「鶏と卵」の問題に直面します。

graph LR
    A[テストを書きたい] --> B[依存関係が多すぎる]
    B --> C[リファクタリングが必要]
    C --> D[テストがないと<br/>リファクタリングは危険]
    D --> A

テストを書くためにはリファクタリングが必要だが、安全にリファクタリングするにはテストが必要という、循環的な問題が発生します。この問題を解決するための戦略が、本記事で解説する各種テクニックです。

特性化テスト(Characterization Test)

特性化テストとは

特性化テスト(Characterization Test) は、既存コードの「現在の振る舞い」を記録するテストです。正しい振る舞いを検証するのではなく、現時点でのコードがどのように動作するかを捉えることに焦点を当てます。

通常のテストと特性化テストの違いを見てみましょう。

観点 通常のテスト 特性化テスト
目的 期待する振る舞いを検証 現在の振る舞いを記録
作成タイミング 実装前または実装後 既存コードに対して後から
失敗の意味 バグの存在 振る舞いの変化を検知
基準 仕様書・要件定義 既存コードの実行結果

特性化テストの作成手順

特性化テストは以下の手順で作成します。

  1. テスト対象のコードを呼び出すテストを書く
  2. 期待値には仮の値を入れる
  3. テストを実行し、実際の出力を確認する
  4. 実際の出力を期待値として設定する
  5. テストが通ることを確認する

実践例:計算ロジックの特性化テスト

以下のようなレガシーコードがあるとします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// PriceCalculator.java - テストのないレガシーコード
public class PriceCalculator {
    public double calculateTotal(List<Item> items, String customerType) {
        double total = 0;
        for (Item item : items) {
            total += item.getPrice() * item.getQuantity();
        }
        
        // 会員種別による割引
        if (customerType.equals("GOLD")) {
            total = total * 0.8;
        } else if (customerType.equals("SILVER")) {
            total = total * 0.9;
        }
        
        // 合計が10000円以上なら追加割引
        if (total >= 10000) {
            total = total - 500;
        }
        
        return total;
    }
}

このコードに対して特性化テストを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// PriceCalculatorCharacterizationTest.java
class PriceCalculatorCharacterizationTest {
    
    private PriceCalculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new PriceCalculator();
    }
    
    @Test
    void ゴールド会員で合計10000円以上の場合() {
        List<Item> items = List.of(
            new Item("商品A", 5000, 2),
            new Item("商品B", 3000, 1)
        );
        
        // まず仮の値で実行し、実際の出力を確認
        // 期待値: (5000*2 + 3000*1) * 0.8 - 500 = 9900
        double result = calculator.calculateTotal(items, "GOLD");
        
        // 実際の出力を期待値として記録
        assertEquals(9900.0, result, 0.01);
    }
    
    @Test
    void シルバー会員で合計10000円未満の場合() {
        List<Item> items = List.of(
            new Item("商品A", 3000, 1),
            new Item("商品B", 2000, 2)
        );
        
        // 期待値: (3000*1 + 2000*2) * 0.9 = 6300
        double result = calculator.calculateTotal(items, "SILVER");
        
        assertEquals(6300.0, result, 0.01);
    }
    
    @Test
    void 一般会員で合計10000円以上の場合() {
        List<Item> items = List.of(
            new Item("商品A", 8000, 1),
            new Item("商品B", 5000, 1)
        );
        
        // 期待値: (8000*1 + 5000*1) - 500 = 12500
        double result = calculator.calculateTotal(items, "NORMAL");
        
        assertEquals(12500.0, result, 0.01);
    }
}

特性化テストのポイント

特性化テストを作成する際の重要なポイントは以下のとおりです。

現在の振る舞いが「正しい」とは限らない

特性化テストは、既存の振る舞いを記録しているに過ぎません。その振る舞いがバグを含んでいる可能性もあります。特性化テストは「変更検知」の役割を果たすものであり、「正しさの保証」ではないことを理解しておく必要があります。

境界値とエッジケースを重点的にカバーする

限られた時間で効果的な特性化テストを作成するには、境界値やエッジケースを優先的にテストします。上記の例では、割引閾値である10000円付近の値を意識してテストケースを設計しています。

リファクタリング後に既存テストが通ることを確認

特性化テストの真価は、リファクタリング時に発揮されます。コード変更後にすべての特性化テストが通ることで、既存の振る舞いを壊していないことを確認できます。

シーム(Seam)を見つける

シームとは

シーム(Seam) とは、コードを編集せずに振る舞いを変更できるポイントのことです。Michael Feathers氏が『レガシーコード改善ガイド』で提唱した概念で、レガシーコードにテストを追加するための突破口となります。

シームを見つけることで、テスト時に依存関係を差し替え、コードを分離してテストできるようになります。

シームの種類

主なシームの種類を見てみましょう。

graph TB
    A[シームの種類] --> B[オブジェクトシーム]
    A --> C[リンクシーム]
    A --> D[プリプロセッサシーム]
    
    B --> B1[多態性による差し替え]
    B --> B2[インターフェースによる抽象化]
    C --> C1[クラスパス・リンク時の差し替え]
    D --> D1[マクロ・条件コンパイル]

Java/JavaScriptにおいて最も活用しやすいのはオブジェクトシームです。

オブジェクトシームの活用例

以下のような外部APIに依存したコードを考えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// OrderService.java - 外部APIに依存したレガシーコード
public class OrderService {
    
    public OrderResult processOrder(Order order) {
        // 在庫確認(外部APIを直接呼び出し)
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://inventory-api.example.com/check"))
            .POST(HttpRequest.BodyPublishers.ofString(order.toJson()))
            .build();
        
        HttpResponse<String> response = client.send(request, 
            HttpResponse.BodyHandlers.ofString());
        
        if (response.statusCode() != 200) {
            return OrderResult.failure("在庫確認に失敗しました");
        }
        
        // 決済処理(外部APIを直接呼び出し)
        // ... 省略
        
        return OrderResult.success(order.getId());
    }
}

このコードには、HTTPクライアントの生成が直接埋め込まれており、テストが困難です。オブジェクトシームを作成して、テスト可能な構造に変更します。

Step 1: インターフェースを抽出する

1
2
3
4
// InventoryClient.java - 在庫確認のインターフェース
public interface InventoryClient {
    InventoryResponse checkStock(Order order);
}

Step 2: 本番用の実装を作成する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// HttpInventoryClient.java - 本番用実装
public class HttpInventoryClient implements InventoryClient {
    
    private final HttpClient httpClient;
    
    public HttpInventoryClient() {
        this.httpClient = HttpClient.newHttpClient();
    }
    
    @Override
    public InventoryResponse checkStock(Order order) {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://inventory-api.example.com/check"))
            .POST(HttpRequest.BodyPublishers.ofString(order.toJson()))
            .build();
        
        try {
            HttpResponse<String> response = httpClient.send(request, 
                HttpResponse.BodyHandlers.ofString());
            return InventoryResponse.fromJson(response.body());
        } catch (Exception e) {
            return InventoryResponse.error(e.getMessage());
        }
    }
}

Step 3: OrderServiceをコンストラクタインジェクションに変更する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// OrderService.java - リファクタリング後
public class OrderService {
    
    private final InventoryClient inventoryClient;
    
    // テスト用コンストラクタ
    public OrderService(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }
    
    // 既存コードとの互換性を維持するコンストラクタ
    public OrderService() {
        this(new HttpInventoryClient());
    }
    
    public OrderResult processOrder(Order order) {
        InventoryResponse response = inventoryClient.checkStock(order);
        
        if (!response.isSuccess()) {
            return OrderResult.failure("在庫確認に失敗しました");
        }
        
        // 以降の処理...
        return OrderResult.success(order.getId());
    }
}

Step 4: テストでモックを注入する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// OrderServiceTest.java
class OrderServiceTest {
    
    @Test
    void 在庫確認成功時に注文が処理される() {
        // Arrange: モックを作成
        InventoryClient mockClient = mock(InventoryClient.class);
        when(mockClient.checkStock(any()))
            .thenReturn(InventoryResponse.success());
        
        OrderService service = new OrderService(mockClient);
        Order order = new Order("商品A", 1);
        
        // Act
        OrderResult result = service.processOrder(order);
        
        // Assert
        assertTrue(result.isSuccess());
        verify(mockClient).checkStock(order);
    }
    
    @Test
    void 在庫確認失敗時にエラーが返される() {
        // Arrange
        InventoryClient mockClient = mock(InventoryClient.class);
        when(mockClient.checkStock(any()))
            .thenReturn(InventoryResponse.error("在庫なし"));
        
        OrderService service = new OrderService(mockClient);
        Order order = new Order("商品B", 100);
        
        // Act
        OrderResult result = service.processOrder(order);
        
        // Assert
        assertFalse(result.isSuccess());
        assertEquals("在庫確認に失敗しました", result.getMessage());
    }
}

このようにオブジェクトシームを活用することで、外部依存を分離し、テスト可能な構造を実現できます。

Sprout/Wrap パターン

新機能を追加する際、レガシーコードに直接手を加えずに安全に実装するためのパターンとして、Sprout(芽生え)Wrap(包む) があります。

Sprout Method(芽生えメソッド)

Sprout Methodは、新しいロジックを既存メソッドに直接追加するのではなく、新しいメソッドとして切り出すパターンです。

適用前:既存メソッドに直接追加しようとしている

1
2
3
4
5
6
7
8
// orderProcessor.js - レガシーコード
function processOrder(order) {
  validateOrder(order)
  calculateTotal(order)
  saveToDatabase(order)
  sendConfirmationEmail(order)
  // ここに新しいロジックを追加したい...
}

適用後:新しいメソッドとして切り出す

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// orderProcessor.js - Sprout Method適用後
function processOrder(order) {
  validateOrder(order)
  calculateTotal(order)
  saveToDatabase(order)
  sendConfirmationEmail(order)
  
  // 新しいロジックは別メソッドとして追加
  notifyInventorySystem(order)
}

// 新しいメソッド(TDDで開発可能)
function notifyInventorySystem(order) {
  // この新しいメソッドはTDDで開発できる
  const notification = {
    orderId: order.id,
    items: order.items.map(item => ({
      productId: item.productId,
      quantity: item.quantity
    })),
    timestamp: new Date().toISOString()
  }
  
  return inventoryClient.notify(notification)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// notifyInventorySystem.test.js - 新しいメソッドのテスト
describe('notifyInventorySystem', () => {
  it('注文情報を在庫システムに通知する', () => {
    // Arrange
    const mockInventoryClient = {
      notify: jest.fn().mockResolvedValue({ success: true })
    }
    const order = {
      id: 'ORDER-001',
      items: [
        { productId: 'PROD-A', quantity: 2 }
      ]
    }
    
    // Act
    notifyInventorySystem(order, mockInventoryClient)
    
    // Assert
    expect(mockInventoryClient.notify).toHaveBeenCalledWith(
      expect.objectContaining({
        orderId: 'ORDER-001',
        items: [{ productId: 'PROD-A', quantity: 2 }]
      })
    )
  })
})

Sprout Class(芽生えクラス)

Sprout Classは、新しいロジックを完全に新しいクラスとして実装するパターンです。既存クラスへの影響を最小限に抑えられます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 既存のレガシークラス
public class ReportGenerator {
    public String generateReport(List<SalesData> data) {
        // 複雑な既存ロジック(数百行)
        // ...
        return report;
    }
}

// Sprout Class: 新しい要件を別クラスとして実装
public class SalesDataFormatter {
    
    public List<FormattedSalesData> format(List<SalesData> data) {
        return data.stream()
            .map(this::formatSingle)
            .collect(Collectors.toList());
    }
    
    private FormattedSalesData formatSingle(SalesData data) {
        return new FormattedSalesData(
            data.getDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
            formatCurrency(data.getAmount()),
            data.getCategory()
        );
    }
    
    private String formatCurrency(BigDecimal amount) {
        return NumberFormat.getCurrencyInstance(Locale.JAPAN)
            .format(amount);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SalesDataFormatterTest.java - TDDで開発
class SalesDataFormatterTest {
    
    private SalesDataFormatter formatter;
    
    @BeforeEach
    void setUp() {
        formatter = new SalesDataFormatter();
    }
    
    @Test
    void 売上データを指定フォーマットに変換する() {
        List<SalesData> data = List.of(
            new SalesData(LocalDate.of(2026, 1, 5), 
                         new BigDecimal("10000"), "家電")
        );
        
        List<FormattedSalesData> result = formatter.format(data);
        
        assertThat(result).hasSize(1);
        assertThat(result.get(0).getDate()).isEqualTo("2026-01-05");
        assertThat(result.get(0).getAmount()).isEqualTo("¥10,000");
    }
}

Wrap Method(包みメソッド)

Wrap Methodは、既存メソッドの呼び出し前後に処理を追加したい場合に使用します。既存メソッドをリネームし、新しいメソッドで包むパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 適用前
public class UserService {
    public void updateUser(User user) {
        // 既存の更新ロジック
        userRepository.save(user);
    }
}

// 適用後: Wrap Method
public class UserService {
    
    // 元のメソッドをリネーム
    private void updateUserCore(User user) {
        userRepository.save(user);
    }
    
    // 新しいメソッドで包む
    public void updateUser(User user) {
        // 前処理(新しいロジック)
        auditLogger.logBeforeUpdate(user);
        
        // 既存ロジックを呼び出し
        updateUserCore(user);
        
        // 後処理(新しいロジック)
        auditLogger.logAfterUpdate(user);
        notificationService.notifyUserUpdated(user);
    }
}

Wrap Class(包みクラス)

Wrap Class(デコレータパターン)は、既存クラスを変更せずに機能を追加する場合に使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 既存のインターフェース
public interface PaymentProcessor {
    PaymentResult process(Payment payment);
}

// 既存の実装(レガシー)
public class LegacyPaymentProcessor implements PaymentProcessor {
    @Override
    public PaymentResult process(Payment payment) {
        // 複雑な既存ロジック
        return new PaymentResult(/* ... */);
    }
}

// Wrap Class: 既存クラスをラップして機能追加
public class LoggingPaymentProcessor implements PaymentProcessor {
    
    private final PaymentProcessor delegate;
    private final PaymentLogger logger;
    
    public LoggingPaymentProcessor(PaymentProcessor delegate, 
                                   PaymentLogger logger) {
        this.delegate = delegate;
        this.logger = logger;
    }
    
    @Override
    public PaymentResult process(Payment payment) {
        logger.logPaymentStart(payment);
        
        try {
            PaymentResult result = delegate.process(payment);
            logger.logPaymentSuccess(payment, result);
            return result;
        } catch (Exception e) {
            logger.logPaymentFailure(payment, e);
            throw e;
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// LoggingPaymentProcessorTest.java
class LoggingPaymentProcessorTest {
    
    @Test
    void 決済処理の前後でログを記録する() {
        // Arrange
        PaymentProcessor mockDelegate = mock(PaymentProcessor.class);
        PaymentLogger mockLogger = mock(PaymentLogger.class);
        
        when(mockDelegate.process(any()))
            .thenReturn(PaymentResult.success());
        
        LoggingPaymentProcessor processor = 
            new LoggingPaymentProcessor(mockDelegate, mockLogger);
        
        Payment payment = new Payment("ORDER-001", 5000);
        
        // Act
        processor.process(payment);
        
        // Assert
        InOrder inOrder = inOrder(mockLogger, mockDelegate);
        inOrder.verify(mockLogger).logPaymentStart(payment);
        inOrder.verify(mockDelegate).process(payment);
        inOrder.verify(mockLogger).logPaymentSuccess(eq(payment), any());
    }
}

段階的なリファクタリング戦略

レガシーコード改善のワークフロー

レガシーコードを安全に改善するためのワークフローを以下に示します。

flowchart TD
    A[変更対象を特定] --> B[影響範囲を分析]
    B --> C[特性化テストを作成]
    C --> D[シームを見つける]
    D --> E[依存関係を分離]
    E --> F[単体テストを追加]
    F --> G[リファクタリング実施]
    G --> H[すべてのテストが通ることを確認]
    H --> I{さらに改善が必要?}
    I -->|Yes| G
    I -->|No| J[完了]

ボーイスカウト・ルール

すべてのレガシーコードを一度に改善しようとするのは現実的ではありません。代わりに、ボーイスカウト・ルール(キャンプ場を来たときよりも綺麗にして帰る)を適用します。

機能追加やバグ修正のために触れたコードに対して、少しずつテストを追加し、小さなリファクタリングを行っていくことで、時間をかけて徐々にコードベースを改善していきます。

優先順位の付け方

限られたリソースで最大の効果を得るため、以下の観点で優先順位を決めます。

優先度 対象コード 理由
頻繁に変更されるコード 変更のたびにリスクが発生する
バグが多発する箇所 品質向上の効果が大きい
複雑度が高いコード 理解しにくく、変更時のリスクが高い
ビジネス上重要な機能 障害時の影響が大きい
安定しているコード 変更頻度が低く、現状維持で問題ない

実践例:レガシーコードへのテスト追加

Before:テストのないレガシーコード

以下のような複雑なレガシーコードがあるとします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// DiscountCalculator.java - 複雑なレガシーコード
public class DiscountCalculator {
    
    public double calculate(Customer customer, List<Product> products, 
                           String couponCode, LocalDate orderDate) {
        double subtotal = 0;
        for (Product p : products) {
            subtotal += p.getPrice() * p.getQuantity();
        }
        
        // 会員ランク割引
        double memberDiscount = 0;
        if (customer.getRank().equals("PLATINUM")) {
            memberDiscount = subtotal * 0.15;
        } else if (customer.getRank().equals("GOLD")) {
            memberDiscount = subtotal * 0.10;
        } else if (customer.getRank().equals("SILVER")) {
            memberDiscount = subtotal * 0.05;
        }
        
        // クーポン割引
        double couponDiscount = 0;
        if (couponCode != null && !couponCode.isEmpty()) {
            // データベースからクーポン情報を取得
            Connection conn = DriverManager.getConnection(DATABASE_URL);
            PreparedStatement stmt = conn.prepareStatement(
                "SELECT discount_rate FROM coupons WHERE code = ? AND expiry_date >= ?");
            stmt.setString(1, couponCode);
            stmt.setDate(2, Date.valueOf(orderDate));
            ResultSet rs = stmt.executeQuery();
            if (rs.next()) {
                couponDiscount = subtotal * rs.getDouble("discount_rate");
            }
            conn.close();
        }
        
        // 季節割引
        double seasonDiscount = 0;
        int month = orderDate.getMonthValue();
        if (month == 1 || month == 7) {
            seasonDiscount = subtotal * 0.10;
        }
        
        // 複数割引の適用ルール: 最大2つまで、大きい順に適用
        List<Double> discounts = Arrays.asList(
            memberDiscount, couponDiscount, seasonDiscount);
        discounts.sort(Collections.reverseOrder());
        
        double totalDiscount = 0;
        for (int i = 0; i < Math.min(2, discounts.size()); i++) {
            totalDiscount += discounts.get(i);
        }
        
        return Math.max(0, subtotal - totalDiscount);
    }
}

このコードには以下の問題があります。

  • データベースへの直接依存があり、テストが困難
  • 複数の責務(会員割引、クーポン、季節割引、適用ルール)が混在
  • ビジネスロジックがメソッド内に散在

After:テスト可能な構造へリファクタリング

Step 1: クーポンリポジトリを抽出(シームの作成)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// CouponRepository.java
public interface CouponRepository {
    Optional<Coupon> findValidCoupon(String code, LocalDate date);
}

// JdbcCouponRepository.java
public class JdbcCouponRepository implements CouponRepository {
    private final DataSource dataSource;
    
    public JdbcCouponRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public Optional<Coupon> findValidCoupon(String code, LocalDate date) {
        // DB接続ロジック
        // ...
    }
}

Step 2: 割引計算ロジックを分離(Sprout Class)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// MemberDiscountCalculator.java
public class MemberDiscountCalculator {
    
    private static final Map<String, Double> DISCOUNT_RATES = Map.of(
        "PLATINUM", 0.15,
        "GOLD", 0.10,
        "SILVER", 0.05
    );
    
    public double calculate(String memberRank, double subtotal) {
        return subtotal * DISCOUNT_RATES.getOrDefault(memberRank, 0.0);
    }
}

// SeasonDiscountCalculator.java
public class SeasonDiscountCalculator {
    
    private static final Set<Integer> SALE_MONTHS = Set.of(1, 7);
    private static final double SEASON_DISCOUNT_RATE = 0.10;
    
    public double calculate(LocalDate orderDate, double subtotal) {
        if (SALE_MONTHS.contains(orderDate.getMonthValue())) {
            return subtotal * SEASON_DISCOUNT_RATE;
        }
        return 0;
    }
}

Step 3: リファクタリング後のメインクラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// DiscountCalculator.java - リファクタリング後
public class DiscountCalculator {
    
    private final CouponRepository couponRepository;
    private final MemberDiscountCalculator memberDiscountCalculator;
    private final SeasonDiscountCalculator seasonDiscountCalculator;
    
    public DiscountCalculator(CouponRepository couponRepository,
                             MemberDiscountCalculator memberDiscountCalculator,
                             SeasonDiscountCalculator seasonDiscountCalculator) {
        this.couponRepository = couponRepository;
        this.memberDiscountCalculator = memberDiscountCalculator;
        this.seasonDiscountCalculator = seasonDiscountCalculator;
    }
    
    public double calculate(Customer customer, List<Product> products,
                           String couponCode, LocalDate orderDate) {
        double subtotal = calculateSubtotal(products);
        
        List<Double> discounts = Arrays.asList(
            memberDiscountCalculator.calculate(customer.getRank(), subtotal),
            calculateCouponDiscount(couponCode, orderDate, subtotal),
            seasonDiscountCalculator.calculate(orderDate, subtotal)
        );
        
        double totalDiscount = applyDiscountRule(discounts);
        return Math.max(0, subtotal - totalDiscount);
    }
    
    private double calculateSubtotal(List<Product> products) {
        return products.stream()
            .mapToDouble(p -> p.getPrice() * p.getQuantity())
            .sum();
    }
    
    private double calculateCouponDiscount(String couponCode, 
                                          LocalDate orderDate, 
                                          double subtotal) {
        if (couponCode == null || couponCode.isEmpty()) {
            return 0;
        }
        
        return couponRepository.findValidCoupon(couponCode, orderDate)
            .map(coupon -> subtotal * coupon.getDiscountRate())
            .orElse(0.0);
    }
    
    private double applyDiscountRule(List<Double> discounts) {
        return discounts.stream()
            .sorted(Comparator.reverseOrder())
            .limit(2)
            .mapToDouble(Double::doubleValue)
            .sum();
    }
}

Step 4: 包括的なテストの作成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// DiscountCalculatorTest.java
class DiscountCalculatorTest {
    
    private CouponRepository couponRepository;
    private DiscountCalculator calculator;
    
    @BeforeEach
    void setUp() {
        couponRepository = mock(CouponRepository.class);
        calculator = new DiscountCalculator(
            couponRepository,
            new MemberDiscountCalculator(),
            new SeasonDiscountCalculator()
        );
    }
    
    @Test
    void プラチナ会員と季節割引で上位2つの割引が適用される() {
        // Arrange
        Customer customer = new Customer("PLATINUM");
        List<Product> products = List.of(new Product("商品A", 10000, 1));
        LocalDate orderDate = LocalDate.of(2026, 1, 15); // 1月(季節割引対象)
        
        when(couponRepository.findValidCoupon(any(), any()))
            .thenReturn(Optional.empty());
        
        // Act
        double result = calculator.calculate(
            customer, products, null, orderDate);
        
        // Assert
        // 小計: 10000
        // 会員割引: 10000 * 0.15 = 1500
        // 季節割引: 10000 * 0.10 = 1000
        // 上位2つ適用: 1500 + 1000 = 2500
        // 結果: 10000 - 2500 = 7500
        assertEquals(7500, result, 0.01);
    }
    
    @Test
    void クーポンが最も高い割引の場合はクーポンが優先される() {
        // Arrange
        Customer customer = new Customer("SILVER");
        List<Product> products = List.of(new Product("商品A", 10000, 1));
        LocalDate orderDate = LocalDate.of(2026, 3, 15); // 3月(季節割引対象外)
        
        Coupon coupon = new Coupon("SPECIAL20", 0.20);
        when(couponRepository.findValidCoupon("SPECIAL20", orderDate))
            .thenReturn(Optional.of(coupon));
        
        // Act
        double result = calculator.calculate(
            customer, products, "SPECIAL20", orderDate);
        
        // Assert
        // 小計: 10000
        // 会員割引: 10000 * 0.05 = 500
        // クーポン割引: 10000 * 0.20 = 2000
        // 上位2つ適用: 2000 + 500 = 2500
        // 結果: 10000 - 2500 = 7500
        assertEquals(7500, result, 0.01);
    }
}

// MemberDiscountCalculatorTest.java - 単体テスト
class MemberDiscountCalculatorTest {
    
    private MemberDiscountCalculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new MemberDiscountCalculator();
    }
    
    @ParameterizedTest
    @CsvSource({
        "PLATINUM, 10000, 1500",
        "GOLD, 10000, 1000",
        "SILVER, 10000, 500",
        "BRONZE, 10000, 0",
        "PLATINUM, 0, 0"
    })
    void 会員ランクに応じた割引額を計算する(String rank, double subtotal, 
                                        double expected) {
        double result = calculator.calculate(rank, subtotal);
        assertEquals(expected, result, 0.01);
    }
}

まとめ

レガシーコードにTDDを導入するためには、段階的なアプローチが不可欠です。本記事で解説した手法を整理すると以下のようになります。

手法 用途 ポイント
特性化テスト 既存の振る舞いを記録 リファクタリングの安全網を作る
シーム 依存関係を分離 テスト時に差し替え可能なポイントを作る
Sprout Method/Class 新機能の追加 既存コードを変更せずにTDDで開発
Wrap Method/Class 既存機能の拡張 既存コードをラップして機能追加

レガシーコードの改善は一朝一夕には完了しません。しかし、これらのテクニックを活用し、ボーイスカウト・ルールに従って少しずつ改善を積み重ねることで、テストに守られた保守性の高いコードベースへと変革していくことができます。

最初の一歩として、次に機能追加やバグ修正を行う際に、対象コードに対して特性化テストを1つ追加することから始めてみてください。

参考リンク