Saga Pattern Testing in Microservices: Strategies and Examples

Saga Pattern Testing in Microservices: Strategies and Examples

The Saga pattern is how you implement long-running business transactions across multiple microservices without distributed transactions. Instead of locking resources across services (which doesn't scale and creates tight coupling), a saga breaks the transaction into a sequence of local transactions — each with a compensating transaction to undo it if a later step fails.

Sagas are powerful. They're also notoriously difficult to get right, and even more difficult to test. This post covers what makes sagas hard to test, the strategies that work, and concrete implementation examples.

What Makes Sagas Hard to Test

In a monolith, a transaction either commits or rolls back atomically. There's no in-between state visible to the outside world. With sagas, intermediate states are visible. If your order saga has committed the "reserve inventory" step but not yet committed the "charge payment" step, there's a window where inventory is reserved but payment hasn't been taken — a real state that real concurrent requests can observe.

Testing needs to cover:

  1. Happy path: All steps complete successfully in order.
  2. Compensation: A step fails mid-saga, and all previous steps are correctly compensated (rolled back at the business level).
  3. Partial failure: A compensation step itself fails — what happens next?
  4. Idempotency: A step or compensation runs twice (due to retries or at-least-once delivery) — is the outcome correct?
  5. Concurrent sagas: Multiple sagas running simultaneously that operate on overlapping resources.
  6. Recovery: The saga coordinator crashes mid-execution and restarts — does the saga resume correctly?

Each of these requires different test strategies.

Choreography vs Orchestration Sagas

Before diving into testing, it's worth being clear about which type of saga you're testing.

Choreography sagas use events. Each service publishes an event when its local transaction completes. Other services subscribe to those events and trigger their own transactions. There's no central coordinator — the saga logic is distributed across the event handlers.

Orchestration sagas use a central coordinator (often called a saga orchestrator or process manager). The orchestrator sends commands to each service and waits for their responses. It contains the saga's control flow logic explicitly.

Orchestration sagas are generally easier to test because the state machine lives in one place. Choreography sagas distribute the state machine across event handlers, making it harder to reason about and test comprehensively.

Testing the Happy Path

Let's use an order placement saga as a running example. The saga involves four steps:

  1. Reserve inventory
  2. Charge payment
  3. Create shipment
  4. Confirm order

Here's the orchestrator in Java using the Eventuate Tram Saga framework:

public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaData> {

    private final SagaDefinition<CreateOrderSagaData> sagaDefinition;

    public CreateOrderSaga(
        InventoryServiceProxy inventoryService,
        PaymentServiceProxy paymentService,
        ShipmentServiceProxy shipmentService,
        OrderServiceProxy orderService
    ) {
        this.sagaDefinition = step()
            .invokeParticipant(inventoryService::reserve,
                               CreateOrderSagaData::makeReserveInventoryCommand)
            .withCompensation(inventoryService::release,
                              CreateOrderSagaData::makeReleaseInventoryCommand)
        .step()
            .invokeParticipant(paymentService::charge,
                               CreateOrderSagaData::makeChargePaymentCommand)
            .withCompensation(paymentService::refund,
                              CreateOrderSagaData::makeRefundCommand)
        .step()
            .invokeParticipant(shipmentService::create,
                               CreateOrderSagaData::makeCreateShipmentCommand)
        .step()
            .invokeParticipant(orderService::confirm,
                               CreateOrderSagaData::makeConfirmOrderCommand)
        .build();
    }
}

Testing the happy path:

@SpringBootTest
@Transactional
class CreateOrderSagaTest {

    @Autowired
    private SagaTester<CreateOrderSagaData> sagaTester;

    @MockBean
    private InventoryServiceProxy inventoryService;

    @MockBean
    private PaymentServiceProxy paymentService;

    @MockBean
    private ShipmentServiceProxy shipmentService;

    @Test
    void happyPath_allStepsCompleteSuccessfully() {
        CreateOrderSagaData data = new CreateOrderSagaData(
            "user-123",
            List.of(new OrderItem("product-456", 2)),
            new Money("39.98")
        );

        // Configure mocks to succeed
        when(inventoryService.reserve(any())).thenReturn(SagaReply.success());
        when(paymentService.charge(any())).thenReturn(SagaReply.success());
        when(shipmentService.create(any())).thenReturn(SagaReply.success());

        SagaTestResult result = sagaTester.test(new CreateOrderSaga(...), data);

        assertThat(result.isSuccessful()).isTrue();
        assertThat(result.getCompletedSteps()).containsExactly(
            "reserve-inventory",
            "charge-payment",
            "create-shipment",
            "confirm-order"
        );

        // Verify the commands sent to each service
        verify(inventoryService).reserve(argThat(cmd ->
            cmd.getProductId().equals("product-456") &&
            cmd.getQuantity() == 2
        ));
        verify(paymentService).charge(argThat(cmd ->
            cmd.getAmount().equals(new Money("39.98"))
        ));
    }
}

Testing Compensation

This is where saga testing gets interesting. You need to verify that when step N fails, steps 1 through N-1 are compensated in reverse order.

@Test
void paymentFails_compensatesInventoryReservation() {
    CreateOrderSagaData data = new CreateOrderSagaData(
        "user-123",
        List.of(new OrderItem("product-456", 2)),
        new Money("39.98")
    );

    // Inventory succeeds, payment fails
    when(inventoryService.reserve(any())).thenReturn(SagaReply.success());
    when(paymentService.charge(any())).thenReturn(
        SagaReply.failure("PAYMENT_DECLINED")
    );

    SagaTestResult result = sagaTester.test(new CreateOrderSaga(...), data);

    assertThat(result.isSuccessful()).isFalse();
    assertThat(result.getFailureReason()).isEqualTo("PAYMENT_DECLINED");

    // Verify inventory was compensated
    verify(inventoryService).release(argThat(cmd ->
        cmd.getProductId().equals("product-456") &&
        cmd.getQuantity() == 2
    ));

    // Verify payment was NOT charged (it failed before completing)
    verify(paymentService, never()).refund(any());

    // Verify shipment was never started
    verify(shipmentService, never()).create(any());
}

@Test
void shipmentFails_compensatesPaymentAndInventory() {
    when(inventoryService.reserve(any())).thenReturn(SagaReply.success());
    when(paymentService.charge(any())).thenReturn(SagaReply.success());
    when(shipmentService.create(any())).thenReturn(
        SagaReply.failure("NO_CARRIER_AVAILABLE")
    );

    SagaTestResult result = sagaTester.test(new CreateOrderSaga(...), data());

    assertThat(result.isSuccessful()).isFalse();

    // Compensation order must be reverse of execution order
    InOrder inOrder = inOrder(paymentService, inventoryService);
    inOrder.verify(paymentService).refund(any());
    inOrder.verify(inventoryService).release(any());

    verify(shipmentService, never()).delete(any()); // shipment didn't complete
}

The compensation order assertion (InOrder) is critical. If compensations run in the wrong order, you can leave data in inconsistent states. For example, confirming a refund before canceling the shipment might result in the shipment going out after the money was returned.

Testing Idempotency

Sagas use message queues with at-least-once delivery. Your saga steps will occasionally execute twice. Test this explicitly:

@Test
void reserveInventory_isIdempotent() {
    String reservationId = UUID.randomUUID().toString();
    ReserveInventoryCommand cmd = new ReserveInventoryCommand(
        "product-456", 2, reservationId
    );

    // Execute the same command twice
    inventoryCommandHandler.reserve(cmd);
    inventoryCommandHandler.reserve(cmd);

    // Inventory should be reserved exactly once
    Inventory inventory = inventoryRepository.findById("product-456").orElseThrow();
    assertThat(inventory.getReservedQuantity()).isEqualTo(2);

    // Both commands should return success (idempotent, not error on duplicate)
    // ... verify via reply messages
}

@Test
void releaseInventory_isIdempotentWhenAlreadyReleased() {
    // First, set up a reservation
    inventoryCommandHandler.reserve(new ReserveInventoryCommand("product-456", 2, "res-001"));

    // Release it
    inventoryCommandHandler.release(new ReleaseInventoryCommand("product-456", 2, "res-001"));

    // Try to release again (duplicate delivery)
    assertDoesNotThrow(() ->
        inventoryCommandHandler.release(new ReleaseInventoryCommand("product-456", 2, "res-001"))
    );

    // Inventory should be released, not doubly released
    Inventory inventory = inventoryRepository.findById("product-456").orElseThrow();
    assertThat(inventory.getReservedQuantity()).isEqualTo(0);
    assertThat(inventory.getAvailableQuantity()).isEqualTo(originalQuantity);
}

The standard technique for idempotency is storing the command/message ID and checking for duplicates before processing. Test both that the deduplication works and that the duplicate returns a sensible response (success, not an error that would trigger further retries).

Testing Compensation Failure

What happens when a compensation step fails? This is the hardest scenario to handle and test. Your options are:

  1. Retry the compensation: Put it back on the queue and try again later.
  2. Dead letter queue: After N retries, move to a DLQ for manual handling.
  3. Saga stuck state: Mark the saga as stuck and alert an operator.

Test that your saga moves to the correct terminal state when compensation fails:

@Test
void compensationFailure_marksSagaAsStuck() {
    when(inventoryService.reserve(any())).thenReturn(SagaReply.success());
    when(paymentService.charge(any())).thenReturn(SagaReply.failure("DECLINED"));
    // Compensation also fails
    when(inventoryService.release(any())).thenThrow(
        new InventoryServiceUnavailableException("connection refused")
    );

    SagaTestResult result = sagaTester.test(new CreateOrderSaga(...), data());

    assertThat(result.getState()).isEqualTo(SagaState.STUCK);
    assertThat(result.getStuckAt()).isEqualTo("release-inventory-compensation");

    // Verify an alert was raised
    verify(alertService).raiseSagaStuckAlert(argThat(alert ->
        alert.getSagaId().equals(result.getSagaId()) &&
        alert.getStep().equals("release-inventory-compensation")
    ));
}

Integration Testing with a Message Broker

Unit tests with mocked participants verify saga control flow. Integration tests verify that the saga actually works through a real message broker (Kafka, RabbitMQ, etc.). This tests message serialization, schema compatibility, and broker configuration.

Using Testcontainers:

@SpringBootTest
@Testcontainers
class CreateOrderSagaIntegrationTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));

    @DynamicPropertySource
    static void kafkaProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Autowired
    private OrderService orderService;

    @Autowired
    private InventoryRepository inventoryRepository;

    @Autowired
    private PaymentRepository paymentRepository;

    @Test
    void orderPlacement_completesFullSagaWithRealBroker() throws InterruptedException {
        // Seed inventory
        inventoryRepository.save(new Inventory("product-456", 100));

        // Trigger saga
        String orderId = orderService.placeOrder(
            new PlaceOrderRequest("user-123", List.of(new Item("product-456", 2)), new Money("39.98"))
        );

        // Wait for saga to complete (max 30 seconds)
        Awaitility.await()
            .atMost(30, TimeUnit.SECONDS)
            .pollInterval(500, TimeUnit.MILLISECONDS)
            .until(() -> orderService.getOrder(orderId).getStatus() == OrderStatus.CONFIRMED);

        // Verify end state
        Order order = orderService.getOrder(orderId);
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);

        Inventory inventory = inventoryRepository.findById("product-456").orElseThrow();
        assertThat(inventory.getAvailableQuantity()).isEqualTo(98);

        // Verify no partial saga state lingers
        assertThat(sagaInstanceRepository.findActiveSagas()).isEmpty();
    }

    @Test
    void orderPlacement_compensatesOnPaymentDecline() throws InterruptedException {
        inventoryRepository.save(new Inventory("product-456", 100));
        // Configure payment service to decline (via test header or test mode)

        String orderId = orderService.placeOrder(
            new PlaceOrderRequest("user-123", List.of(new Item("product-456", 2)),
                new Money("39.98"), Map.of("x-test-payment-result", "decline"))
        );

        Awaitility.await()
            .atMost(30, TimeUnit.SECONDS)
            .until(() -> orderService.getOrder(orderId).getStatus() == OrderStatus.REJECTED);

        // Verify inventory was released back
        Inventory inventory = inventoryRepository.findById("product-456").orElseThrow();
        assertThat(inventory.getAvailableQuantity()).isEqualTo(100); // Back to original
    }
}

