Skip to main content

Testing DDD Code: Strategies and Patterns

Testing DDD code requires different approaches than testing typical CRUD applications. Domain logic is concentrated in aggregates, and tests should reflect the ubiquitous language.

TL;DR

Test TypeWhat to TestTools
Unit TestsAggregates, value objects, domain servicesxUnit, NUnit
Behavior TestsUse cases, scenariosSpecFlow, xBehave
Integration TestsRepositories, event handlersTestContainers
Contract TestsAPI contracts between servicesPact

Testing Aggregates

Given-When-Then Pattern

public class OrderTests
{
[Fact]
public void Placing_order_changes_status_to_placed()
{
// Given
var order = Order.Create(
CustomerId.New(),
TestAddresses.DefaultShipping);
order.AddLine(ProductId.New(), quantity: 2, new Money(50, Currency.USD));

// When
order.Place();

// Then
Assert.Equal(OrderStatus.Placed, order.Status);
}

[Fact]
public void Cannot_place_empty_order()
{
// Given
var order = Order.Create(
CustomerId.New(),
TestAddresses.DefaultShipping);
// No lines added

// When / Then
Assert.Throws<CannotPlaceEmptyOrderException>(() => order.Place());
}

[Fact]
public void Cannot_add_lines_to_shipped_order()
{
// Given
var order = CreateShippedOrder();

// When / Then
Assert.Throws<InvalidOrderStateException>(() =>
order.AddLine(ProductId.New(), 1, new Money(10, Currency.USD)));
}

[Fact]
public void Placing_order_emits_OrderPlaced_event()
{
// Given
var order = CreateDraftOrderWithLines();

// When
order.Place();

// Then
var @event = order.DomainEvents.OfType<OrderPlaced>().Single();
Assert.Equal(order.Id, @event.OrderId);
Assert.Equal(order.Total, @event.Total);
}
}

Test Builders

public class OrderBuilder
{
private CustomerId _customerId = CustomerId.New();
private Address _shippingAddress = TestAddresses.Default;
private readonly List<(ProductId, int, Money)> _lines = new();
private OrderStatus? _targetStatus;

public OrderBuilder WithCustomer(CustomerId customerId)
{
_customerId = customerId;
return this;
}

public OrderBuilder WithLine(ProductId productId, int quantity, Money price)
{
_lines.Add((productId, quantity, price));
return this;
}

public OrderBuilder WithDefaultLine()
{
_lines.Add((ProductId.New(), 1, new Money(100, Currency.USD)));
return this;
}

public OrderBuilder InStatus(OrderStatus status)
{
_targetStatus = status;
return this;
}

public Order Build()
{
var order = Order.Create(_customerId, _shippingAddress);

foreach (var (productId, quantity, price) in _lines)
{
order.AddLine(productId, quantity, price);
}

if (_targetStatus == OrderStatus.Placed)
{
if (!_lines.Any()) WithDefaultLine();
order.Place();
}
else if (_targetStatus == OrderStatus.Shipped)
{
if (!_lines.Any()) WithDefaultLine();
order.Place();
order.Ship(new TrackingNumber("TRACK123"));
}

order.ClearDomainEvents(); // Clear events from setup
return order;
}
}

// Usage
[Fact]
public void Shipped_order_cannot_be_cancelled()
{
var order = new OrderBuilder()
.WithDefaultLine()
.InStatus(OrderStatus.Shipped)
.Build();

Assert.Throws<CannotCancelShippedOrderException>(() =>
order.Cancel("Changed mind"));
}

Testing Value Objects

