テスト戦略 — テストピラミッドと層別テスト
DDDにおけるテストの考え方
DDDとクリーンアーキテクチャでは、各層を独立してテストできることが大きなメリットです。
テストピラミッド
テストピラミッドは、効率的なテスト戦略を視覚化したモデルです。
テストピラミッドの原則
- 基盤は高速なユニットテスト: 最も数が多く、最も速く実行される
- 中間は統合テスト: DBや外部サービスとの連携をテスト
- 頂点は少数のE2Eテスト: 重要なユーザーフローのみをテスト
なぜこの形が理想的なのか?
| 理由 | 説明 |
|---|---|
| 高速なフィードバック | ユニットテストは数秒で完了し、開発中に何度も実行できる |
| 問題の早期発見 | ドメインロジックの誤りをコーディング中に検出 |
| メンテナンス性 | ユニットテストはシンプルで変更に強い |
| 信頼性 | 各層のテストを組み合わせることで、全体の品質を保証 |
アンチパターン(アイスクリームコーン):
E2Eテストばかりで、ユニットテストが少ない状態。実行に時間がかかり、テストが壊れやすくなります。
| テスト種別 | 対象層 | 特徴 | 実行時間 | 数 |
|---|---|---|---|---|
| Unit Test | Domain, Application | DBなし、高速、多数 | 0.1秒/テスト | 数百〜数千 |
| Integration Test | Infrastructure | DB使用、中程度 | 1秒/テスト | 数十〜数百 |
| Feature Test | Presentation | HTTP経由、E2E的 | 3秒/テスト | 数個〜数十 |
ドメイン層のテスト
なぜドメイン層のテストが「DBなし」で実行できることが重要なのか
DDDアーキテクチャの最大の利点の1つは、ビジネスロジックをインフラから独立させることです。
[従来のアプローチ(Active Record)]
Order::create([...]); // Eloquent Model(DBに依存)
↓
テストでDBが必要
↓
- テストが遅い(DB接続、マイグレーション、データ投入)
- テストが不安定(DB状態に依存)
- テストが書きにくい(セットアップが複雑)
[DDDアプローチ(ドメインモデル)]
Order::create($orderId, $address); // ドメインエンティティ(DBに依存しない)
↓
テストでDBが不要
↓
- テストが高速(メモリ上で完結)
- テストが安定(純粋な関数のように動作)
- テストが書きやすい(new するだけ)
ドメイン層をDBから独立させるメリット:
| メリット | 説明 |
|---|---|
| 高速な実行 | DBアクセスなしで、数百のテストを数秒で実行できる |
| 高い安定性 | DB状態に依存しないため、テストが壊れにくい |
| 開発効率 | テストを書きながらドメインロジックを設計できる(TDD) |
| リファクタリング | ビジネスロジックの変更時に即座にフィードバックを得られる |
ドメイン層のテストが遅い場合、設計を見直す
もしドメイン層のテストでDBが必要になっている場合、ドメイン層にインフラの責務が混入している可能性があります。
// BAD: ドメインエンティティがEloquentに依存
class Order extends Model // ✗ DBに依存
{
public function confirm(): void
{
$this->update(['status' => 'confirmed']); // ✗ DB操作
}
}
// GOOD: ドメインエンティティは純粋なPHPクラス
class Order // ✓ DBに依存しない
{
public function confirm(): void
{
$this->status = OrderStatus::CONFIRMED; // ✓ メモリ上で完結
}
}
エンティティのテスト
ドメイン層のテストは、DBなしで実行できます。
// tests/Unit/Domain/Order/OrderTest.php
final class OrderTest extends TestCase
{
private function createOrder(): Order
{
return Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
}
private function createOrderWithItem(): Order
{
$order = $this->createOrder();
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
return $order;
}
public function test_新規注文は下書き状態で作成される(): void
{
$order = $this->createOrder();
$this->assertTrue($order->status()->isDraft());
}
public function test_商品を追加できる(): void
{
$order = $this->createOrder();
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
$this->assertCount(1, $order->orderLines());
$this->assertEquals(2000, $order->totalAmount()->amount());
}
public function test_注文を確定できる(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$this->assertTrue($order->status()->isConfirmed());
}
public function test_明細が空の注文は確定できない(): void
{
$order = $this->createOrder();
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('注文明細が空');
$order->confirm();
}
public function test_確定済み注文には商品を追加できない(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$this->expectException(InvalidOrderStateException::class);
$order->addItem(
new OrderLineId(2),
new ProductId(102),
1,
new Money(500, 'JPY')
);
}
public function test_確定済み注文はキャンセルできる(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$order->cancel('お客様都合');
$this->assertTrue($order->status()->isCancelled());
}
public function test_発送済み注文はキャンセルできない(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$order->ship('TRACK123');
$this->expectException(InvalidOrderStateException::class);
$order->cancel('お客様都合');
}
}
値オブジェクトのテスト
// tests/Unit/Domain/Shared/MoneyTest.php
final class MoneyTest extends TestCase
{
public function test_同じ金額と通貨は等しい(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'JPY');
$this->assertTrue($money1->equals($money2));
}
public function test_異なる通貨は等しくない(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'USD');
$this->assertFalse($money1->equals($money2));
}
public function test_加算できる(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(500, 'JPY');
$result = $money1->add($money2);
$this->assertEquals(1500, $result->amount());
}
public function test_異なる通貨は加算できない(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(10, 'USD');
$this->expectException(\InvalidArgumentException::class);
$money1->add($money2);
}
public function test_乗算できる(): void
{
$money = new Money(1000, 'JPY');
$result = $money->multiply(3);
$this->assertEquals(3000, $result->amount());
}
public function test_負の金額は作成できない(): void
{
$this->expectException(\InvalidArgumentException::class);
new Money(-100, 'JPY');
}
}
ドメインサービスのテスト
// tests/Unit/Domain/Shipping/ShippingFeeCalculationServiceTest.php
final class ShippingFeeCalculationServiceTest extends TestCase
{
private ShippingFeeCalculationService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new ShippingFeeCalculationService();
}
public function test_5000円以上は送料無料(): void
{
$order = $this->createOrderWithTotal(5000);
$fee = $this->service->calculate($order);
$this->assertEquals(0, $fee->amount());
}
public function test_5000円未満は送料500円(): void
{
$order = $this->createOrderWithTotal(4999);
$fee = $this->service->calculate($order);
$this->assertEquals(500, $fee->amount());
}
public function test_北海道は追加送料500円(): void
{
$order = $this->createOrderWithTotalAndPrefecture(3000, '北海道');
$fee = $this->service->calculate($order);
$this->assertEquals(1000, $fee->amount()); // 基本500 + 追加500
}
private function createOrderWithTotal(int $amount): Order
{
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(1),
1,
new Money($amount, 'JPY')
);
return $order;
}
private function createOrderWithTotalAndPrefecture(int $amount, string $prefecture): Order
{
$order = Order::create(
new OrderId(1),
new ShippingAddress($prefecture, '市区町村', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(1),
1,
new Money($amount, 'JPY')
);
return $order;
}
}
アプリケーション層のテスト
モックの使い方と使いすぎの弊害
アプリケーション層のテストでは、リポジトリなどのインフラ依存をモック化します。
モックとは:
テスト対象のクラスが依存するオブジェクトを、テスト用の偽物(モック)に置き換える手法です。
[Without Mock - 統合テスト]
UseCase → Repository → Database
(実際のDB操作)
[With Mock - ユニットテスト]
UseCase → Mock Repository (偽物)
↓
- save()が呼ばれたかを検証
- DBアクセスなし
- 高速
モックを使うメリット:
| メリット | 説明 |
|---|---|
| 高速 | DBアクセスがないため、テストが高速 |
| 独立性 | インフラの実装に依存せずテストできる |
| 振る舞いの検証 | 「正しいメソッドが呼ばれたか」を検証できる |
モックの使いすぎの弊害:
// BAD: すべてをモック化しすぎ
$mock1 = $this->createMock(Dependency1::class);
$mock2 = $this->createMock(Dependency2::class);
$mock3 = $this->createMock(Dependency3::class);
// ... モックだらけで本質が見えない
// GOOD: 必要最小限のモック化
$orderRepository = $this->createMock(OrderRepositoryInterface::class);
$useCase = new ConfirmOrderUseCase($orderRepository);
モックは必要最小限に
| 基準 | モックを使う | 実物を使う |
|---|---|---|
| インフラ依存(DB, 外部API) | ✓ モック化 | - |
| ドメインオブジェクト(エンティティ、値オブジェクト) | - | ✓ 実物を使う |
| ドメインサービス | - | ✓ 実物を使う |
理由: ドメインオブジェクトは軽量でDBに依存しないため、モック化する必要がありません。むしろ実物を使うことで、ビジネスロジック全体の動作をテストできます。
UseCaseのテスト(モック使用)
// tests/Unit/Application/UseCase/Order/CreateOrderUseCaseTest.php
final class CreateOrderUseCaseTest extends TestCase
{
private OrderRepositoryInterface|MockObject $orderRepository;
private CreateOrderUseCase $useCase;
protected function setUp(): void
{
parent::setUp();
$this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
$this->useCase = new CreateOrderUseCase($this->orderRepository);
}
public function test_注文を作成できる(): void
{
// Arrange
$this->orderRepository
->method('nextIdentity')
->willReturn(new OrderId(1));
$this->orderRepository
->method('nextLineIdentity')
->willReturnOnConsecutiveCalls(
new OrderLineId(1),
new OrderLineId(2)
);
$this->orderRepository
->expects($this->once())
->method('save')
->with($this->callback(function (Order $order) {
return $order->id()->value() === 1
&& count($order->orderLines()) === 2;
}));
$command = new CreateOrderCommand(
'東京都',
'渋谷区',
'1-1-1',
[
['productId' => 101, 'quantity' => 2, 'unitPrice' => 1000],
['productId' => 102, 'quantity' => 1, 'unitPrice' => 500],
]
);
// Act
$orderId = $this->useCase->execute($command);
// Assert
$this->assertEquals(1, $orderId->value());
}
}
// tests/Unit/Application/UseCase/Order/ConfirmOrderUseCaseTest.php
final class ConfirmOrderUseCaseTest extends TestCase
{
private OrderRepositoryInterface|MockObject $orderRepository;
private InventoryRepositoryInterface|MockObject $inventoryRepository;
private ConfirmOrderUseCase $useCase;
protected function setUp(): void
{
parent::setUp();
$this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
$this->inventoryRepository = $this->createMock(InventoryRepositoryInterface::class);
$this->useCase = new ConfirmOrderUseCase(
$this->orderRepository,
$this->inventoryRepository
);
}
public function test_注文を確定できる(): void
{
// Arrange
$order = $this->createDraftOrderWithItem();
$inventory = new Inventory(new ProductId(101), 10);
$this->orderRepository
->method('findById')
->willReturn($order);
$this->inventoryRepository
->method('findByProductId')
->willReturn($inventory);
$this->orderRepository
->expects($this->once())
->method('save');
$this->inventoryRepository
->expects($this->once())
->method('save');
// Act & Assert
// expects($this->once())で設定したsave()が呼ばれることを検証
// 例外が発生しなければテスト成功
$this->useCase->execute(new ConfirmOrderCommand(1));
}
public function test_注文が見つからない場合は例外(): void
{
$this->orderRepository
->method('findById')
->willReturn(null);
$this->expectException(OrderNotFoundException::class);
$this->useCase->execute(new ConfirmOrderCommand(999));
}
public function test_在庫不足の場合は例外(): void
{
$order = $this->createDraftOrderWithItem(quantity: 10);
$inventory = new Inventory(new ProductId(101), stock: 5);
$this->orderRepository
->method('findById')
->willReturn($order);
$this->inventoryRepository
->method('findByProductId')
->willReturn($inventory);
$this->expectException(InsufficientStockException::class);
$this->useCase->execute(new ConfirmOrderCommand(1));
}
private function createDraftOrderWithItem(int $quantity = 2): Order
{
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
$quantity,
new Money(1000, 'JPY')
);
return $order;
}
}
インフラ層のテスト
リポジトリの統合テスト
// tests/Integration/Infrastructure/Repository/EloquentOrderRepositoryTest.php
final class EloquentOrderRepositoryTest extends TestCase
{
use RefreshDatabase;
private EloquentOrderRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = new EloquentOrderRepository(
new LaravelDomainEventDispatcher()
);
}
public function test_注文を保存して取得できる(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
// Act
$this->repository->save($order);
$retrieved = $this->repository->findById(new OrderId(1));
// Assert
$this->assertNotNull($retrieved);
$this->assertEquals(1, $retrieved->id()->value());
$this->assertTrue($retrieved->status()->isDraft());
$this->assertCount(1, $retrieved->orderLines());
$this->assertEquals(2000, $retrieved->totalAmount()->amount());
}
public function test_存在しない注文はnullを返す(): void
{
$result = $this->repository->findById(new OrderId(999));
$this->assertNull($result);
}
public function test_注文を更新できる(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
$this->repository->save($order);
// Act
$retrieved = $this->repository->findById(new OrderId(1));
$retrieved->confirm();
$this->repository->save($retrieved);
// Assert
$updated = $this->repository->findById(new OrderId(1));
$this->assertTrue($updated->status()->isConfirmed());
}
public function test_nextIdentityは一意のIDを生成する(): void
{
$id1 = $this->repository->nextIdentity();
$id2 = $this->repository->nextIdentity();
$this->assertNotEquals($id1->value(), $id2->value());
}
}
プレゼンテーション層のテスト
Feature Test(HTTP経由)
// tests/Feature/Http/Controllers/Api/OrderControllerTest.php
final class OrderControllerTest extends TestCase
{
use RefreshDatabase;
public function test_注文を作成できる(): void
{
$response = $this->postJson('/api/orders', [
'shipping' => [
'prefecture' => '東京都',
'city' => '渋谷区',
'street' => '1-1-1',
],
'items' => [
['productId' => 101, 'quantity' => 2, 'unitPrice' => 1000],
],
]);
$response->assertStatus(201)
->assertJsonStructure(['id', 'message']);
$this->assertDatabaseHas('orders', [
'shipping_prefecture' => '東京都',
'status' => 'draft',
]);
}
public function test_バリデーションエラー(): void
{
$response = $this->postJson('/api/orders', [
'shipping' => [
'prefecture' => '', // 必須項目が空
],
'items' => [], // 空の配列
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['shipping.prefecture', 'items']);
}
public function test_注文を確定できる(): void
{
// Arrange: 注文と在庫を作成
$order = OrderModel::factory()->create(['status' => 'draft']);
OrderLineModel::factory()->create([
'order_id' => $order->id,
'product_id' => 101,
'quantity' => 2,
]);
InventoryModel::factory()->create([
'product_id' => 101,
'stock' => 10,
]);
// Act
$response = $this->postJson("/api/orders/{$order->id}/confirm");
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => 'confirmed',
]);
}
public function test_存在しない注文の確定は404(): void
{
$response = $this->postJson('/api/orders/999/confirm');
$response->assertStatus(404)
->assertJson(['code' => 'ORDER_NOT_FOUND']);
}
}
テストのベストプラクティス
1. Arrange-Act-Assert パターン
public function test_example(): void
{
// Arrange: テストデータの準備
$order = $this->createOrder();
// Act: テスト対象の実行
$order->confirm();
// Assert: 結果の検証
$this->assertTrue($order->status()->isConfirmed());
}
2. テストメソッド名は日本語で
// 何をテストしているかが明確
public function test_確定済み注文はキャンセルできる(): void
public function test_発送済み注文はキャンセルできない(): void
public function test_明細が空の注文は確定できない(): void
3. テストデータビルダーパターンの活用
テストデータビルダーパターンは、テストデータの生成を簡潔にするデザインパターンです。
なぜテストデータビルダーが必要なのか
// BAD: テストごとに同じセットアップコードを繰り返す
public function test_注文を確定できる(): void
{
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
// テストロジック...
}
public function test_注文をキャンセルできる(): void
{
$order = Order::create( // ← 同じコードが繰り返される
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
// テストロジック...
}
// GOOD: ビルダーパターンで簡潔に
public function test_注文を確定できる(): void
{
$order = OrderBuilder::default()->build();
// テストロジック...
}
public function test_注文をキャンセルできる(): void
{
$order = OrderBuilder::default()->build();
// テストロジック...
}
テストデータビルダーの利点:
| 利点 | 説明 |
|---|---|
| DRY原則 | テストデータの生成ロジックを一箇所に集約 |
| 可読性 | テストの意図が明確になる |
| メンテナンス性 | ドメインモデルの変更時、ビルダーのみ修正すればよい |
| 柔軟性 | 必要なプロパティのみをカスタマイズできる |
実装例
// tests/Support/OrderBuilder.php
final class OrderBuilder
{
private OrderId $id;
private ShippingAddress $shippingAddress;
private array $items = [];
public function __construct()
{
// デフォルト値を設定
$this->id = new OrderId(1);
$this->shippingAddress = new ShippingAddress('東京都', '渋谷区', '1-1-1');
}
public static function default(): self
{
return new self();
}
public function withId(int $id): self
{
$this->id = new OrderId($id);
return $this;
}
public function withShippingAddress(string $prefecture, string $city, string $street): self
{
$this->shippingAddress = new ShippingAddress($prefecture, $city, $street);
return $this;
}
public function withItem(int $productId, int $quantity, int $price): self
{
$this->items[] = [
'productId' => $productId,
'quantity' => $quantity,
'price' => $price,
];
return $this;
}
public function build(): Order
{
$order = Order::create($this->id, $this->shippingAddress);
foreach ($this->items as $index => $item) {
$order->addItem(
new OrderLineId($index + 1),
new ProductId($item['productId']),
$item['quantity'],
new Money($item['price'], 'JPY')
);
}
return $order;
}
}
使用例
// デフォルト値で作成
$order = OrderBuilder::default()->build();
// 特定のプロパティをカスタマイズ
$order = OrderBuilder::default()
->withId(123)
->withItem(101, 2, 1000)
->withItem(102, 1, 500)
->build();
// 特定のシナリオ用のエイリアスメソッド
$order = OrderBuilder::default()
->withItem(101, 10, 100) // 大量注文
->build();
まとめ
| 層 | テスト種別 | 特徴 |
|---|---|---|
| Domain | Unit Test | DBなし、高速、ビジネスルールを検証 |
| Application | Unit Test + Mock | リポジトリをモック、フローを検証 |
| Infrastructure | Integration Test | DB使用、永続化を検証 |
| Presentation | Feature Test | HTTP経由、E2E的な検証 |
テスト戦略のポイント
| ポイント | 説明 |
|---|---|
| テストピラミッド | 基盤は高速なユニットテスト、頂点は少数のE2Eテスト |
| ドメイン層はDBなし | ビジネスロジックをインフラから独立させることで高速テストを実現 |
| モックは最小限 | インフラ依存のみモック化、ドメインオブジェクトは実物を使う |
| テストデータビルダー | テストデータの生成を簡潔にし、メンテナンス性を向上 |
| Arrange-Act-Assert | テストを3つのセクションに分けて可読性を向上 |
参考リソース
本書のテストコードは PHPUnit スタイル(extends TestCase + test_xxx() メソッド)で記述しています。Laravel 11 からはデフォルトのテストフレームワークが Pest に切り替わっていますが、Pest も内部で PHPUnit を利用しているため、本書のサンプルは両環境で動作します。
- Laravel Testing - Laravel公式ドキュメント
- PHPUnit Documentation - PHPUnit公式ドキュメント
- Pest PHP - Laravel 11+ のデフォルトテストフレームワーク
- Martin Fowler「Test Pyramid」 - テストピラミッドの概念
次のチャプターでは、DDDとクリーンアーキテクチャにおける認証・認可設計について学びます。