はじめに

FizzBuzz問題でRed-Green-Refactorサイクルを体験した後、多くの開発者は「実際のプロジェクトでTDDをどう適用すればよいのか」という壁にぶつかります。実務では、外部API連携、データベース操作、複雑なビジネスロジックなど、シンプルな練習問題では遭遇しない課題が山積しています。

本記事では、ECサイトのカート機能決済API連携在庫管理システムという3つの実践的なケーススタディを通じて、現場でTDDを適用するための具体的なアプローチを解説します。外部依存をどう切り離すか、ビジネスルールをどうテストするか、そして設計をどう改善していくかを、実際のコード例とともに学んでいきましょう。

実プロジェクトでTDDが難しい理由

練習問題と実プロジェクトでは、TDDの適用難易度が大きく異なります。その理由を整理してみましょう。

観点 練習問題 実プロジェクト
入出力 純粋な計算のみ DB、API、ファイルシステム
ビジネスロジック シンプルな条件分岐 複雑なルールの組み合わせ
状態管理 ステートレス セッション、トランザクション
テスト実行速度 瞬時 外部依存があると遅延
テスト安定性 常に同じ結果 外部要因で結果が変動

これらの課題に対処するため、実プロジェクトでのTDDでは以下の3つの原則を守ることが重要です。

  1. ビジネスロジックを外部依存から分離する
  2. テストダブル(モック・スタブ)を適切に使う
  3. テストピラミッドを意識した設計を行う

ケーススタディ1: ECサイトのカート機能

要件定義

ECサイトのショッピングカート機能を実装します。以下の要件を満たす必要があります。

  • 商品をカートに追加できる
  • 同じ商品を追加すると数量が増える
  • カート内の合計金額を計算できる
  • 数量が100個を超える場合は10%割引を適用する
  • 会員ランク(GOLD/SILVER/BRONZE)に応じた割引を適用する

テストリストの作成

TDDでは、まずテストリストを作成します。要件から導出されるテストケースを洗い出しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ショッピングカート テストリスト:
- [ ] 空のカートの合計金額は0円
- [ ] 商品を1つ追加するとカートに反映される
- [ ] 同じ商品を追加すると数量が増える
- [ ] 異なる商品を追加すると別々に保持される
- [ ] 合計金額は単価×数量の総和
- [ ] 数量が100個以上で10%の大量購入割引
- [ ] GOLD会員は20%割引
- [ ] SILVER会員は10%割引
- [ ] BRONZE会員は割引なし
- [ ] 大量購入割引と会員割引は併用可能

サイクル1: 空のカートの合計金額

最もシンプルなケースから始めます。

Red: 失敗するテストを書く

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// CartTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

class CartTest {

    @Test
    @DisplayName("空のカートの合計金額は0円")
    void emptyCartTotalIsZero() {
        Cart cart = new Cart();
        assertEquals(0, cart.calculateTotal());
    }
}

Green: テストを通す最小限のコード

1
2
3
4
5
6
// Cart.java
public class Cart {
    public int calculateTotal() {
        return 0;
    }
}

この時点ではリファクタリングの必要はありません。次のサイクルに進みます。

サイクル2: 商品の追加と合計金額計算

Red: 次のテストを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
@DisplayName("商品を1つ追加すると合計金額に反映される")
void addOneItemCalculatesTotal() {
    Cart cart = new Cart();
    Product product = new Product("商品A", 1000);
    
    cart.addItem(product, 2);
    
    assertEquals(2000, cart.calculateTotal());
}

Green: 実装を追加

 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
// Product.java
public class Product {
    private final String name;
    private final int price;
    
    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }
    
    public String getName() { return name; }
    public int getPrice() { return price; }
}

// CartItem.java
public class CartItem {
    private final Product product;
    private int quantity;
    
    public CartItem(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }
    
    public Product getProduct() { return product; }
    public int getQuantity() { return quantity; }
    
    public int getSubtotal() {
        return product.getPrice() * quantity;
    }
    
