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.