public class MoneyTests
{
[Fact]
public void Equal_amounts_are_equal()
{
var money1 = new Money(100, Currency.USD);
var money2 = new Money(100, Currency.USD);

Assert.Equal(money1, money2);
}

[Fact]
public void Different_currencies_are_not_equal()
{
var usd = new Money(100, Currency.USD);
var eur = new Money(100, Currency.EUR);

Assert.NotEqual(usd, eur);
}

[Fact]
public void Can_add_same_currency()
{
var a = new Money(100, Currency.USD);
var b = new Money(50, Currency.USD);

var result = a.Add(b);

Assert.Equal(new Money(150, Currency.USD), result);
}

[Fact]
public void Cannot_add_different_currencies()
{
var usd = new Money(100, Currency.USD);
var eur = new Money(50, Currency.EUR);

Assert.Throws<CurrencyMismatchException>(() => usd.Add(eur));
}

[Theory]
[InlineData(-1)]
[InlineData(-100)]
public void Cannot_create_negative_amount(decimal amount)
{
Assert.Throws<ArgumentException>(() => new Money(amount, Currency.USD));
}
}

public class EmailAddressTests
{
[Theory]
[InlineData("test@example.com")]
[InlineData("user.name@domain.co.uk")]
[InlineData("user+tag@example.org")]
public void Valid_emails_are_accepted(string email)
{
var emailAddress = new EmailAddress(email);
Assert.Equal(email.ToLower(), emailAddress.Value);
}

[Theory]
[InlineData("")]
[InlineData("invalid")]
[InlineData("@example.com")]
[InlineData("test@")]
public void Invalid_emails_are_rejected(string email)
{
Assert.Throws<ArgumentException>(() => new EmailAddress(email));
}
}

Testing Domain Services

public class PricingServiceTests
{
private readonly PricingService _sut;

public PricingServiceTests()
{
var rules = new List<IPricingRule>
{
new VolumeDiscountRule(),
new LoyaltyDiscountRule()
};
_sut = new PricingService(rules);
}

[Fact]
public void Base_price_returned_for_single_item()
{
var product = new Product(ProductId.New(), new Money(100, Currency.USD));
var customer = new Customer(CustomerId.New(), LoyaltyTier.Bronze);

var price = _sut.CalculatePrice(product, customer, quantity: 1);

Assert.Equal(new Money(100, Currency.USD), price);
}

[Theory]
[InlineData(10, 90)] // 10% discount
[InlineData(50, 85)] // 15% discount
[InlineData(100, 80)] // 20% discount
public void Volume_discount_applied_correctly(int quantity, decimal expectedPerUnit)
{
var product = new Product(ProductId.New(), new Money(100, Currency.USD));
var customer = new Customer(CustomerId.New(), LoyaltyTier.Bronze);

var price = _sut.CalculatePrice(product, customer, quantity);

var expectedTotal = expectedPerUnit * quantity;
Assert.Equal(new Money(expectedTotal, Currency.USD), price);
}

[Fact]
public void Gold_customer_gets_additional_discount()
{
var product = new Product(ProductId.New(), new Money(100, Currency.USD));
var customer = new Customer(CustomerId.New(), LoyaltyTier.Gold);

var price = _sut.CalculatePrice(product, customer, quantity: 1);

// 5% loyalty discount
Assert.Equal(new Money(95, Currency.USD), price);
}
}

Testing Application Services

public class OrderApplicationServiceTests
{
private readonly InMemoryOrderRepository _orderRepository;
private readonly Mock<IPricingService> _pricingService;
private readonly OrderApplicationService _sut;

public OrderApplicationServiceTests()
{
_orderRepository = new InMemoryOrderRepository();
_pricingService = new Mock<IPricingService>();
_sut = new OrderApplicationService(_orderRepository, _pricingService.Object);
}

[Fact]
public async Task PlaceOrder_creates_and_saves_order()
{
// Arrange
var customerId = CustomerId.New();
var productId = ProductId.New();
_pricingService
.Setup(p => p.GetPrice(productId, It.IsAny<Customer>(), It.IsAny<int>()))
.ReturnsAsync(new Money(100, Currency.USD));

var command = new PlaceOrderCommand(
customerId,
TestAddresses.Default,
new[] { new OrderItemDto(productId, 2) });

// Act
var orderId = await _sut.PlaceOrder(command);

// Assert
var savedOrder = await _orderRepository.GetById(orderId);
Assert.NotNull(savedOrder);
Assert.Equal(OrderStatus.Placed, savedOrder.Status);
Assert.Single(savedOrder.Lines);
}

[Fact]
public async Task PlaceOrder_fails_for_invalid_customer()
{
var command = new PlaceOrderCommand(
CustomerId.New(), // Non-existent customer
TestAddresses.Default,
new[] { new OrderItemDto(ProductId.New(), 1) });

await Assert.ThrowsAsync<CustomerNotFoundException>(() =>
_sut.PlaceOrder(command));
}
}

