iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🧪

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)
GitHubで編集を提案

Discussion