Testing aggregates

Event-sourced aggregates (see Aggregates) are pure domain objects. The spring-ddd-starter-eventsourcing-test starter ships an aggregate test fixture that drives them with no Spring context, no repository, and no database.

The fixture

given(history…).whenever { command }.expect… reconstructs the aggregate from a prior event history, runs a business method, and lets you assert both the events the command recorded and the resulting state.

import de.dwittkoetter.ddd.eventsourcing.test.aggregate.given
import io.kotest.matchers.shouldBe
import java.math.BigDecimal

class BankAccountTest {

    private val id = AccountId()

    @Test
    fun `deposit records the event and updates the balance`() {
        given<BankAccount>(AccountOpened(id, Money(BigDecimal("100.00"), "EUR"))) (1)
            .whenever { it.deposit(Money(BigDecimal("50.00"), "EUR")) }           (2)
            .expectEvents(MoneyDeposited(id, Money(BigDecimal("50.00"), "EUR")))  (3)
            .expectState { it.balance shouldBe Money(BigDecimal("150.00"), "EUR") } (4)
    }
}
1 Fold a prior event history to reach a starting state. Use given<BankAccount>() with no events to start fresh (e.g. for the opening command). Construction works even with a private constructor — the fixture instantiates by reflection, so tests never relax visibility.
2 Run the command. it is the aggregate.
3 Assert exactly which events the command recorded (equality, in order).
4 Assert final state. The recorded events are folded for you, so balance reflects the deposit — the aggregate behaves as if it had been saved.

Assertions

  • expectEvents(vararg) — recorded events equal the expected list, in order.

  • expectEventTypes(vararg KClass) — recorded event types, in order.

  • expectSingleEvent<T> { … } — exactly one event, of type T, satisfying the block.

  • expectNoEvents() — the command recorded nothing.

  • expectState { … } — assert on the aggregate after its events are folded.

  • expectFailure<E> { … } — the command threw E; assert on the throwable.

given<BankAccount>(AccountOpened(id, Money(BigDecimal("100.00"), "EUR")))
    .whenever { it.withdraw(Money(BigDecimal("500.00"), "EUR")) }
    .expectFailure<IllegalArgumentException> { it.message shouldContain "Insufficient" }
    .expectNoEvents()

Under the hood

A business method calls registerEvent(…​), which only records an event; the matching @EventSourcingHandler folds it into state when the aggregate is saved. The fixture replicates that: it captures the recorded events for expectEvents, then folds them so expectState sees the post-command state. This is why you assert events and state in a single, natural flow.