Testing Strategy — The Test Pyramid and Layered Testing
The idea of testing in DDD
In DDD and clean architecture, a major benefit is that you can test each layer independently.
The test pyramid
The test pyramid is a model that visualizes an efficient testing strategy.
The principles of the test pyramid
- The foundation is fast unit tests: the most numerous and the fastest to run
- The middle is integration tests: test integration with a DB and external services
- The top is a few E2E tests: test only important user flows
| Reason | Description |
|---|---|
| Fast feedback | Unit tests complete in seconds and can be run many times during development |
| Early detection of problems | Detect domain-logic mistakes while coding |
| Maintainability | Unit tests are simple and resilient to change |
| Reliability | Combining tests at each layer guarantees the overall quality |
Anti-pattern (the ice-cream cone):
A state with too many E2E tests and few unit tests. It takes a long time to run, and the tests become brittle.
| Test type | Target layer | Characteristic | Execution time | Count |
|---|---|---|---|---|
| Unit Test | Domain, Application | No DB, fast, many | 0.1s/test | Hundreds to thousands |
| Integration Test | Infrastructure | Uses a DB, moderate | 1s/test | Tens to hundreds |
| Feature Test | Presentation | Over HTTP, E2E-like | 3s/test | A few to tens |
Testing the domain layer
Why it matters that domain-layer tests can run "without a DB"
One of the biggest benefits of a DDD architecture is making business logic independent of infrastructure.
[The traditional approach (Active Record)]
Order::create([...]); // Eloquent Model (depends on the DB)
↓
Tests require a DB
↓
- Tests are slow (DB connection, migration, data seeding)
- Tests are unstable (depend on DB state)
- Tests are hard to write (setup is complex)
[The DDD approach (domain model)]
Order::create($orderId, $address); // a domain entity (does not depend on the DB)
↓
Tests do not require a DB
↓
- Tests are fast (complete in memory)
- Tests are stable (behave like pure functions)
- Tests are easy to write (just new it up)
Benefits of making the domain layer independent of the DB:
| Benefit | Description |
|---|---|
| Fast execution | Run hundreds of tests in seconds without DB access |
| High stability | Tests are hard to break because they do not depend on DB state |
| Development efficiency | You can design domain logic while writing tests (TDD) |
| Refactoring | Get immediate feedback when changing business logic |
If your domain-layer tests require a DB, infrastructure responsibilities may have crept into the domain layer.
// BAD: the domain entity depends on Eloquent
class Order extends Model // ✗ depends on the DB
{
public function confirm(): void
{
$this->update(['status' => 'confirmed']); // ✗ DB operation
}
}
// GOOD: the domain entity is a pure PHP class
class Order // ✓ does not depend on the DB
{
public function confirm(): void
{
$this->status = OrderStatus::CONFIRMED; // ✓ completes in memory
}
}
Testing entities
Domain-layer tests can run without a DB.
// tests/Unit/Domain/Order/OrderTest.php
final class OrderTest extends TestCase
{
private function createOrder(): Order
{
return Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '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_new_order_is_created_in_draft_status(): void
{
$order = $this->createOrder();
$this->assertTrue($order->status()->isDraft());
}
public function test_can_add_item(): 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_can_confirm_order(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$this->assertTrue($order->status()->isConfirmed());
}
public function test_cannot_confirm_order_with_empty_lines(): void
{
$order = $this->createOrder();
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('no line items');
$order->confirm();
}
public function test_cannot_add_item_to_confirmed_order(): 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_confirmed_order_can_be_cancelled(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$order->cancel('Customer request');
$this->assertTrue($order->status()->isCancelled());
}
public function test_shipped_order_cannot_be_cancelled(): void
{
$order = $this->createOrderWithItem();
$order->confirm();
$order->ship('TRACK123');
$this->expectException(InvalidOrderStateException::class);
$order->cancel('Customer request');
}
}
Testing value objects
// tests/Unit/Domain/Shared/MoneyTest.php
final class MoneyTest extends TestCase
{
public function test_same_amount_and_currency_are_equal(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'JPY');
$this->assertTrue($money1->equals($money2));
}
public function test_different_currencies_are_not_equal(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'USD');
$this->assertFalse($money1->equals($money2));
}
public function test_can_add(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(500, 'JPY');
$result = $money1->add($money2);
$this->assertEquals(1500, $result->amount());
}
public function test_cannot_add_different_currencies(): void
{
$money1 = new Money(1000, 'JPY');
$money2 = new Money(10, 'USD');
$this->expectException(\InvalidArgumentException::class);
$money1->add($money2);
}
public function test_can_multiply(): void
{
$money = new Money(1000, 'JPY');
$result = $money->multiply(3);
$this->assertEquals(3000, $result->amount());
}
public function test_cannot_create_negative_amount(): void
{
$this->expectException(\InvalidArgumentException::class);
new Money(-100, 'JPY');
}
}
Testing domain services
// 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_free_shipping_for_5000_yen_or_more(): void
{
$order = $this->createOrderWithTotal(5000);
$fee = $this->service->calculate($order);
$this->assertEquals(0, $fee->amount());
}
public function test_shipping_is_500_yen_under_5000_yen(): void
{
$order = $this->createOrderWithTotal(4999);
$fee = $this->service->calculate($order);
$this->assertEquals(500, $fee->amount());
}
public function test_hokkaido_has_500_yen_surcharge(): void
{
$order = $this->createOrderWithTotalAndPrefecture(3000, 'Hokkaido');
$fee = $this->service->calculate($order);
$this->assertEquals(1000, $fee->amount()); // base 500 + surcharge 500
}
private function createOrderWithTotal(int $amount): Order
{
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '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, 'City', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(1),
1,
new Money($amount, 'JPY')
);
return $order;
}
}
Testing the application layer
How to use mocks and the harm of overusing them
In application-layer tests, you mock out infrastructure dependencies such as repositories.
What is a mock:
A technique that replaces the objects a class under test depends on with test fakes (mocks).
[Without Mock - integration test]
UseCase → Repository → Database
(actual DB operations)
[With Mock - unit test]
UseCase → Mock Repository (a fake)
↓
- Verify that save() was called
- No DB access
- Fast
Benefits of using mocks:
| Benefit | Description |
|---|---|
| Fast | Tests are fast because there is no DB access |
| Independence | Test without depending on the infrastructure implementation |
| Behavior verification | Verify "was the right method called" |
The harm of overusing mocks:
// BAD: mocking too much of everything
$mock1 = $this->createMock(Dependency1::class);
$mock2 = $this->createMock(Dependency2::class);
$mock3 = $this->createMock(Dependency3::class);
// ... so many mocks that the essence is lost
// GOOD: minimal mocking
$orderRepository = $this->createMock(OrderRepositoryInterface::class);
$useCase = new ConfirmOrderUseCase($orderRepository);
| Criterion | Use a mock | Use the real thing |
|---|---|---|
| Infrastructure dependency (DB, external API) | ✓ Mock it | - |
| Domain object (entity, value object) | - | ✓ Use the real thing |
| Domain service | - | ✓ Use the real thing |
Reason: domain objects are lightweight and do not depend on a DB, so there is no need to mock them. On the contrary, using the real thing lets you test the behavior of the business logic as a whole.
Testing the UseCase (using mocks)
// 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_can_create_order(): 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(
'Tokyo',
'Shibuya',
'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_can_confirm_order(): 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
// Verify that save(), set up with expects($this->once()), is called
// The test succeeds if no exception is thrown
$this->useCase->execute(new ConfirmOrderCommand(1));
}
public function test_throws_when_order_not_found(): void
{
$this->orderRepository
->method('findById')
->willReturn(null);
$this->expectException(OrderNotFoundException::class);
$this->useCase->execute(new ConfirmOrderCommand(999));
}
public function test_throws_when_stock_is_insufficient(): 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('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
$quantity,
new Money(1000, 'JPY')
);
return $order;
}
}
Testing the infrastructure layer
Integration testing the repository
// 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_can_save_and_retrieve_order(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '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_returns_null_for_nonexistent_order(): void
{
$result = $this->repository->findById(new OrderId(999));
$this->assertNull($result);
}
public function test_can_update_order(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '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_next_identity_generates_unique_id(): void
{
$id1 = $this->repository->nextIdentity();
$id2 = $this->repository->nextIdentity();
$this->assertNotEquals($id1->value(), $id2->value());
}
}
Testing the presentation layer
Feature Test (over HTTP)
// tests/Feature/Http/Controllers/Api/OrderControllerTest.php
final class OrderControllerTest extends TestCase
{
use RefreshDatabase;
public function test_can_create_order(): void
{
$response = $this->postJson('/api/orders', [
'shipping' => [
'prefecture' => 'Tokyo',
'city' => 'Shibuya',
'street' => '1-1-1',
],
'items' => [
['productId' => 101, 'quantity' => 2, 'unitPrice' => 1000],
],
]);
$response->assertStatus(201)
->assertJsonStructure(['id', 'message']);
$this->assertDatabaseHas('orders', [
'shipping_prefecture' => 'Tokyo',
'status' => 'draft',
]);
}
public function test_validation_error(): void
{
$response = $this->postJson('/api/orders', [
'shipping' => [
'prefecture' => '', // a required field is empty
],
'items' => [], // an empty array
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['shipping.prefecture', 'items']);
}
public function test_can_confirm_order(): void
{
// Arrange: create the order and the stock
$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_confirming_nonexistent_order_returns_404(): void
{
$response = $this->postJson('/api/orders/999/confirm');
$response->assertStatus(404)
->assertJson(['code' => 'ORDER_NOT_FOUND']);
}
}
Testing best practices
1. The Arrange-Act-Assert pattern
public function test_example(): void
{
// Arrange: prepare the test data
$order = $this->createOrder();
// Act: run the code under test
$order->confirm();
// Assert: verify the result
$this->assertTrue($order->status()->isConfirmed());
}
2. Use descriptive test method names
Make the test method name describe the behavior it verifies, so anyone can grasp the intent at a glance.
// It is clear what is being tested
public function test_confirmed_order_can_be_cancelled(): void
public function test_shipped_order_cannot_be_cancelled(): void
public function test_cannot_confirm_order_with_empty_lines(): void
This book uses test_xxx() style names that describe the behavior in English. The point is not the specific naming convention but that the test name should clearly describe the behavior under test. Choose a naming convention that fits your team and keep it consistent.
3. Using the test data builder pattern
The test data builder pattern is a design pattern that makes generating test data concise.
Why a test data builder is needed
// BAD: repeat the same setup code in every test
public function test_can_confirm_order(): void
{
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
// test logic...
}
public function test_can_cancel_order(): void
{
$order = Order::create( // ← the same code is repeated
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(101),
2,
new Money(1000, 'JPY')
);
// test logic...
}
// GOOD: concise with the builder pattern
public function test_can_confirm_order(): void
{
$order = OrderBuilder::default()->build();
// test logic...
}
public function test_can_cancel_order(): void
{
$order = OrderBuilder::default()->build();
// test logic...
}
Benefits of a test data builder:
| Benefit | Description |
|---|---|
| DRY principle | Consolidate test-data generation logic in one place |
| Readability | The intent of the test becomes clear |
| Maintainability | When the domain model changes, you only fix the builder |
| Flexibility | Customize only the properties you need |
Implementation example
// tests/Support/OrderBuilder.php
final class OrderBuilder
{
private OrderId $id;
private ShippingAddress $shippingAddress;
private array $items = [];
public function __construct()
{
// Set default values
$this->id = new OrderId(1);
$this->shippingAddress = new ShippingAddress('Tokyo', 'Shibuya', '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;
}
}
Usage example
// Create with default values
$order = OrderBuilder::default()->build();
// Customize specific properties
$order = OrderBuilder::default()
->withId(123)
->withItem(101, 2, 1000)
->withItem(102, 1, 500)
->build();
// An alias method for a specific scenario
$order = OrderBuilder::default()
->withItem(101, 10, 100) // a large order
->build();
Summary
| Layer | Test type | Characteristic |
|---|---|---|
| Domain | Unit Test | No DB, fast, verifies business rules |
| Application | Unit Test + Mock | Mock the repository, verify the flow |
| Infrastructure | Integration Test | Uses a DB, verifies persistence |
| Presentation | Feature Test | Over HTTP, E2E-like verification |
Points of the testing strategy
| Point | Description |
|---|---|
| The test pyramid | The foundation is fast unit tests; the top is a few E2E tests |
| The domain layer has no DB | Achieve fast tests by making business logic independent of infrastructure |
| Minimal mocks | Mock only infrastructure dependencies; use the real thing for domain objects |
| Test data builder | Make test-data generation concise and improve maintainability |
| Arrange-Act-Assert | Improve readability by splitting tests into three sections |
Reference resources
This book's test code is written in PHPUnit style (extends TestCase + test_xxx() methods). From Laravel 11, the default testing framework switched to Pest, but since Pest also uses PHPUnit internally, this book's samples work in both environments.
- Laravel Testing - the Laravel official documentation
- PHPUnit Documentation - the PHPUnit official documentation
- Pest PHP - the default testing framework in Laravel 11+
- Martin Fowler "Test Pyramid" - the concept of the test pyramid
In the next chapter, we will learn about authentication and authorization design in DDD and clean architecture.