Event-Driven Architecture Testing: Eventual Consistency, Sagas, and the Outbox Pattern

Event-Driven Architecture Testing: Eventual Consistency, Sagas, and the Outbox Pattern

Event-driven architecture is powerful and notoriously hard to test. Events flow asynchronously between services, state changes propagate eventually, and a bug might not surface until several events down a chain. Standard request/response testing patterns don't apply. You need a different mental model.

The Core Testing Challenge

In synchronous systems, testing is simple: call function, assert result. In event-driven systems:

  • The producer doesn't know who consumes its events
  • The consumer doesn't know when the event will arrive
  • Multiple consumers might process the same event differently
  • Failures at any step might leave the system in a partially-updated state

The goal isn't to eliminate this complexity — it's to test each concern at the right level.

Level 1: Test Events in Isolation

The first layer is pure: test that your domain events are structurally correct and carry the right data.

class OrderPlacedEventTest {

    @Test
    void shouldContainRequiredFields() {
        Order order = new Order("order-123", "customer-456", 99.99, List.of(
            new LineItem("sku-1", 2, 49.99)
        ));

        OrderPlacedEvent event = OrderPlacedEvent.from(order);

        assertThat(event.getOrderId()).isEqualTo("order-123");
        assertThat(event.getCustomerId()).isEqualTo("customer-456");
        assertThat(event.getTotal()).isEqualTo(99.99);
        assertThat(event.getLineItems()).hasSize(1);
        assertThat(event.getOccurredAt()).isNotNull();
        assertThat(event.getEventId()).isNotNull(); // idempotency key
    }

    @Test
    void shouldSerializeToExpectedSchema() throws Exception {
        OrderPlacedEvent event = OrderPlacedEvent.from(testOrder());
        String json = objectMapper.writeValueAsString(event);

        // Validate against JSON Schema
        JsonSchema schema = loadSchema("schemas/order-placed-event-v1.json");
        Set<ValidationMessage> errors = schema.validate(objectMapper.readTree(json));
        assertThat(errors).isEmpty();
    }
}

Always version your event schemas and validate against them in tests. Schema drift is the silent killer of event-driven systems.

Level 2: Test Event Handlers in Isolation

Test each handler with a known input event and assert the expected state change:

class InventoryReservationHandlerTest {

    private InventoryReservationHandler handler;
    private InventoryRepository inventoryRepository;
    private EventPublisher eventPublisher;

    @BeforeEach
    void setup() {
        inventoryRepository = mock(InventoryRepository.class);
        eventPublisher = mock(EventPublisher.class);
        handler = new InventoryReservationHandler(inventoryRepository, eventPublisher);
    }

    @Test
    void shouldReserveInventoryAndPublishReservedEvent() {
        OrderPlacedEvent event = new OrderPlacedEvent(
            "order-123", List.of(new LineItem("sku-1", 2, 49.99))
        );
        when(inventoryRepository.getAvailableStock("sku-1")).thenReturn(10);

        handler.handle(event);

        verify(inventoryRepository).reserveStock("sku-1", 2, "order-123");
        verify(eventPublisher).publish(argThat(e -> 
            e instanceof InventoryReservedEvent &&
            ((InventoryReservedEvent) e).getOrderId().equals("order-123")
        ));
    }

    @Test
    void shouldPublishFailedEventWhenInsufficientStock() {
        OrderPlacedEvent event = new OrderPlacedEvent(
            "order-123", List.of(new LineItem("sku-1", 5, 49.99))
        );
        when(inventoryRepository.getAvailableStock("sku-1")).thenReturn(2);

        handler.handle(event);

        verify(inventoryRepository, never()).reserveStock(any(), anyInt(), any());
        verify(eventPublisher).publish(argThat(e -> 
            e instanceof InventoryReservationFailedEvent
        ));
    }
}

This is the fastest and most precise test — pure logic, no infrastructure.

Level 3: Testing Eventual Consistency

Eventual consistency means the system will reach the correct state, but not immediately. Test this with time-bounded assertions:

@SpringBootTest
@Testcontainers
class OrderFulfillmentEventFlowTest {

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

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

    @Autowired
    private OrderService orderService;

    @Autowired
    private InventoryRepository inventoryRepository;

    @Autowired
    private NotificationRepository notificationRepository;

