Skip to content

Testing a saga

Saga.step(state, input) runs evolve and then react, and returns the new state together with the effects that transition produced. It is what the executor runs per input, which is why it is also what a test drives. No clock, no scheduler, no store.

The sagas below are the ones from the Flow DSL, and they are kept compiling as real tests in dsl/saga-dsl/common/src/test, so what you read here is what runs.

Start with a plain event. In the game lobby, a PlayerJoined takes a branch that ends the flow, so the saga completes:

Saga.Step<FlowState<GameEvent>, CloseGame> started = start(lobby, new GameCreated("game-1"));
Saga.Step<FlowState<GameEvent>, CloseGame> step = lobby.step(started.state(), SagaInput.event(new PlayerJoined("game-1")));
assertThat(step.state().completed()).isTrue();

Two things to know before you write the first test

Section titled “Two things to know before you write the first test”

step does not apply onStart. A start event creates the instance, and onStart runs once at that point, but step is only evolve plus react. So a start event is applied the way the executor does it, and it is worth a small helper because every saga test needs it:

static <E, C> Saga.Step<FlowState<E>, C> start(Saga<E, FlowState<E>, C> saga, E event) {
FlowState<E> state = saga.evolve(saga.initialState(), SagaInput.event(event));
List<SagaEffect<C>> effects = new ArrayList<>(saga.onStart(state, event));
effects.addAll(saga.react(state, SagaInput.event(event)));
return new Saga.Step<>(state, effects);
}

Effects are not only commands. Leaving a step whose timeout was armed cancels that timer, and the cancellation is an effect like any other. So a branch that issues no command does not produce an empty effects list, it produces a CancelTimeout for the timer the step armed, which stepTimer names for you. That is what issuedCommands() is for. It reads the commands back out of the effects, so a reaction that issued nothing is empty there even when the effects list is not:

Saga.Step<FlowState<GameEvent>, CloseGame> step = lobby.step(started.state(), SagaInput.event(new PlayerJoined("game-1")));
// The branch issues nothing, but leaving the step still cancels its timeout
assertThat(step.issuedCommands()).isEmpty();
assertThat(step.timerEffects()).containsExactly(SagaEffect.cancelTimeout(stepTimer("awaiting-players")));

Timers get the same split treatment as commands. timerEffects() reads the started, re-armed, and cancelled timers back out of effects, in the order effects holds them, so a reaction that only issues commands is empty there even when effects is not. effects() stays the single ordered log of everything a reaction produced, and issuedCommands() and timerEffects() are two derived readings of it, the command half and the timer half, that together account for every effect a step can produce. Use whichever half the assertion is about, and fall back to effects itself when a test needs commands and timers in the order they were produced. Note that Kotlin calls both accessors with the parentheses, since they are derived methods rather than record components.

Fire a timeout in a test by naming its timer, instead of waiting for time to pass. A flow step’s timer is named after the step, and stepTimer from the flow DSL gives you that name:

Saga.Step<FlowState<GameEvent>, CloseGame> step =
lobby.step(started.state(), SagaInput.timeout("game-1", stepTimer("awaiting-players")));
assertAll(
() -> assertThat(step.effects()).containsExactly(SagaEffect.issue(new CloseGame("game-1"))),
() -> assertThat(step.state().completed()).isTrue()
);

The step takes SagaInput.timeout(...) here the same way it took SagaInput.event(...) in the tests above, because to a step a timeout is just another input to react to.

An absolute timeout(at = ...) fires the same way.

The deadline only tells the executor when to fire the timer. Once it fires, the deadline has no further say in what happens, so a test never has to reach that instant to check the outcome.

A timer name the saga does not know is a no-op. Test that case too, since it’s what a typo in a reactOnTimeout name looks like.

