Skip to main content

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

  1. The foundation is fast unit tests: the most numerous and the fastest to run
  2. The middle is integration tests: test integration with a DB and external services
  3. The top is a few E2E tests: test only important user flows
Why is this shape ideal?
ReasonDescription
Fast feedbackUnit tests complete in seconds and can be run many times during development
Early detection of problemsDetect domain-logic mistakes while coding
MaintainabilityUnit tests are simple and resilient to change
ReliabilityCombining 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 typeTarget layerCharacteristicExecution timeCount
Unit TestDomain, ApplicationNo DB, fast, many0.1s/testHundreds to thousands
Integration TestInfrastructureUses a DB, moderate1s/testTens to hundreds
Feature TestPresentationOver HTTP, E2E-like3s/testA 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:

BenefitDescription
Fast executionRun hundreds of tests in seconds without DB access
High stabilityTests are hard to break because they do not depend on DB state
Development efficiencyYou can design domain logic while writing tests (TDD)
RefactoringGet immediate feedback when changing business logic
If domain-layer tests are slow, revisit the design

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:

BenefitDescription
FastTests are fast because there is no DB access
IndependenceTest without depending on the infrastructure implementation
Behavior verificationVerify "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);
Keep mocks to a minimum
CriterionUse a mockUse 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
A note on test method naming

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:

BenefitDescription
DRY principleConsolidate test-data generation logic in one place
ReadabilityThe intent of the test becomes clear
MaintainabilityWhen the domain model changes, you only fix the builder
FlexibilityCustomize 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

LayerTest typeCharacteristic
DomainUnit TestNo DB, fast, verifies business rules
ApplicationUnit Test + MockMock the repository, verify the flow
InfrastructureIntegration TestUses a DB, verifies persistence
PresentationFeature TestOver HTTP, E2E-like verification

Points of the testing strategy

PointDescription
The test pyramidThe foundation is fast unit tests; the top is a few E2E tests
The domain layer has no DBAchieve fast tests by making business logic independent of infrastructure
Minimal mocksMock only infrastructure dependencies; use the real thing for domain objects
Test data builderMake test-data generation concise and improve maintainability
Arrange-Act-AssertImprove 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.

In the next chapter, we will learn about authentication and authorization design in DDD and clean architecture.