    @Test
    void shouldFulfillOrderEventuallyAfterPlacement() {
        // Set up initial inventory
        inventoryRepository.setStock("sku-1", 10);

        // Place order (publishes OrderPlacedEvent)
        String orderId = orderService.placeOrder(
            new PlaceOrderRequest("customer-1", List.of(new LineItem("sku-1", 2)))
        );

        // The system eventually:
        // 1. Reserves inventory (handled by InventoryService)
        // 2. Triggers payment (handled by PaymentService)
        // 3. Sends confirmation (handled by NotificationService)

        await().atMost(Duration.ofSeconds(30))
               .pollInterval(Duration.ofMillis(500))
               .untilAsserted(() -> {
                   // Inventory reserved
                   assertThat(inventoryRepository.getReservedStock("sku-1")).isEqualTo(2);
                   
                   // Notification sent
                   assertThat(notificationRepository.findByOrderId(orderId))
                       .isNotEmpty()
                       .allMatch(n -> n.getType().equals("ORDER_CONFIRMED"));
               });
    }
}

The key insight: await().untilAsserted() lets you express "this will eventually be true" without knowing exactly when. Always set a reasonable timeout — 30 seconds is usually enough for local tests, but adjust for CI.

Level 4: Testing Saga Patterns

A saga is a sequence of local transactions coordinated by events. When any step fails, compensating transactions run. Testing sagas means testing both the happy path and every compensation path.

Choreography-Based Saga

In choreography, each service listens for events and reacts:

class OrderSagaChoreographyTest {

    // Happy path: Order → Reserve Inventory → Charge Payment → Confirm Order

    @Test
    void happyPath_shouldConfirmOrderWhenAllStepsSucceed() {
        String orderId = placeOrder(validOrder());

        await().atMost(Duration.ofSeconds(20)).untilAsserted(() -> {
            assertThat(orderRepository.getStatus(orderId)).isEqualTo(OrderStatus.CONFIRMED);
            assertThat(inventoryRepository.isReserved("sku-1", orderId)).isTrue();
            assertThat(paymentRepository.getPaymentStatus(orderId)).isEqualTo(PaymentStatus.CHARGED);
        });
    }

    // Compensation path: Order → Reserve Inventory → Payment Fails → Release Inventory → Cancel Order

    @Test
    void compensationPath_shouldCancelOrderAndReleaseInventoryWhenPaymentFails() {
        // Configure payment service to fail for this order
        paymentServiceStub.failFor("order-insufficient-funds");

        String orderId = placeOrder(orderWithId("order-insufficient-funds"));

        await().atMost(Duration.ofSeconds(20)).untilAsserted(() -> {
            assertThat(orderRepository.getStatus(orderId)).isEqualTo(OrderStatus.CANCELLED);
            // Inventory should be released (compensating transaction)
            assertThat(inventoryRepository.isReserved("sku-1", orderId)).isFalse();
            assertThat(inventoryRepository.getAvailableStock("sku-1")).isEqualTo(10); // Back to original
        });
    }
}

Orchestration-Based Saga

In orchestration, a central coordinator drives the saga:

class OrderSagaOrchestratorTest {

    private OrderSagaOrchestrator orchestrator;
    private InventoryService inventoryService;
    private PaymentService paymentService;
    private NotificationService notificationService;

    @BeforeEach
    void setup() {
        inventoryService = mock(InventoryService.class);
        paymentService = mock(PaymentService.class);
        notificationService = mock(NotificationService.class);
        orchestrator = new OrderSagaOrchestrator(
            inventoryService, paymentService, notificationService
        );
    }

    @Test
    void shouldExecuteAllStepsInOrder() {
        when(inventoryService.reserve(any())).thenReturn(ReservationResult.success("res-1"));
        when(paymentService.charge(any())).thenReturn(PaymentResult.success("pay-1"));

        SagaResult result = orchestrator.execute(new PlaceOrderCommand("order-1", 99.99));

        assertThat(result.isSuccess()).isTrue();
        
        InOrder inOrder = inOrder(inventoryService, paymentService, notificationService);
        inOrder.verify(inventoryService).reserve(any());
        inOrder.verify(paymentService).charge(any());
        inOrder.verify(notificationService).sendConfirmation(any());
    }

    @Test
    void shouldCompensateWhenPaymentFails() {
        when(inventoryService.reserve(any())).thenReturn(ReservationResult.success("res-1"));
        when(paymentService.charge(any())).thenReturn(PaymentResult.failure("INSUFFICIENT_FUNDS"));

        SagaResult result = orchestrator.execute(new PlaceOrderCommand("order-1", 99.99));

        assertThat(result.isSuccess()).isFalse();
        assertThat(result.getFailureReason()).isEqualTo("INSUFFICIENT_FUNDS");
        
        // Compensation: inventory reservation should be released
        verify(inventoryService).release("res-1");
        // Notification service should NOT be called
        verify(notificationService, never()).sendConfirmation(any());
    }
}

Level 5: Testing the Outbox Pattern

The outbox pattern solves the dual-write problem: write to your database and publish an event atomically. Without it, you risk publishing an event for a transaction that got rolled back, or missing events when the broker is down.

What the Outbox Pattern Does