Behavior-Driven Tests (BDD)

Using SpecFlow or similar:

Feature: Order Placement
As a customer
I want to place orders
So that I can purchase products

Scenario: Successfully place an order
Given I am a registered customer
And I have items in my cart
When I place my order
Then the order should be created with status "Placed"
And I should receive an order confirmation

Scenario: Cannot place empty order
Given I am a registered customer
And my cart is empty
When I try to place my order
Then I should see an error "Cannot place empty order"
And no order should be created

Scenario: Order total includes volume discount
Given I am a registered customer
And I have 10 units of "Widget" priced at $100 each
When I place my order
Then the order total should be $900
And the discount applied should be $100
[Binding]
public class OrderPlacementSteps
{
private readonly OrderApplicationService _orderService;
private CustomerId _customerId;
private List<OrderItemDto> _cartItems = new();
private OrderId? _orderId;
private Exception? _exception;

[Given(@"I am a registered customer")]
public void GivenIAmARegisteredCustomer()
{
_customerId = CustomerId.New();
// Setup customer in test database
}

[Given(@"I have items in my cart")]
public void GivenIHaveItemsInMyCart()
{
_cartItems.Add(new OrderItemDto(ProductId.New(), 1));
}

[Given(@"my cart is empty")]
public void GivenMyCartIsEmpty()
{
_cartItems.Clear();
}

[When(@"I place my order")]
public async Task WhenIPlaceMyOrder()
{
try
{
var command = new PlaceOrderCommand(
_customerId,
TestAddresses.Default,
_cartItems);
_orderId = await _orderService.PlaceOrder(command);
}
catch (Exception ex)
{
_exception = ex;
}
}

[Then(@"the order should be created with status ""(.*)""")]
public async Task ThenTheOrderShouldBeCreatedWithStatus(string status)
{
var order = await _orderRepository.GetById(_orderId!);
Assert.Equal(Enum.Parse<OrderStatus>(status), order.Status);
}
}

Testing Event-Sourced Aggregates

public class EventSourcedOrderTests
{
[Fact]
public void Creating_order_produces_OrderCreated_event()
{
// When
var order = Order.Create(
CustomerId.New(),
TestAddresses.Default);

// Then
var @event = order.UncommittedEvents.Single();
Assert.IsType<OrderCreated>(@event);
}

[Fact]
public void Order_can_be_reconstituted_from_events()
{
// Given
var events = new IDomainEvent[]
{
new OrderCreated(
Guid.NewGuid(),
Guid.NewGuid(),
TestAddresses.Default,
DateTime.UtcNow),
new OrderLineAdded(
Guid.NewGuid(),
Guid.NewGuid(),
2,
100m,
"USD"),
new OrderPlaced(
Guid.NewGuid(),
Guid.NewGuid(),
200m,
"USD",
DateTime.UtcNow)
};

// When
var order = new Order();
order.LoadFromHistory(events);

// Then
Assert.Equal(OrderStatus.Placed, order.Status);
Assert.Single(order.Lines);
Assert.Equal(new Money(200, Currency.USD), order.Total);
}

[Fact]
public void Given_When_Then_with_events()
{
// Given these events happened
var order = Given(
new OrderCreated(...),
new OrderLineAdded(...));

// When this command is executed
order.Place();

// Then these events are produced
ThenExpect<OrderPlaced>(order, e =>
{
Assert.Equal(order.Id, e.OrderId);
});
}
}