Testing Concurrent Sagas and Race Conditions

When two orders compete for the last unit of inventory, what happens?

@Test
void concurrentOrders_onlyOneSucceedsWhenInventoryLimited() throws Exception {
    inventoryRepository.save(new Inventory("limited-product", 1)); // Only 1 in stock

    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch startLatch = new CountDownLatch(1);
    List<Future<String>> futures = new ArrayList<>();

    for (int i = 0; i < 2; i++) {
        final String userId = "user-" + i;
        futures.add(executor.submit(() -> {
            startLatch.await(); // Both start simultaneously
            return orderService.placeOrder(
                new PlaceOrderRequest(userId, List.of(new Item("limited-product", 1)), new Money("9.99"))
            );
        }));
    }

    startLatch.countDown(); // Release both threads

    List<String> orderIds = futures.stream()
        .map(f -> {
            try { return f.get(30, TimeUnit.SECONDS); }
            catch (Exception e) { return null; }
        })
        .filter(Objects::nonNull)
        .collect(Collectors.toList());

    // Wait for all sagas to complete
    Awaitility.await()
        .atMost(60, TimeUnit.SECONDS)
        .until(() -> orderIds.stream()
            .allMatch(id -> orderService.getOrder(id).getStatus().isTerminal())
        );

    long confirmedCount = orderIds.stream()
        .filter(id -> orderService.getOrder(id).getStatus() == OrderStatus.CONFIRMED)
        .count();

    long rejectedCount = orderIds.stream()
        .filter(id -> orderService.getOrder(id).getStatus() == OrderStatus.REJECTED)
        .count();

    // Exactly one order should succeed, one should fail
    assertThat(confirmedCount).isEqualTo(1);
    assertThat(rejectedCount).isEqualTo(1);

    // Inventory should be at zero, not negative
    Inventory inventory = inventoryRepository.findById("limited-product").orElseThrow();
    assertThat(inventory.getAvailableQuantity()).isEqualTo(0);
    assertThat(inventory.getReservedQuantity()).isEqualTo(1);
}