    public void addQuantity(int amount) {
        this.quantity += amount;
    }
}

// Cart.java
import java.util.ArrayList;
import java.util.List;

public class Cart {
    private final List<CartItem> items = new ArrayList<>();
    
    public void addItem(Product product, int quantity) {
        items.add(new CartItem(product, quantity));
    }
    
    public int calculateTotal() {
        return items.stream()
                .mapToInt(CartItem::getSubtotal)
                .sum();
    }
}

サイクル3: 同一商品の数量加算

Red: テストを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
@DisplayName("同じ商品を追加すると数量が増える")
void addSameItemIncreasesQuantity() {
    Cart cart = new Cart();
    Product product = new Product("商品A", 1000);
    
    cart.addItem(product, 2);
    cart.addItem(product, 3);
    
    assertEquals(5000, cart.calculateTotal());
    assertEquals(1, cart.getItemCount()); // アイテム数は1
}

Green: 同一商品の判定ロジックを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Cart.java
public class Cart {
    private final List<CartItem> items = new ArrayList<>();
    
    public void addItem(Product product, int quantity) {
        for (CartItem item : items) {
            if (item.getProduct().getName().equals(product.getName())) {
                item.addQuantity(quantity);
                return;
            }
        }
        items.add(new CartItem(product, quantity));
    }
    
    public int calculateTotal() {
        return items.stream()
                .mapToInt(CartItem::getSubtotal)
                .sum();
    }
    
    public int getItemCount() {
        return items.size();
    }
}

Refactor: 商品の同一性判定を改善

商品の同一性判定をProduct側に移動し、責務を明確にします。

 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
// Product.java
public class Product {
    private final String name;
    private final int price;
    
