Testing sagas

A saga (see Writing a saga) is a plain object whose @SagaEventHandler and @SagaDeadlineHandler methods are ordinary methods, so their logic is unit-testable with no Spring context: construct the saga, set any prior state, call a handler directly with the triggering event and test doubles for its collaborators, then assert the saga’s state and the commands it issued. SagaCommands and SagaDeadlines are interfaces, so a mock records what the handler does; SagaLifecycle is a simple object whose ended flag you inspect directly.

class TransferSagaTest {

    @Test
    fun `starting the transfer issues a debit command`() {
        val commands = mockk<SagaCommands>(relaxed = true)   (1)
        val saga = TransferSaga()                            (2)

        saga.on(
            TransferRequested(
                transferId = "t-1",
                sourceAccountId = "acc-1",
                targetAccountId = "acc-2",
                amount = BigDecimal("100.00"),
            ),
            commands,
        )

        saga.transferId shouldBe "t-1" (3)
        verify { commands.send(DebitAccount(accountId = "acc-1", amount = BigDecimal("100.00"))) } (4)
    }

    @Test
    fun `the saga completes once both legs are confirmed`() {
        val saga = TransferSaga().apply {
            transferId = "t-1"
            debitConfirmed = true (5)
        }
        val lifecycle = SagaLifecycle()

        saga.on(CreditConfirmed(transferId = "t-1"), lifecycle)

        saga.creditConfirmed shouldBe true
        lifecycle.ended shouldBe true (6)
    }

    @Test
    fun `the payment-timeout deadline compensates with a reverse debit`() {
        val commands = mockk<SagaCommands>(relaxed = true)
        val saga = TransferSaga().apply {
            transferId = "t-1"
            sourceAccountId = "acc-1"
            amount = BigDecimal("100.00")
        }

        saga.onPaymentTimeout(commands) (7)

        verify { commands.send(ReverseDebit(accountId = "acc-1", amount = BigDecimal("100.00"))) }
    }
}
1 A relaxed mock records the commands the saga sends without stubbing return values.
2 Construct the saga with its no-argument constructor — it is a plain object, not a Spring bean.
3 Assert the state the handler set.
4 Assert the follow-up command; commands and the command are plain objects, so verify matches by value.
5 Set the prior state the scenario assumes — the debit leg already confirmed.
6 SagaLifecycle.ended is true after the handler calls lifecycle.end(); assert it directly, no mock needed.
7 Call the @SagaDeadlineHandler method directly to test the compensation a fired deadline triggers.

The saga lifecycle — @SagaStart creating or routing to an instance, association by associationProperty, idempotent redelivery, @SagaEnd tombstoning, and a deadline actually firing on schedule — is driven by the framework, so verify it in an integration test. A saga instance is not a Spring bean and cannot be injected, so the test waits for it with SagaAwait: drive the flow by publishing the event that starts the saga (or sending the commands that raise it), then wait until the saga instance passes a check. The test needs no fixed sleep, no polling loop and no store bean of its own.

testImplementation("de.dwittkoetter:spring-ddd-starter-test:0.0.1-SNAPSHOT")

spring-ddd-starter-test brings spring-ddd-saga-test, which holds SagaAwait. SagaAwait and the top-level function sagaAwait are in de.dwittkoetter.ddd.saga.test; @EnableSagaAwait is in de.dwittkoetter.ddd.saga.test.annotation. Annotate a Spring Boot test class with @EnableSagaAwait and take a SagaAwait test-method parameter:

@SpringBootTest
@EnableSagaAwait
class TransferSagaIntegrationTest(@Autowired val events: ApplicationEventPublisher) {

    @Test
    fun `the saga completes once both legs are confirmed`(sagas: SagaAwait) {
        events.publishEvent(TransferRequested("t-1", "acc-1", "acc-2", BigDecimal("100.00")))

        sagas.await<TransferSaga>("t-1") { saga, completed -> (1)
            saga.creditConfirmed shouldBe true
            completed shouldBe true
        }
    }

    @Test
    fun `a transfer that is never confirmed stays open`(sagas: SagaAwait) {
        events.publishEvent(TransferRequested("t-2", "acc-1", "acc-2", BigDecimal("100.00")))

        sagas.await<TransferSaga>("t-2").within(5.seconds).until { saga, completed -> (2)
            saga.debitConfirmed shouldBe true
            completed shouldBe false
        }
    }
}
1 Waits for the instance with that saga id, then runs the check on the saga and on whether it has completed.
2 within sets another timeout for this wait.

The class must run under Spring’s JUnit extension, as @SpringBootTest does. In Java the saga is named by its class, and the check is a BiConsumer:

@SpringBootTest
@EnableSagaAwait
class TransferSagaIntegrationTest {

    @Autowired
    ApplicationEventPublisher events;

    @Test
    void theSagaCompletesOnceBothLegsAreConfirmed(SagaAwait sagas) {
        events.publishEvent(new TransferRequested("t-1", "acc-1", "acc-2", new BigDecimal("100.00")));

        sagas.await(TransferSaga.class, "t-1", (saga, completed) -> {
            assertTrue(saga.getCreditConfirmed());
            assertTrue(completed);
        });
    }
}

Where there is no JUnit parameter to take, as in Kotest, build the fixture with the top-level function sagaAwait from the application context:

val sagas = sagaAwait(applicationContext)

The check is run again until it passes, or until 10 seconds have passed; both await forms return the saga when it passes. The check is retried, because a saga advances one event at a time and the test names the state it expects; Waiting for projections describes the projection await, which runs its check once. An instance that does not exist yet is waited for. At the timeout the wait fails with an AssertionError that names the saga, its id and the last failure of the check, so a wrong expectation shows its own expected and actual values. Any other exception from the check ends the wait at once.

The wait does not run into its timeout when the saga can never advance. It fails at once, with an AssertionError, when the saga type’s consumer is halted, is paused, or holds a dead letter of any instance of that type that waits for an operator. This applies only while the awaited check has not passed yet: once it passes, the wait returns, whatever the other instances do.

The wait must run outside a transaction: the events before it are not committed yet, so the saga can never see them. A @Transactional test class holds a transaction around every test; wait in a test class that commits. Waiting for a class that is not a saga is refused with an IllegalArgumentException.

Saga delivery is asynchronous and at-least-once — see Sagas overview. When the test checks a read model that the saga’s commands update, wait for its projection with Waiting for projections instead of polling.