Waiting for projections
A projection applies an event after the transaction that recorded it has committed, on its consumer’s own thread.
A test that sends a command and then reads a read model therefore has to wait until the projection has applied the command’s events.
ProjectionAwait waits exactly that long: it returns once the projection has applied everything committed before the call, and then lets the test assert once on the projection’s read models, which have settled.
The test needs no fixed sleep, no polling loop and no query bean of its own.
testImplementation("de.dwittkoetter:spring-ddd-starter-test:0.0.1-SNAPSHOT")
spring-ddd-starter-test brings spring-ddd-cqrs-test, which holds ProjectionAwait.
It adds test classes only: CQRS comes from the application’s own dependencies.
ProjectionAwait, ReadModels and the top-level function projectionAwait are in de.dwittkoetter.ddd.cqrs.test.projection; @EnableProjectionAwait is in de.dwittkoetter.ddd.cqrs.test.annotation.
JUnit
Annotate a Spring Boot test class with @EnableProjectionAwait and take a ProjectionAwait test-method parameter:
@SpringBootTest
@EnableProjectionAwait
class AccountSummaryTest(@Autowired val commands: CommandGateway) {
@Test
fun `a withdrawal lowers the balance`(projections: ProjectionAwait) {
commands.send(DepositMoney("A-1", 100))
commands.send(WithdrawMoney("A-1", 30))
projections.await<AccountSummaryProjection> { (1)
find<AccountSummary>("A-1")!!.balance shouldBe 70 (2)
}
}
}
| 1 | Waits until AccountSummaryProjection has applied both commands' events. |
| 2 | The verify block runs once, after the wait, with the projection’s read models as its receiver. |
The class must run under Spring’s JUnit extension, as @SpringBootTest does: the parameter is built from the test’s application context.
In Java the projection is named by its class, and the verify block is a Consumer<ReadModels>:
@SpringBootTest
@EnableProjectionAwait
class AccountSummaryTest {
@Autowired
CommandGateway commands;
@Test
void aWithdrawalLowersTheBalance(ProjectionAwait projections) {
commands.send(new DepositMoney("A-1", 100));
commands.send(new WithdrawMoney("A-1", 30));
projections.await(AccountSummaryProjection.class, readModels ->
assertEquals(70, readModels.find("A-1", AccountSummary.class).getBalance()));
}
}
Kotest and other test frameworks
Where there is no JUnit parameter to take, build the fixture with the top-level function projectionAwait from the application context:
val projections = projectionAwait(applicationContext)
The verify block
The block runs exactly once, after the wait, and is not retried until it passes. A retried assertion cannot tell "not applied yet" from "applied": an assertion that something did not change, such as "a rejected withdrawal leaves the balance at 150", would pass before the projection saw the rejection, and an intermediate state could match by accident. Waiting first means the block sees a read model that has applied everything committed before the call.
A failing block fails the test with its own assertion error, so the IDE still shows its expected and actual values. A suppressed exception is added to it, saying how far the projection had applied when the block ran.
await<AccountSummaryProjection>() without a block only waits, for a test that checks several read models afterwards.
Reading the read models
Every await returns the projection’s ReadModels, the same object the verify block receives:
val summaries = projections.await<AccountSummaryProjection>()
summaries.find<AccountSummary>("A-1")!!.balance shouldBe 70
summaries.find<AccountSummary>("A-2") shouldBe null (1)
summaries.count() shouldBe 1 (2)
| 1 | find returns null when the projection holds no read model of that key, so a deletion is checked this way. |
| 2 | The store’s own count of read models, or null when the store cannot count them. |
find takes the key value as the projection’s events carry it: a string, a number, an enum, or a single-value wrapper such as AccountId.
It reads through the projection’s own store.
The verify block runs inside one read-only transaction of that store, and every find in it joins that transaction.
Read a JPA read model’s lazy associations inside the block.
A find on the ReadModels that await returns, outside the block, opens a read-only transaction of its own and returns a detached read model, whose lazy associations can no longer be loaded.
ReadModels reads the store as it is when it reads.
Only the wait makes the read models settled: a read long after the wait sees whatever was committed since.
These calls fail:
-
find<AccountReport>(…)on a projection whose read model isAccountSummary:IllegalArgumentException, "AccountSummaryProjection’s read model is AccountSummary, not AccountReport"; -
a key value that does not reduce to a key, such as an object with several properties:
IllegalArgumentException, naming the type the read models are keyed by; -
any read of a projection without a read model, one with no listeners and no
@InitReadModel:IllegalStateException, "AccountSummaryProjection has no read model: it has no listeners and no @InitReadModel". The projection can still be awaited.
Several commands, one wait
The wait covers everything committed before the call, so a test that sends several commands waits once, after the last one. Events published after the call may or may not be covered.
Timeout
A wait gives up after 10 seconds by default. Pass another timeout per call:
projections.await<AccountSummaryProjection>(timeout = 10.seconds) { … }
In Java pass a java.time.Duration:
projections.await(AccountSummaryProjection.class, Duration.ofSeconds(10), readModels -> { … });
At the deadline the wait fails with an AssertionError that names the projection and its class and says what it is still waiting for: a rebuild that has not finished, undelivered messages still being retried, dead letters its error handler has not acted on yet, or messages it has not applied yet.
When a message it waits for is not readable yet, the error says so: a write is still in flight in another transaction, or no instance runs with spring.ddd.messaging.high-water-mark.enabled=true.
Projections that listen to other projections
Waiting for a projection that listens to another projection’s read models also waits for that projection first, and for everything between them, however long the chain is. Waiting for the last projection of a chain is enough.
A projection that reads from a cycle of projections, which a @ReadModelKey mapping allows, cannot be waited for: the call is refused with an IllegalArgumentException naming the cycle.
What fails at once
The wait does not run into its timeout when the projection can never catch up.
It fails at once, with an AssertionError, when the projection:
-
is halted, for example on a message its error handler halts on:
projection 'account-summary' (AccountSummaryProjection) is halted: APPLY_ERROR; -
is paused:
… is paused; -
holds a dead letter,
DEAD_LETTER_KEYorDEAD_LETTER, which waits for an operator:… has 1 dead letter. A key’s later messages waiting behind its firstDEAD_LETTER_KEYare not counted on their own: the wait goes on while the first is retried, and they are applied in order behind it.
It keeps waiting while the projection is rebuilt, until the rebuild has replayed it and deleted the read models the replay did not re-create, and while an undelivered message is still being retried.
Only the projection waited for, and the projections it reads from, can fail the wait: a halted projection elsewhere in the application does not.
Refused calls
These calls fail before any waiting:
-
a class that is not a projection:
IllegalArgumentException; -
an ad-hoc-only projection (
@AdHocProjection), which no consumer runs:IllegalArgumentException; -
a call inside a transaction:
IllegalStateException. The writes before the call are not committed yet, so the projection can never see them. A@Transactionaltest class holds a transaction around every test; wait in a test class that commits.
Events published without a command
A test may publish an event through ApplicationEventPublisher instead of sending a command, and wait for the projection that listens to it: the event is captured and reaches the projection through the capture topic.
An event type that an event-sourced aggregate records is never captured, though: it reaches a projection only through the event store, so publishing it from a test does not reach the projection.
Several instances
A test that starts several instances of the application waits precisely only for the messages the calling instance applies.
For messages another instance applies, the wait can take up to a minute longer than the projection needed.
That is longer than the default timeout of 10 seconds, so raise the timeout in such a test, for example to timeout = 2.minutes.