Testing Saga Recovery After Coordinator Crash

Saga orchestrators need to recover state after a crash. This tests the persistence and recovery mechanism:

@Test
void sagaRecovery_resumesAfterCoordinatorRestart() {
    // Step 1: Start a saga and let it complete "reserve inventory"
    String sagaId = sagaManager.begin(new CreateOrderSagaData(...));

    // Wait for first step to complete
    Awaitility.await()
        .until(() -> sagaInstanceRepository.findById(sagaId)
            .map(s -> s.getCurrentStep().equals("charge-payment"))
            .orElse(false));

    // Step 2: Simulate coordinator crash by resetting the in-memory state
    sagaManager.simulateCrash();

    // Step 3: "Restart" by triggering recovery
    sagaManager.recover();

    // Step 4: Saga should resume from where it left off (charge-payment)
    // — not restart from the beginning
    Awaitility.await()
        .atMost(30, TimeUnit.SECONDS)
        .until(() -> {
            Order order = orderService.getOrderBySagaId(sagaId);
            return order != null && order.getStatus().isTerminal();
        });

    // Verify inventory was NOT double-reserved (recovery resumed, not restarted)
    verify(inventoryService, times(1)).reserve(any()); // Exactly once
}

Choreography Sagas: Testing Event-Driven Flows

