Testing projections
Projections are updated asynchronously, so tests must wait for the read model to reach the expected state.
Delivery timing
Event-sourced projections are consumed by the ordered consumer loop.
The consumer wakes immediately on an after-commit signal; if no signal arrives it falls back to polling every spring.ddd.cqrs.projection.consumer.polling.interval (default PT1S).
JPA-aggregate projections are dispatched live once the publishing transaction commits.
In both cases the gap between publishing and projection update may be sub-millisecond in a local test or up to the poll interval under load. Tests that assert projection state must poll rather than assert immediately.
The worked examples below publish a domain event directly, which drives the live (JPA-aggregate / Spring Modulith) delivery path. To exercise an event-sourced projection end to end, send the command that produces the events instead — the events are appended to the store and the ordered consumer applies them — then poll the read model the same way.
Spring Modulith Scenario (preferred)
When the application uses Spring Modulith (see Spring Modulith Integration), the Scenario API provides a concise polling assertion style.
testImplementation("org.springframework.modulith:spring-modulith-test:<version>")
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.modulith.test.EnableScenarios
import org.springframework.modulith.test.Scenario
@SpringBootTest
@EnableScenarios
class AccountSummaryProjectionTest(
private val accountSummaryRepository: AccountSummaryRepository,
) {
@Test
fun `AccountSummary reflects the opening balance`(scenario: Scenario) { (1)
val id = AccountId("acc-1")
scenario
.publish(AccountOpened(accountId = id, initialBalance = Money(BigDecimal("100.00"), "EUR"))) (2)
.andWaitForStateChange { accountSummaryRepository.findByIdOrNull(id) } (3)
.andVerify { summary ->
summary.balance shouldBe Money(BigDecimal("100.00"), "EUR") (4)
}
}
}
| 1 | Scenario is injected by Spring Modulith’s test infrastructure; @EnableScenarios activates it. |
| 2 | Publish an event through the application event bus; Spring DDD’s listeners forward it to the projection. |
| 3 | andWaitForStateChange polls the lambda until it returns a non-null value or a configurable timeout elapses. |
| 4 | Assert the fully populated read model once delivery is confirmed. |
Awaitility (fallback)
When Spring Modulith is not on the classpath, Awaitility provides equivalent polling semantics.
import io.kotest.matchers.shouldBe
import org.awaitility.kotlin.await
import org.awaitility.kotlin.untilNotNull
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.ApplicationEventPublisher
import java.math.BigDecimal
@SpringBootTest
class AccountSummaryAwaitilityTest(
private val publisher: ApplicationEventPublisher,
private val accountSummaryRepository: AccountSummaryRepository,
) {
@Test
fun `AccountSummary reflects the opening balance`() {
val id = AccountId("acc-2")
publisher.publishEvent(AccountOpened(accountId = id, initialBalance = Money(BigDecimal("100.00"), "EUR"))) (1)
val summary = await untilNotNull { accountSummaryRepository.findByIdOrNull(id) } (2)
summary.balance shouldBe Money(BigDecimal("100.00"), "EUR") (3)
}
}
| 1 | Publish the event to trigger projection delivery. |
| 2 | await untilNotNull polls the lambda until the repository returns a non-null result, with a configurable timeout. |
| 3 | Assert the projected state once delivery is confirmed. |