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 typeT, satisfying the block. -
expectNoEvents()— the command recorded nothing. -
expectState { … }— assert on the aggregate after its events are folded. -
expectFailure<E> { … }— the command threwE; 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.