// Helper for event-sourced testing
public static class EventSourcedTestHelpers
{
public static T Given<T>(params IDomainEvent[] events) where T : EventSourcedAggregate, new()
{
var aggregate = new T();
aggregate.LoadFromHistory(events);
aggregate.ClearUncommittedEvents();
return aggregate;
}

public static void ThenExpect<TEvent>(
EventSourcedAggregate aggregate,
Action<TEvent>? assertions = null) where TEvent : IDomainEvent
{
var @event = aggregate.UncommittedEvents.OfType<TEvent>().SingleOrDefault();
Assert.NotNull(@event);
assertions?.Invoke(@event);
}
}

Integration Tests

public class OrderRepositoryIntegrationTests : IClassFixture<DatabaseFixture>
{
private readonly AppDbContext _context;
private readonly OrderRepository _repository;

public OrderRepositoryIntegrationTests(DatabaseFixture fixture)
{
_context = fixture.CreateContext();
_repository = new OrderRepository(_context);
}

[Fact]
public async Task Can_save_and_retrieve_order()
{
// Arrange
var order = new OrderBuilder()
.WithDefaultLine()
.InStatus(OrderStatus.Placed)
.Build();

// Act
await _repository.Save(order);
var retrieved = await _repository.GetById(order.Id);

// Assert
Assert.NotNull(retrieved);
Assert.Equal(order.Id, retrieved.Id);
Assert.Equal(order.Status, retrieved.Status);
Assert.Equal(order.Lines.Count, retrieved.Lines.Count);
}

[Fact]
public async Task Optimistic_concurrency_prevents_lost_updates()
{
// Arrange
var order = new OrderBuilder().WithDefaultLine().Build();
await _repository.Save(order);

// Simulate concurrent access
var order1 = await _repository.GetById(order.Id);
var order2 = await _repository.GetById(order.Id);

// Act
order1.Place();
await _repository.Save(order1); // Succeeds

order2.Place();

// Assert
await Assert.ThrowsAsync<ConcurrencyException>(() =>
_repository.Save(order2)); // Fails - version mismatch
}
}

Test Organization

tests/
├── Domain.UnitTests/
│ ├── Orders/
│ │ ├── OrderTests.cs
│ │ ├── OrderLineTests.cs
│ │ └── OrderBuilderTests.cs
│ ├── ValueObjects/
│ │ ├── MoneyTests.cs
│ │ └── EmailAddressTests.cs
│ └── Services/
│ └── PricingServiceTests.cs
├── Application.UnitTests/
│ └── Orders/
│ └── PlaceOrderHandlerTests.cs
├── Infrastructure.IntegrationTests/
│ └── Repositories/
│ └── OrderRepositoryTests.cs
└── Acceptance.Tests/
└── Features/
└── OrderPlacement.feature

Quick Reference Card

┌─────────────────────────────────────────────────────────┐
│ TESTING DDD CODE QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ AGGREGATE TESTS │
│ • Given-When-Then pattern │
│ • Test state changes │
│ • Test domain events emitted │
│ • Test invariant enforcement │
│ • Use builders for setup │
│ │
│ VALUE OBJECT TESTS │
│ • Test equality │
│ • Test validation │
│ • Test immutability │
│ • Test behavior methods │
│ │
│ APPLICATION SERVICE TESTS │
│ • Mock repositories │
│ • Test orchestration │
│ • Test error handling │
│ │
│ INTEGRATION TESTS │
│ • Real database │
│ • Repository operations │
│ • Concurrency scenarios │
│ │
└─────────────────────────────────────────────────────────┘

Next Steps