Instead of publishing directly to Kafka/SQS:

  1. Write event to an outbox table in the same database transaction as your domain change
  2. A separate relay process reads the outbox table and publishes events to the broker
  3. Events are only lost if the database itself fails — not if the broker is temporarily down

Testing the Outbox Write

class OutboxIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OutboxRepository outboxRepository;

    @Test
    @Transactional
    void shouldWriteEventToOutboxInSameTransaction() {
        orderService.placeOrder(new PlaceOrderRequest("customer-1", List.of(
            new LineItem("sku-1", 1, 49.99)
        )));

        List<OutboxEntry> entries = outboxRepository.findUnpublished();
        assertThat(entries).hasSize(1);

        OutboxEntry entry = entries.get(0);
        assertThat(entry.getAggregateType()).isEqualTo("Order");
        assertThat(entry.getEventType()).isEqualTo("OrderPlaced");
        assertThat(entry.getPayload()).contains("customer-1");
        assertThat(entry.isPublished()).isFalse();
    }

    @Test
    void shouldNotWriteOutboxEntryWhenTransactionRolledBack() {
        // Force a failure after the order is "written" but before commit
        assertThatThrownBy(() -> orderService.placeOrderWithForcedRollback(
            new PlaceOrderRequest("customer-1", List.of())
        ));

        // No outbox entry should exist — transaction rolled back atomically
        assertThat(outboxRepository.findUnpublished()).isEmpty();
    }
}

Testing the Outbox Relay

class OutboxRelayTest {

    private OutboxRelay relay;
    private OutboxRepository outboxRepository;
    private EventPublisher publisher;

    @BeforeEach
    void setup() {
        outboxRepository = mock(OutboxRepository.class);
        publisher = mock(EventPublisher.class);
        relay = new OutboxRelay(outboxRepository, publisher);
    }

    @Test
    void shouldPublishUnpublishedEntriesAndMarkAsPublished() {
        OutboxEntry entry1 = outboxEntry("event-1", "OrderPlaced", "{...}");
        OutboxEntry entry2 = outboxEntry("event-2", "OrderPlaced", "{...}");
        when(outboxRepository.findUnpublished()).thenReturn(List.of(entry1, entry2));

        relay.processOutbox();

        verify(publisher).publish("OrderPlaced", entry1.getPayload());
        verify(publisher).publish("OrderPlaced", entry2.getPayload());
        verify(outboxRepository).markAsPublished("event-1");
        verify(outboxRepository).markAsPublished("event-2");
    }

    @Test
    void shouldNotMarkAsPublishedWhenPublishFails() {
        OutboxEntry entry = outboxEntry("event-1", "OrderPlaced", "{...}");
        when(outboxRepository.findUnpublished()).thenReturn(List.of(entry));
        doThrow(new PublishException("broker unavailable"))
            .when(publisher).publish(any(), any());

        assertThatThrownBy(() -> relay.processOutbox())
            .isInstanceOf(PublishException.class);

        // Should NOT mark as published when publish fails
        verify(outboxRepository, never()).markAsPublished(any());
    }
}

Idempotency Testing

Event handlers must be idempotent — processing the same event twice should produce the same result as processing it once. This is non-negotiable in distributed systems.

class IdempotentHandlerTest {

    @Test
    void shouldNotDuplicateWhenSameEventProcessedTwice() {
        OrderPlacedEvent event = new OrderPlacedEvent("order-123", ...);
        event.setEventId("unique-event-id");

        handler.handle(event);
        handler.handle(event); // Same event, second time

        // Order created exactly once
        assertThat(orderRepository.countByOrderId("order-123")).isEqualTo(1);
        // Payment charged exactly once
        verify(paymentService, times(1)).charge(any());
    }
}

Common Pitfalls

Testing only the happy path. In event-driven systems, the compensation paths are just as important. Test every failure scenario.

Not testing idempotency. At-least-once delivery is standard. Your handlers will receive duplicates. Test for it.

Assertions without timeouts. assertThat(something).isTrue() after an async operation will flake. Always use await().atMost(...).

Not testing schema compatibility. Adding a required field to an event will break existing consumers. Test forward and backward compatibility.

Continuous End-to-End Monitoring

Integration tests catch bugs in dev, but event-driven flows can degrade silently in production — events pile up, consumers fall behind, compensation transactions stop firing. HelpMeTest monitors the observable outcomes of your event flows continuously, catching degradation before users notice. Set up monitoring at helpmetest.com.

Summary

  • Test events structurally (schema validation) at the lowest level
  • Test handlers in isolation with known input events
  • Use await().atMost().untilAsserted() for eventual consistency assertions
  • Test every saga compensation path, not just the happy path
  • Test the outbox pattern: both the atomic write and the relay's failure modes
  • Always test idempotency — at-least-once delivery is the default
Start now free