A condition fires once it is satisfied, counted since the step was entered. The interesting tests are the ones before that, a partial match that must not fire, and the other alternative firing instead. Using the review saga from Step Conditions above, one Approved is not enough to publish, a second one is, and a single Rejected discards immediately without waiting for anything else. Feed the events one at a time and pass each step’s state into the next:

Saga.Step<FlowState<ReviewEvent>, ReviewCommand> started = start(review, new ReviewStarted("review-1"));
// One of the two Approved events the condition needs, not enough to complete yet
Saga.Step<FlowState<ReviewEvent>, ReviewCommand> afterFirst = review.step(started.state(), SagaInput.event(new Approved("review-1")));
assertThat(afterFirst.state().completed()).isFalse();
// The second Approved satisfies the condition and publishes
Saga.Step<FlowState<ReviewEvent>, ReviewCommand> afterSecond = review.step(afterFirst.state(), SagaInput.event(new Approved("review-1")));
assertAll(
() -> assertThat(afterSecond.state().completed()).isTrue(),
() -> assertThat(afterSecond.effects()).containsExactly(SagaEffect.issue(new Publish("review-1")))
);

A single Rejected takes the other branch and completes the saga immediately, with no second event needed:

Saga.Step<FlowState<ReviewEvent>, ReviewCommand> step = review.step(started.state(), SagaInput.event(new Rejected("review-1")));
assertAll(
() -> assertThat(step.state().completed()).isTrue(),
() -> assertThat(step.effects()).containsExactly(SagaEffect.issue(new Discard("review-1")))
);

Write the partial case first. A condition that fires too early passes a test that only checks the fulfilled path.

currentStep() is how you assert where a flow went, and that covers next, end and transitionTo alike. A back-edge is the one that repays testing, because a loop that quietly leaves its step looks the same from the outside as one that stays. Using the auction from the Flow DSL section, each bid re-enters bidding, and the deadline still closes it after any number of bids:

Saga.Step<FlowState<AuctionEvent>, CloseAuction> started = start(auction, new AuctionStarted("auction-1", Instant.parse("2026-01-01T12:00:00Z")));
Saga.Step<FlowState<AuctionEvent>, CloseAuction> afterBid = auction.step(started.state(), SagaInput.event(new BidPlaced("auction-1", 100)));
assertThat(afterBid.state().currentStep()).isEqualTo("bidding");
Saga.Step<FlowState<AuctionEvent>, CloseAuction> closed =
auction.step(afterBid.state(), SagaInput.timeout("auction-1", stepTimer("bidding")));
assertThat(closed.effects()).containsExactly(SagaEffect.issue(new CloseAuction("auction-1")));

A guard is tested the same way, by driving the same branch twice with events that differ only in the guarded value and asserting the flow moved in one case and not the other.

The level above proves the saga. This level proves the wiring. It confirms that the runner subscribes, correlates, persists, and dispatches. Use the in-memory event store and subscription model, SagaStateStore.inMemory(), and a CommandDispatcher that collects into a list so you can assert on it.

A timeout is the one case that genuinely needs time to pass here, and the poll interval is configurable so it does not need much. Shrink both the interval and the saga’s own timeout:

List<OrderCommand> issued = new CopyOnWriteArrayList<>();
CommandDispatcher<OrderCommand> dispatcher = issued::add;
SagaRunnerConfig config = SagaRunnerConfig.defaults().withTimerPollInterval(Duration.ofMillis(50));
SagaRunner<OrderEvent, OrderCommand> runner = SagaRunner.agnostic(subscriptionModel, converter);
runner.run("orders", orderFulfillment(Duration.ofMillis(150)), stateStore, dispatcher, null, config)
.waitUntilStarted();
eventStore.write(orderId, converter.toCloudEvents(List.of(new OrderPlaced(orderId, 42.0))));
await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(issued).containsExactly(new ReservePayment(orderId, 42.0), new CancelOrder(orderId, "payment timeout")));

The order-fulfillment example runs exactly this, in both languages, with no Docker.