iTranslated by AI
DDD Testing Strategy: How to Test Aggregates and Domain Services
Introduction
A codebase designed with DDD contains many components such as entities, value objects, aggregates, domain services, repositories, and use cases. Deciding at which level to test each element is a critical judgment directly linked to team productivity and quality.
In the test pyramid advocated by Mike Cohn, it is recommended to have the most unit tests, followed by fewer integration tests, and even fewer E2E tests (Reference: TestPyramid). However, how to map this pyramid to the layered structure of DDD is not self-evident.
In this article, we will explain the testing strategy from the domain layer to the infrastructure layer with Go code examples. E2E tests for the interface layer (such as HTTP handlers) are excluded from the scope of this article because they are heavily dependent on frameworks.
Mapping the Test Pyramid to DDD Layers
Let's organize the relationship between the DDD layered structure and the test pyramid.
| DDD Layer | Test Level | Test Target |
|---|---|---|
| Domain Layer | Unit Test | Aggregate behavior, value object constraints, domain service logic |
| Use Case Layer | Integration Test | Use case flow, collaboration of dependent components |
| Infrastructure Layer | Integration Test | Repository queries, communication with external services |
| Interface Layer | E2E Test / HTTP Test | API endpoint requests/responses |
Following the test pyramid approach, the domain layer, which has no external dependencies, is the most suitable target for unit testing and is considered to have high cost-effectiveness.
Unit Testing the Domain Layer
Among the components of the domain layer, this section deals with testing value objects and aggregates (entities). Each has a different testing perspective.
- Value Objects: Constraints upon creation and equality
- Aggregates: Behavior involving state transitions
Testing domain services involves determining the boundaries of mocks, so it will be explained independently in the next section.
Testing Value Objects
For value objects, we test constraints (validation) upon creation and equality. Because their structure is simple, they are the easiest elements to test.
// domain/model/email_test.go
package model_test
import (
"testing"
"example/domain/model"
)
func TestNewEmail(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{name: "Valid email address", input: "user@example.com", wantErr: false},
{name: "Empty string", input: "", wantErr: true},
{name: "No @ symbol", input: "userexample.com", wantErr: true},
{name: "No domain", input: "user@", wantErr: true},
{name: "Japanese domain", input: "user@例え.jp", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := model.NewEmail(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("NewEmail(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestEmail_Equals(t *testing.T) {
email1, _ := model.NewEmail("user@example.com")
email2, _ := model.NewEmail("user@example.com")
email3, _ := model.NewEmail("other@example.com")
if !email1.Equals(email2) {
t.Error("Emails with the same address should be equal")
}
if email1.Equals(email3) {
t.Error("Emails with different addresses should not be equal")
}
}
Testing Aggregate Behavior
What is important in testing aggregates is to test behavior, not state. Instead of verifying internal fields directly, we test changes observable from the outside as a result of executing a command.
// domain/model/order_test.go
package model_test
import (
"testing"
"example/domain/model"
)
func TestOrder_Confirm(t *testing.T) {
t.Run("Can confirm a draft order", func(t *testing.T) {
order := newTestOrder(t)
err := order.Confirm()
if err != nil {
t.Fatalf("Confirm() failed: %v", err)
}
if order.Status() != model.OrderStatusConfirmed {
t.Errorf("Status became %q. Expected %q",
order.Status(), model.OrderStatusConfirmed)
}
})
t.Run("Cannot confirm an already confirmed order", func(t *testing.T) {
order := newTestOrder(t)
order.Confirm()
err := order.Confirm()
if err == nil {
t.Error("Confirm() for a confirmed order should return an error")
}
})
t.Run("Cannot confirm a cancelled order", func(t *testing.T) {
order := newTestOrder(t)
order.Cancel("test cancellation")
err := order.Confirm()
if err == nil {
t.Error("Confirm() for a cancelled order should return an error")
}
})
}
func TestOrder_Ship(t *testing.T) {
t.Run("Can ship a confirmed order", func(t *testing.T) {
order := newTestOrder(t)
order.Confirm()
err := order.Ship("TRACK-001")
if err != nil {
t.Fatalf("Ship() failed: %v", err)
}
if order.Status() != model.OrderStatusShipped {
t.Errorf("Status became %q. Expected %q",
order.Status(), model.OrderStatusShipped)
}
})
t.Run("Cannot ship a draft order", func(t *testing.T) {
order := newTestOrder(t)
err := order.Ship("TRACK-001")
if err == nil {
t.Error("Ship() for a draft order should return an error")
}
})
}
// newTestOrder is a helper to create an order for testing.
func newTestOrder(t *testing.T) *model.Order {
t.Helper()
order, err := model.NewOrder("order-1", "customer-1", []model.OrderItem{
{ProductID: "product-1", Quantity: 2, Price: 1000},
})
if err != nil {
t.Fatalf("Failed to create test order: %v", err)
}
return order
}
Table-Driven Testing for State Transitions
When aggregate state transitions are complex, use table-driven tests to cover representative patterns.
func TestOrder_StateTransitions(t *testing.T) {
type action func(*model.Order) error
confirm := func(o *model.Order) error { return o.Confirm() }
ship := func(o *model.Order) error { return o.Ship("TRACK-001") }
cancel := func(o *model.Order) error { return o.Cancel("test") }
tests := []struct {
name string
setup []action // Actions to run beforehand
action action // Action under test
wantErr bool
wantStatus model.OrderStatus
}{
{"Draft→Confirmed", nil, confirm, false, model.OrderStatusConfirmed},
{"Draft→Shipped", nil, ship, true, ""},
{"Draft→Cancelled", nil, cancel, false, model.OrderStatusCancelled},
{"Confirmed→Shipped", []action{confirm}, ship, false, model.OrderStatusShipped},
{"Confirmed→Cancelled", []action{confirm}, cancel, false, model.OrderStatusCancelled},
{"Shipped→Cancelled", []action{confirm, ship}, cancel, true, ""},
{"Cancelled→Confirmed", []action{cancel}, confirm, true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
order := newTestOrder(t)
for _, a := range tt.setup {
if err := a(order); err != nil {
t.Fatalf("Error during setup action: %v", err)
}
}
err := tt.action(order)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && order.Status() != tt.wantStatus {
t.Errorf("status = %q, want %q", order.Status(), tt.wantStatus)
}
})
}
}
Mapping this to a state transition diagram makes it easier to discover missing test cases.
Testing Domain Services
Identifying Mock Boundaries
When testing domain services, you must carefully determine the scope of mock usage. The basic guidelines are:
- Inside the domain layer (value objects, entities): Do not mock. Use the actual objects.
- Repositories: Mock. The target of the test is domain logic, not data access.
- External service ports: Mock. This is for the stability and speed of the tests.
// domain/service/pricing_service_test.go
package service_test
import (
"context"
"testing"
"example/domain/model"
"example/domain/service"
)
// discountRuleFinder is a stub for testing.
type discountRuleFinder struct {
rules []model.DiscountRule
}
func (f *discountRuleFinder) FindApplicableRules(
ctx context.Context,
customerID string,
) ([]model.DiscountRule, error) {
return f.rules, nil
}
func TestPricingService_CalculateTotal(t *testing.T) {
t.Run("Discount rule is applied", func(t *testing.T) {
finder := &discountRuleFinder{
rules: []model.DiscountRule{
model.NewPercentageDiscount("Member Discount", 10),
},
}
svc := service.NewPricingService(finder)
items := []model.OrderItem{
{ProductID: "p1", Quantity: 1, Price: 10000},
}
total, err := svc.CalculateTotal(context.Background(), "customer-1", items)
if err != nil {
t.Fatalf("CalculateTotal() error = %v", err)
}
// 10% discount on 10,000 yen = 9,000 yen
if total != 9000 {
t.Errorf("total = %d, want 9000", total)
}
})
t.Run("List price if no discount rule exists", func(t *testing.T) {
finder := &discountRuleFinder{rules: nil}
svc := service.NewPricingService(finder)
items := []model.OrderItem{
{ProductID: "p1", Quantity: 2, Price: 500},
}
total, err := svc.CalculateTotal(context.Background(), "customer-1", items)
if err != nil {
t.Fatalf("CalculateTotal() error = %v", err)
}
if total != 1000 {
t.Errorf("total = %d, want 1000", total)
}
})
}
Why Not Use Mock Libraries?
As introduced in Mitchell Hashimoto's talk Advanced Testing with Go, the Go standard library does not use mock libraries. Instead, stub structs are defined within test files. This article adopts the same style for the following reasons:
- Go interfaces are satisfied implicitly, making it easy to create stubs for testing.
- If an interface is small (1-2 methods), the overhead of mock generation tools outweighs the benefits.
- Test code is self-contained, allowing dependencies to be understood just by looking at the test file.
However, if the interface has many methods or if verification of the number of calls is necessary, using a mock library is also a rational choice.
Testing the Use Case Layer
Use Case Testing Strategy
Testing the use case layer is a type of component testing where repositories and event buses are replaced with test doubles to verify the behavior of the use case. Domain objects are used as-is, and the tests do not depend on infrastructure implementations.
// usecase/confirm_order_test.go
package usecase_test
import (
"context"
"fmt"
"testing"
"example/domain/event"
"example/domain/model"
"example/usecase"
)
type inMemoryOrderRepo struct {
orders map[string]*model.Order
}
func newInMemoryOrderRepo() *inMemoryOrderRepo {
return &inMemoryOrderRepo{orders: make(map[string]*model.Order)}
}
func (r *inMemoryOrderRepo) FindByID(ctx context.Context, id string) (*model.Order, error) {
order, ok := r.orders[id]
if !ok {
return nil, fmt.Errorf("Order not found: %s", id)
}
return order, nil
}
func (r *inMemoryOrderRepo) Save(ctx context.Context, order *model.Order) error {
r.orders[order.ID()] = order
return nil
}
type spyEventBus struct {
published []event.Event
}
func (b *spyEventBus) Publish(events ...event.Event) error {
b.published = append(b.published, events...)
return nil
}
func (b *spyEventBus) Subscribe(string, event.Handler) {}
func TestConfirmOrderUseCase(t *testing.T) {
t.Run("Confirm order and publish event", func(t *testing.T) {
repo := newInMemoryOrderRepo()
bus := &spyEventBus{}
order, err := model.NewOrder("order-1", "customer-1", []model.OrderItem{
{ProductID: "p1", Quantity: 1, Price: 1000},
})
if err != nil {
t.Fatalf("Failed to create test order: %v", err)
}
repo.orders["order-1"] = order
uc := usecase.NewConfirmOrderUseCase(repo, bus)
err = uc.Execute(context.Background(), "order-1")
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if repo.orders["order-1"].Status() != model.OrderStatusConfirmed {
t.Error("Order status is not confirmed")
}
if len(bus.published) == 0 {
t.Error("Domain event not published")
}
})
t.Run("Error for non-existent order", func(t *testing.T) {
repo := newInMemoryOrderRepo()
bus := &spyEventBus{}
uc := usecase.NewConfirmOrderUseCase(repo, bus)
err := uc.Execute(context.Background(), "not-found")
if err == nil {
t.Error("Execute() should return an error for non-existent order")
}
})
}
Types of Test Doubles
| Type | Usage | Example |
|---|---|---|
| Stub | Returns fixed values | discountRuleFinder |
| Spy | Records calls | spyEventBus |
| Fake | Provides a simple implementation | inMemoryOrderRepo |
| Mock | Verifies expected calls | gomock-generated mocks |
In my experience, stubs, fakes, and spies are sufficient for most DDD testing. Mock verification (e.g., verifying how many times a method was called) depends on implementation details, so excessive use can reduce refactoring resilience.
Testing the Infrastructure Layer
Integration Tests for Repositories
Repository implementations are tested against an actual database. In Go, it is common to set up the database in TestMain within the testing package.
// infrastructure/postgres/order_repository_test.go
package postgres_test
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"example/domain/model"
"example/infrastructure/postgres"
)
var testDB *sql.DB
func TestMain(m *testing.M) {
var err error
testDB, err = sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
panic(err)
}
if err := testDB.Ping(); err != nil {
panic(fmt.Sprintf("Cannot connect to database: %v", err))
}
code := m.Run()
testDB.Close()
os.Exit(code)
}
func TestOrderRepository_SaveAndFindByID(t *testing.T) {
repo := postgres.NewOrderRepository(testDB)
ctx := context.Background()
// Register cleanup first (executed even if Save fails)
t.Cleanup(func() {
testDB.Exec("DELETE FROM orders WHERE id = $1", "test-order-1")
})
order, err := model.NewOrder("test-order-1", "customer-1", []model.OrderItem{
{ProductID: "p1", Quantity: 2, Price: 1000},
})
if err != nil {
t.Fatalf("Failed to create test order: %v", err)
}
// Save
if err := repo.Save(ctx, order); err != nil {
t.Fatalf("Save() error = %v", err)
}
// FindByID
found, err := repo.FindByID(ctx, "test-order-1")
if err != nil {
t.Fatalf("FindByID() error = %v", err)
}
if found.ID() != order.ID() {
t.Errorf("ID = %q, want %q", found.ID(), order.ID())
}
if found.Status() != order.Status() {
t.Errorf("Status = %q, want %q", found.Status(), order.Status())
}
}
A separate database with the same schema as production is used for testing. By using testcontainers-go, you can start a temporary PostgreSQL instance in a Docker container during test execution.
Best Practices for Test Design
1. Test Naming Convention
Follow the naming conventions widely used in the Go community. Use the format Test{FunctionName}_{Scenario} and describe concrete scenarios in English.
func TestOrder_Confirm(t *testing.T) {
t.Run("Can confirm draft order", func(t *testing.T) { ... })
t.Run("Cannot confirm already confirmed order", func(t *testing.T) { ... })
}
2. Arrange-Act-Assert Pattern
Standardize test structure into three phases: Arrange, Act, and Assert. Using blank lines to separate them makes each phase immediately recognizable.
All code examples in this article follow this pattern. For instance, in TestOrder_Confirm, newTestOrder(t) is the Arrange phase, order.Confirm() is the Act phase, and the order.Status() verification corresponds to Assert.
3. Attach t.Helper() to Test Helpers
func newTestOrder(t *testing.T) *model.Order {
t.Helper() // Skips this function in the stack trace during errors
order, err := model.NewOrder("order-1", "customer-1", []model.OrderItem{
{ProductID: "product-1", Quantity: 2, Price: 1000},
})
if err != nil {
t.Fatalf("Failed to create test order: %v", err)
}
return order
}
4. Separate Test Execution Strategies
Unit tests for the domain layer can be executed rapidly via go test ./domain/..., so run them frequently during local development. Conversely, integration tests for the infrastructure layer requiring a database should be separated using build tags like //go:build integration and executed only in the CI environment.
Summary
The key to DDD test strategy is selecting the appropriate test level for each layer.
- The domain layer is the main battlefield for unit tests. You can write fast, stable tests without external dependencies.
- Aggregate testing focuses on behavior. Instead of internal state, verify observable changes resulting from commands.
- State transition testing is covered by table-driven tests. Mapping them to state transition diagrams helps prevent omissions.
- Minimize mocks for domain services. Mock only repositories and external service ports; use real domain objects.
- Stubs, fakes, and spies are sufficient for most cases in Go. Consider introducing mock libraries only when interfaces are large.
Design that is easy to test is also an indicator of good DDD design. If you feel that tests are difficult to write, it is a signal to revisit your aggregate boundaries or interface design.
References
| Content | Source |
|---|---|
| Test Pyramid | Mike Cohn, Succeeding with Agile (2009); Martin Fowler, TestPyramid |
| Original DDD Book | Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) |
| IDDD Testing Strategy | Vaughn Vernon, Implementing Domain-Driven Design (2013) |
| Go Testing Patterns | Mitchell Hashimoto, Advanced Testing with Go |
| Test Double Classification | Gerard Meszaros, xUnit Test Patterns (2007) |
| TDD and DDD | Kent Beck, Test Driven Development: By Example (2002) |
Discussion