    // コンストラクタ、ゲッターは省略
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return Objects.equals(name, product.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

// Cart.java - リファクタリング後
public void addItem(Product product, int quantity) {
    for (CartItem item : items) {
        if (item.getProduct().equals(product)) {
            item.addQuantity(quantity);
            return;
        }
    }
    items.add(new CartItem(product, quantity));
}

サイクル4: 割引ルールの実装

ここからがTDDの真価を発揮する場面です。複雑なビジネスルール(割引計算)をテストで仕様化していきます。

Red: 大量購入割引のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
@DisplayName("数量が100個以上で10%の大量購入割引が適用される")
void bulkDiscountAppliedForLargeQuantity() {
    Cart cart = new Cart();
    Product product = new Product("商品A", 100);
    
    cart.addItem(product, 100); // 100個 × 100円 = 10,000円
    
    // 10%割引で9,000円
    assertEquals(9000, cart.calculateTotal());
}

Green: 割引ロジックを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Cart.java
public int calculateTotal() {
    int subtotal = items.stream()
            .mapToInt(CartItem::getSubtotal)
            .sum();
    
    int totalQuantity = items.stream()
            .mapToInt(CartItem::getQuantity)
            .sum();
    
    if (totalQuantity >= 100) {
        subtotal = (int) (subtotal * 0.9);
    }
    
    return subtotal;
}

Refactor: 割引計算の責務を分離

割引ルールが増えることを見越して、割引計算を別クラスに分離します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// DiscountPolicy.java
public interface DiscountPolicy {
    int apply(int subtotal, int totalQuantity);
}

// BulkDiscountPolicy.java
public class BulkDiscountPolicy implements DiscountPolicy {
    private static final int BULK_THRESHOLD = 100;
    private static final double BULK_DISCOUNT_RATE = 0.9;
    
    @Override
    public int apply(int subtotal, int totalQuantity) {
        if (totalQuantity >= BULK_THRESHOLD) {
            return (int) (subtotal * BULK_DISCOUNT_RATE);
        }
        return subtotal;
    }
}

会員ランク割引の追加

会員ランクによる割引を追加します。ここでTDDが設計を導く様子を見てみましょう。

Red: 会員割引のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
@DisplayName("GOLD会員は20%割引が適用される")
void goldMemberGets20PercentDiscount() {
    Cart cart = new Cart();
    cart.setMemberRank(MemberRank.GOLD);
    Product product = new Product("商品A", 1000);
    
    cart.addItem(product, 10); // 10,000円
    
    // 20%割引で8,000円
    assertEquals(8000, cart.calculateTotal());
}

テストを書くことで、「カートに会員ランク情報を持たせる」という設計判断が自然に導かれました。

Green + Refactor: 複合割引の実装

 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
// MemberRank.java
public enum MemberRank {
    GOLD(0.8),
    SILVER(0.9),
    BRONZE(1.0);
    
    private final double discountRate;
    
    MemberRank(double discountRate) {
        this.discountRate = discountRate;
    }
    
    public double getDiscountRate() {
        return discountRate;
    }
}

// MemberDiscountPolicy.java
public class MemberDiscountPolicy implements DiscountPolicy {
    private final MemberRank memberRank;
    
    public MemberDiscountPolicy(MemberRank memberRank) {
        this.memberRank = memberRank;
    }
    
    @Override
    public int apply(int subtotal, int totalQuantity) {
        return (int) (subtotal * memberRank.getDiscountRate());
    }
}

// CompositeDiscountPolicy.java
public class CompositeDiscountPolicy implements DiscountPolicy {
    private final List<DiscountPolicy> policies;
    
    public CompositeDiscountPolicy(List<DiscountPolicy> policies) {
        this.policies = policies;
    }
    
    @Override
    public int apply(int subtotal, int totalQuantity) {
        int result = subtotal;
        for (DiscountPolicy policy : policies) {
            result = policy.apply(result, totalQuantity);
        }
        return result;
    }
}

最終的なカートクラス

TDDを通じて、責務が明確に分離された設計が導かれました。

 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
// Cart.java - 最終形
public class Cart {
    private final List<CartItem> items = new ArrayList<>();
    private MemberRank memberRank = MemberRank.BRONZE;
    
    public void setMemberRank(MemberRank memberRank) {
        this.memberRank = memberRank;
    }
    
    public void addItem(Product product, int quantity) {
        for (CartItem item : items) {
            if (item.getProduct().equals(product)) {
                item.addQuantity(quantity);
                return;
            }
        }
        items.add(new CartItem(product, quantity));
    }
    
    public int calculateTotal() {
        int subtotal = items.stream()
                .mapToInt(CartItem::getSubtotal)
                .sum();
        
        int totalQuantity = items.stream()
                .mapToInt(CartItem::getQuantity)
                .sum();
        
        DiscountPolicy policy = new CompositeDiscountPolicy(List.of(
            new BulkDiscountPolicy(),
            new MemberDiscountPolicy(memberRank)
        ));
        
        return policy.apply(subtotal, totalQuantity);
    }
    
    public int getItemCount() {
        return items.size();
    }
}

ケーススタディ1のポイント

flowchart LR
    A[シンプルなテスト] --> B[基本実装]
    B --> C[複雑なルール追加]
    C --> D[設計の改善]
    D --> E[責務の分離]
    
    style A fill:#e6f3ff,stroke:#0066cc,color:#000000
    style B fill:#e6f3ff,stroke:#0066cc,color:#000000
    style C fill:#fff2e6,stroke:#cc6600,color:#000000
    style D fill:#e6ffe6,stroke:#00cc00,color:#000000
    style E fill:#e6ffe6,stroke:#00cc00,color:#000000
  • シンプルなケースから始める: 空のカート → 商品追加 → 割引の順に進めた
  • テストが設計を導く: 会員ランク割引のテストを書くことで、自然にカートに会員情報を持たせる設計が導かれた
  • リファクタリングで責務を分離: 割引ロジックを別クラスに抽出することで、新しい割引ルールの追加が容易になった

ケーススタディ2: 決済API連携

要件定義

外部の決済サービスAPIと連携する機能を実装します。

  • クレジットカード決済処理を行う
  • 決済成功時は注文を確定する
  • 決済失敗時は適切なエラーメッセージを返す
  • ネットワークエラー時はリトライを3回試みる

外部依存の課題

外部APIとの連携では、以下の課題があります。

  1. テスト実行のたびにAPIを呼び出すのは非現実的: コスト、速度、安定性の問題
  2. エラーケースの再現が困難: ネットワークエラーやタイムアウトを意図的に発生させにくい
  3. 決済の副作用: テストで実際に課金が発生してしまう

これらの課題を解決するために、ポート・アダプタパターン(六角形アーキテクチャ) を適用します。

アーキテクチャ設計

flowchart TB
    subgraph core["ドメイン層(テスト対象)"]
        PS[PaymentService]
        PP[PaymentPort]
    end
    
    subgraph adapters["アダプタ層"]
        RPA[RealPaymentAdapter<br/>本番用]
        MPA[MockPaymentAdapter<br/>テスト用]
    end
    
    PS --> PP
    PP -.-> RPA
    PP -.-> MPA
    
    RPA --> API[外部決済API]
    
    style core fill:#e6f3ff,stroke:#0066cc,color:#000000
    style adapters fill:#f5f5f5,stroke:#999999,color:#000000
    style API fill:#ffcccc,stroke:#cc0000,color:#000000

ポート(インターフェース)の定義

まず、外部依存を抽象化するポートを定義します。

 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
// PaymentPort.java
public interface PaymentPort {
    PaymentResult charge(CreditCard card, int amount);
}

// PaymentResult.java
public class PaymentResult {
    private final boolean success;
    private final String transactionId;
    private final String errorMessage;
    
    private PaymentResult(boolean success, String transactionId, String errorMessage) {
        this.success = success;
        this.transactionId = transactionId;
        this.errorMessage = errorMessage;
    }
    
    public static PaymentResult success(String transactionId) {
        return new PaymentResult(true, transactionId, null);
    }
    
    public static PaymentResult failure(String errorMessage) {
        return new PaymentResult(false, null, errorMessage);
    }
    
    public boolean isSuccess() { return success; }
    public String getTransactionId() { return transactionId; }
    public String getErrorMessage() { return errorMessage; }
}

TDDで決済サービスを実装

Red: 決済成功時のテスト

 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
// PaymentServiceTest.java
class PaymentServiceTest {
    
    @Test
    @DisplayName("決済成功時は注文を確定する")
    void confirmOrderWhenPaymentSucceeds() {
        // Arrange: モックを作成
        PaymentPort mockPaymentPort = new PaymentPort() {
            @Override
            public PaymentResult charge(CreditCard card, int amount) {
                return PaymentResult.success("TXN-12345");
            }
        };
        
        PaymentService service = new PaymentService(mockPaymentPort);
        Order order = new Order("ORDER-001", 10000);
        CreditCard card = new CreditCard("4111111111111111", "12/25", "123");
        
        // Act
        OrderResult result = service.processPayment(order, card);
        
        // Assert
        assertTrue(result.isConfirmed());
        assertEquals("TXN-12345", result.getTransactionId());
    }
}

Green: 基本実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// PaymentService.java
public class PaymentService {
    private final PaymentPort paymentPort;
    
    public PaymentService(PaymentPort paymentPort) {
        this.paymentPort = paymentPort;
    }
    
    public OrderResult processPayment(Order order, CreditCard card) {
        PaymentResult result = paymentPort.charge(card, order.getAmount());
        
        if (result.isSuccess()) {
            return OrderResult.confirmed(order.getId(), result.getTransactionId());
        }
        
        return OrderResult.failed(result.getErrorMessage());
    }
}

決済失敗時のテスト

Red: 決済失敗のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Test
@DisplayName("決済失敗時はエラーメッセージを返す")
void returnErrorWhenPaymentFails() {
    PaymentPort mockPaymentPort = new PaymentPort() {
        @Override
        public PaymentResult charge(CreditCard card, int amount) {
            return PaymentResult.failure("カードの有効期限が切れています");
        }
    };
    
    PaymentService service = new PaymentService(mockPaymentPort);
    Order order = new Order("ORDER-001", 10000);
    CreditCard card = new CreditCard("4111111111111111", "12/20", "123");
    
    OrderResult result = service.processPayment(order, card);
    
    assertFalse(result.isConfirmed());
    assertEquals("カードの有効期限が切れています", result.getErrorMessage());
}

このテストは既存の実装で通ります。次はリトライ機能を追加します。

リトライ機能のTDD

Red: リトライ動作のテスト

 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
@Test
@DisplayName("ネットワークエラー時は最大3回リトライする")
void retryUpToThreeTimesOnNetworkError() {
    // リトライ回数を記録するカウンター
    int[] callCount = {0};
    
    PaymentPort mockPaymentPort = new PaymentPort() {
        @Override
        public PaymentResult charge(CreditCard card, int amount) {
            callCount[0]++;
            if (callCount[0] < 3) {
                throw new NetworkException("接続タイムアウト");
            }
            return PaymentResult.success("TXN-12345");
        }
    };
    
    PaymentService service = new PaymentService(mockPaymentPort);
    Order order = new Order("ORDER-001", 10000);
    CreditCard card = new CreditCard("4111111111111111", "12/25", "123");
    
    OrderResult result = service.processPayment(order, card);
    
    assertTrue(result.isConfirmed());
    assertEquals(3, callCount[0]); // 3回目で成功
}

Green: リトライロジックを追加

 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
// PaymentService.java
public class PaymentService {
    private static final int MAX_RETRY_COUNT = 3;
    private final PaymentPort paymentPort;
    
    public PaymentService(PaymentPort paymentPort) {
        this.paymentPort = paymentPort;
    }
    
    public OrderResult processPayment(Order order, CreditCard card) {
        PaymentResult result = null;
        int retryCount = 0;
        
        while (retryCount < MAX_RETRY_COUNT) {
            try {
                result = paymentPort.charge(card, order.getAmount());
                break; // 成功したらループを抜ける
            } catch (NetworkException e) {
                retryCount++;
                if (retryCount >= MAX_RETRY_COUNT) {
                    return OrderResult.failed("ネットワークエラー: " + e.getMessage());
                }
            }
        }
        
        if (result != null && result.isSuccess()) {
            return OrderResult.confirmed(order.getId(), result.getTransactionId());
        }
        
        return OrderResult.failed(result != null ? result.getErrorMessage() : "不明なエラー");
    }
}

Refactor: リトライロジックを汎用化

リトライロジックを再利用可能な形に抽出します。

 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
// RetryExecutor.java
public class RetryExecutor {
    private final int maxRetries;
    
    public RetryExecutor(int maxRetries) {
        this.maxRetries = maxRetries;
    }
    
    public <T> T execute(Supplier<T> operation) {
        int retryCount = 0;
        while (true) {
            try {
                return operation.get();
            } catch (NetworkException e) {
                retryCount++;
                if (retryCount >= maxRetries) {
                    throw e;
                }
            }
        }
    }
}

// PaymentService.java - リファクタリング後
public class PaymentService {
    private final PaymentPort paymentPort;
    private final RetryExecutor retryExecutor;
    
    public PaymentService(PaymentPort paymentPort) {
        this.paymentPort = paymentPort;
        this.retryExecutor = new RetryExecutor(3);
    }
    
    public OrderResult processPayment(Order order, CreditCard card) {
        try {
            PaymentResult result = retryExecutor.execute(
                () -> paymentPort.charge(card, order.getAmount())
            );
            
            if (result.isSuccess()) {
                return OrderResult.confirmed(order.getId(), result.getTransactionId());
            }
            return OrderResult.failed(result.getErrorMessage());
            
        } catch (NetworkException e) {
            return OrderResult.failed("ネットワークエラー: " + e.getMessage());
        }
    }
}

ケーススタディ2のポイント

  • ポート・アダプタパターンで外部依存を抽象化: 外部APIをインターフェースで抽象化することで、テスト時にモックを注入できる
  • 異常系のテストが容易: モックを使うことで、ネットワークエラーやタイムアウトを自由に再現できる
  • 本番コードに影響なくテスト可能: 実際のAPIを呼び出さずにビジネスロジックを検証できる

ケーススタディ3: 在庫管理システム

要件定義

商品の在庫を管理するシステムを実装します。

  • 在庫の追加・減少ができる
  • 在庫がマイナスになる操作は拒否する
  • 在庫数が閾値を下回ったらアラートを発行する
  • 在庫操作の履歴を記録する

ドメインモデルの設計

TDDを通じて、リッチなドメインモデルを構築していきます。

Red: 在庫追加のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// InventoryTest.java
class InventoryTest {
    
    @Test
    @DisplayName("在庫を追加できる")
    void addStock() {
        Inventory inventory = new Inventory("PRODUCT-001", 10);
        
        inventory.add(5);
        
        assertEquals(15, inventory.getQuantity());
    }
    
    @Test
    @DisplayName("在庫を減少できる")
    void reduceStock() {
        Inventory inventory = new Inventory("PRODUCT-001", 10);
        
        inventory.reduce(3);
        
        assertEquals(7, inventory.getQuantity());
    }
}

Green: 基本実装

 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
// Inventory.java
public class Inventory {
    private final String productId;
    private int quantity;
    
    public Inventory(String productId, int initialQuantity) {
        this.productId = productId;
        this.quantity = initialQuantity;
    }
    
    public void add(int amount) {
        this.quantity += amount;
    }
    
    public void reduce(int amount) {
        this.quantity -= amount;
    }
    
    public int getQuantity() {
        return quantity;
    }
    
    public String getProductId() {
        return productId;
    }
}

不変条件の保護

Red: 在庫がマイナスになる操作を拒否

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
@DisplayName("在庫がマイナスになる操作は例外をスローする")
void throwExceptionWhenReducingBelowZero() {
    Inventory inventory = new Inventory("PRODUCT-001", 10);
    
    assertThrows(InsufficientStockException.class, () -> {
        inventory.reduce(15);
    });
    
    // 在庫は変更されていないことを確認
    assertEquals(10, inventory.getQuantity());
}

Green: バリデーションを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// InsufficientStockException.java
public class InsufficientStockException extends RuntimeException {
    public InsufficientStockException(String message) {
        super(message);
    }
}

// Inventory.java
public void reduce(int amount) {
    if (this.quantity < amount) {
        throw new InsufficientStockException(
            "在庫不足: 現在" + quantity + "個、要求" + amount + "個"
        );
    }
    this.quantity -= amount;
}

ドメインイベントの発行

在庫が閾値を下回った際にアラートを発行する機能を追加します。これはドメインイベントパターンで実装します。

Red: アラート発行のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Test
@DisplayName("在庫が閾値を下回るとアラートイベントが発行される")
void raiseAlertWhenBelowThreshold() {
    List<DomainEvent> events = new ArrayList<>();
    Inventory inventory = new Inventory("PRODUCT-001", 15);
    inventory.setLowStockThreshold(10);
    inventory.setEventHandler(events::add);
    
    inventory.reduce(10); // 残り5個で閾値以下
    
    assertEquals(1, events.size());
    assertTrue(events.get(0) instanceof LowStockAlert);
    LowStockAlert alert = (LowStockAlert) events.get(0);
    assertEquals("PRODUCT-001", alert.getProductId());
    assertEquals(5, alert.getCurrentQuantity());
}

Green: イベント発行を実装

 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
// DomainEvent.java
public interface DomainEvent {
    LocalDateTime getOccurredAt();
}

// LowStockAlert.java
public class LowStockAlert implements DomainEvent {
    private final String productId;
    private final int currentQuantity;
    private final LocalDateTime occurredAt;
    
    public LowStockAlert(String productId, int currentQuantity) {
        this.productId = productId;
        this.currentQuantity = currentQuantity;
        this.occurredAt = LocalDateTime.now();
    }
    
    public String getProductId() { return productId; }
    public int getCurrentQuantity() { return currentQuantity; }
    @Override
    public LocalDateTime getOccurredAt() { return occurredAt; }
}

// Inventory.java - イベント発行機能を追加
public class Inventory {
    private final String productId;
    private int quantity;
    private int lowStockThreshold = 0;
    private Consumer<DomainEvent> eventHandler = event -> {};
    
    // 既存のコードは省略
    
    public void setLowStockThreshold(int threshold) {
        this.lowStockThreshold = threshold;
    }
    
    public void setEventHandler(Consumer<DomainEvent> handler) {
        this.eventHandler = handler;
    }
    
    public void reduce(int amount) {
        if (this.quantity < amount) {
            throw new InsufficientStockException(
                "在庫不足: 現在" + quantity + "個、要求" + amount + "個"
            );
        }
        this.quantity -= amount;
        
        if (this.quantity <= lowStockThreshold && lowStockThreshold > 0) {
            eventHandler.accept(new LowStockAlert(productId, quantity));
        }
    }
}

操作履歴の記録

Red: 履歴記録のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Test
@DisplayName("在庫操作の履歴が記録される")
void recordStockOperationHistory() {
    Inventory inventory = new Inventory("PRODUCT-001", 10);
    
    inventory.add(5);
    inventory.reduce(3);
    
    List<StockOperation> history = inventory.getOperationHistory();
    assertEquals(2, history.size());
    
    assertEquals(OperationType.ADD, history.get(0).getType());
    assertEquals(5, history.get(0).getAmount());
    
    assertEquals(OperationType.REDUCE, history.get(1).getType());
    assertEquals(3, history.get(1).getAmount());
}

Green + Refactor: 履歴管理を追加

 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
// StockOperation.java
public class StockOperation {
    private final OperationType type;
    private final int amount;
    private final int resultQuantity;
    private final LocalDateTime operatedAt;
    
    public StockOperation(OperationType type, int amount, int resultQuantity) {
        this.type = type;
        this.amount = amount;
        this.resultQuantity = resultQuantity;
        this.operatedAt = LocalDateTime.now();
    }
    
    public OperationType getType() { return type; }
    public int getAmount() { return amount; }
    public int getResultQuantity() { return resultQuantity; }
    public LocalDateTime getOperatedAt() { return operatedAt; }
}

// OperationType.java
public enum OperationType {
    ADD, REDUCE
}

// Inventory.java - 最終形
public class Inventory {
    private final String productId;
    private int quantity;
    private int lowStockThreshold = 0;
    private Consumer<DomainEvent> eventHandler = event -> {};
    private final List<StockOperation> operationHistory = new ArrayList<>();
    
    public Inventory(String productId, int initialQuantity) {
        this.productId = productId;
        this.quantity = initialQuantity;
    }
    
    public void add(int amount) {
        this.quantity += amount;
        recordOperation(OperationType.ADD, amount);
    }
    
    public void reduce(int amount) {
        if (this.quantity < amount) {
            throw new InsufficientStockException(
                "在庫不足: 現在" + quantity + "個、要求" + amount + "個"
            );
        }
        this.quantity -= amount;
        recordOperation(OperationType.REDUCE, amount);
        checkLowStock();
    }
    
    private void recordOperation(OperationType type, int amount) {
        operationHistory.add(new StockOperation(type, amount, quantity));
    }
    
    private void checkLowStock() {
        if (quantity <= lowStockThreshold && lowStockThreshold > 0) {
            eventHandler.accept(new LowStockAlert(productId, quantity));
        }
    }
    
    public int getQuantity() { return quantity; }
    public String getProductId() { return productId; }
    public List<StockOperation> getOperationHistory() { 
        return Collections.unmodifiableList(operationHistory); 
    }
    
    public void setLowStockThreshold(int threshold) {
        this.lowStockThreshold = threshold;
    }
    
    public void setEventHandler(Consumer<DomainEvent> handler) {
        this.eventHandler = handler;
    }
}

ケーススタディ3のポイント

  • 不変条件をテストで表現: 在庫がマイナスにならないというビジネスルールをテストで明示
  • ドメインイベントで副作用を分離: アラート発行などの副作用をイベントとして分離することで、テストが容易になる
  • リッチなドメインモデル: TDDを通じて、振る舞いを持つドメインモデルが自然に形成される

実プロジェクトでTDDを成功させるための原則

3つのケーススタディを通じて得られた知見を整理します。

原則1: 適切な抽象化レイヤーを設ける

flowchart TB
    subgraph testable["テスト対象(高速・安定)"]
        DL[ドメインロジック]
        AS[アプリケーションサービス]
    end
    
    subgraph boundary["境界層"]
        PORT[ポート/インターフェース]
    end
    
    subgraph external["外部依存(遅い・不安定)"]
        DB[(データベース)]
        API[外部API]
        FS[ファイルシステム]
    end
    
    DL --> AS
    AS --> PORT
    PORT --> DB
    PORT --> API
    PORT --> FS
    
    style testable fill:#e6ffe6,stroke:#00cc00,color:#000000
    style boundary fill:#fff2e6,stroke:#cc6600,color:#000000
    style external fill:#ffcccc,stroke:#cc0000,color:#000000
~~~

### 原則2: テストピラミッドを意識する

| レベル | テスト対象 | 実行速度 | 数量 |
|--------|-----------|---------|------|
| ユニットテスト | ドメインロジック | ミリ秒 | 多い |
| 統合テスト | アダプタ・リポジトリ | 秒 | 中程度 |
| E2Eテスト | システム全体 | 分 | 少ない |

TDDで書くテストは主にユニットテストです。外部依存を含む部分は統合テストで別途検証します。

### 原則3: テストを仕様書として書く

~~~java
// 悪い例:テストの意図が不明確
@Test
void test1() {
    Cart c = new Cart();
    c.addItem(new Product("A", 100), 5);
    assertEquals(500, c.calculateTotal());
}

// 良い例:テストが仕様を語る
@Test
@DisplayName("商品を追加すると単価×数量が合計金額に反映される")
void itemPriceMultipliedByQuantityAddedToTotal() {
    // Given: 空のカート
    Cart cart = new Cart();
    Product product = new Product("商品A", 100);
    
    // When: 商品を5個追加
    cart.addItem(product, 5);
    
    // Then: 合計金額は500円
    assertEquals(500, cart.calculateTotal());
}
~~~

## まとめ

本記事では、ECサイトのカート機能、決済API連携、在庫管理システムという3つの実践的なケーススタディを通じて、実プロジェクトでのTDD適用方法を解説しました。

実プロジェクトでTDDを成功させるための重要なポイントを振り返ります。

- **シンプルなケースから始める**: 複雑な要件も、最もシンプルなテストケースから段階的に進める
- **外部依存をインターフェースで抽象化**: ポート・アダプタパターンを適用し、ビジネスロジックを外部依存から分離する
- **テストが設計を導く**: テストを書くことで、自然と適切な責務分割やインターフェース設計が導かれる
- **リファクタリングを怠らない**: Greenになった後のリファクタリングが、長期的なコード品質を左右する
- **ドメインイベントで副作用を分離**: アラート発行や通知などの副作用をイベントとして分離することで、テスタビリティが向上する

TDDは単なるテスト手法ではなく、設計手法でもあります。テストを先に書くことで「このコードはどう使われるべきか」を常に意識し、自然と使いやすいインターフェースが生まれます。ぜひ実際のプロジェクトで実践してみてください。

## 参考リンク

- [Martin Fowler - Test Driven Development](https://martinfowler.com/bliki/TestDrivenDevelopment.html)
- [Kent Beck『テスト駆動開発』(オーム社)](https://www.amazon.co.jp/dp/4274217884)
- [Michael Feathers『レガシーコード改善ガイド』(翔泳社)](https://www.amazon.co.jp/dp/4798116831)
- [Alistair Cockburn - Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/)
- [JUnit 5 User Guide](https://junit.org/junit5/docs/current/user-guide/)