はじめに#
FizzBuzz問題でRed-Green-Refactorサイクルを体験した後、多くの開発者は「実際のプロジェクトでTDDをどう適用すればよいのか」という壁にぶつかります。実務では、外部API連携、データベース操作、複雑なビジネスロジックなど、シンプルな練習問題では遭遇しない課題が山積しています。
本記事では、ECサイトのカート機能、決済API連携、在庫管理システムという3つの実践的なケーススタディを通じて、現場でTDDを適用するための具体的なアプローチを解説します。外部依存をどう切り離すか、ビジネスルールをどうテストするか、そして設計をどう改善していくかを、実際のコード例とともに学んでいきましょう。
実プロジェクトでTDDが難しい理由#
練習問題と実プロジェクトでは、TDDの適用難易度が大きく異なります。その理由を整理してみましょう。
| 観点 |
練習問題 |
実プロジェクト |
| 入出力 |
純粋な計算のみ |
DB、API、ファイルシステム |
| ビジネスロジック |
シンプルな条件分岐 |
複雑なルールの組み合わせ |
| 状態管理 |
ステートレス |
セッション、トランザクション |
| テスト実行速度 |
瞬時 |
外部依存があると遅延 |
| テスト安定性 |
常に同じ結果 |
外部要因で結果が変動 |
これらの課題に対処するため、実プロジェクトでのTDDでは以下の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との連携では、以下の課題があります。
- テスト実行のたびにAPIを呼び出すのは非現実的: コスト、速度、安定性の問題
- エラーケースの再現が困難: ネットワークエラーやタイムアウトを意図的に発生させにくい
- 決済の副作用: テストで実際に課金が発生してしまう
これらの課題を解決するために、ポート・アダプタパターン(六角形アーキテクチャ) を適用します。
アーキテクチャ設計#
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/)