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.
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import java.math.BigDecimal
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 assert the saga’s observable effects rather than the instance itself: drive the flow (publish the triggering events, or send the commands that raise them) and poll the aggregates, events, or read models its commands produce — for the transfer saga, the source and target account read models reaching their expected balances — using the same Spring Modulith Scenario or Awaitility approach shown in Testing projections.
To assert the saga’s own persisted state or that it completed, inject the SagaStore bean and call find(sagaType, sagaId, TransferSaga::class); the returned LoadedSaga carries the deserialised instance and a completed flag.
Saga delivery is asynchronous and at-least-once — see Sagas overview.