For choreography sagas, there's no central orchestrator to test. Instead, you test each service's event handler in isolation, then integration-test the full event chain:

// Test a single event handler
@Test
void inventoryReservedEvent_triggersPaymentCharge() {
    InventoryReservedEvent event = new InventoryReservedEvent(
        "order-123", "product-456", 2, "res-001"
    );

    paymentEventHandler.onInventoryReserved(event);

    // Verify a PaymentChargeCommand was published
    verify(messagePublisher).publish(
        eq("payment-commands"),
        argThat(cmd -> cmd instanceof ChargePaymentCommand &&
            ((ChargePaymentCommand) cmd).getOrderId().equals("order-123"))
    );
}

// Integration test the full chain
@Test
void orderEvent_triggersFullSagaChain() {
    orderEventPublisher.publish(new OrderPlacedEvent("order-123", "user-123", items, amount));

    Awaitility.await()
        .atMost(30, TimeUnit.SECONDS)
        .until(() -> getOrderStatus("order-123") == OrderStatus.CONFIRMED);

    // Verify each event in the chain was published in order
    assertEventsPublished(
        "inventory.reserved",
        "payment.charged",
        "shipment.created",
        "order.confirmed"
    );
}

Observability for Saga Testing

Sagas are hard to debug without good observability. Each saga instance should emit span events at each step transition:

// In your saga step handler
public SagaReply handleReserveInventory(ReserveInventoryCommand cmd) {
    Span span = tracer.spanBuilder("saga.step.reserve-inventory")
        .setAttribute("saga.id", cmd.getSagaId())
        .setAttribute("saga.step", "reserve-inventory")
        .startSpan();

    try (Scope scope = span.makeCurrent()) {
        boolean reserved = inventoryService.reserve(cmd.getProductId(), cmd.getQuantity(), cmd.getReservationId());
        if (reserved) {
            span.setAttribute("saga.step.status", "success");
            return SagaReply.success();
        } else {
            span.setAttribute("saga.step.status", "failed");
            span.setAttribute("saga.failure.reason", "insufficient-inventory");
            return SagaReply.failure("INSUFFICIENT_INVENTORY");
        }
    } finally {
        span.end();
    }
}

With proper trace instrumentation, you can see a complete saga execution as a single trace in Jaeger or Tempo — even for choreography sagas where the logic is distributed.

Wrapping Up

Saga testing is harder than testing stateless services. The state is distributed, intermediate states are visible, and failures can occur at any step in both the forward and compensation paths. But these failure modes are real and will occur in production — better to find them in tests.

The testing hierarchy for sagas:

  1. Unit tests for each step's command handler (idempotency, success, failure)
  2. Orchestrator unit tests with mocked participants (control flow, compensation order)
  3. Integration tests with a real message broker (serialization, schema compatibility)
  4. Concurrency tests for race conditions around shared resources
  5. Recovery tests for coordinator crash scenarios

Most teams cover the first two levels and skip the rest. That's where the remaining production incidents live. The concurrency and recovery scenarios are harder to write but represent the failure modes that are most damaging in production — data inconsistencies that are hard to detect and even harder to fix after the fact.

Read more

Start now free