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:
- Write event to an
outboxtable in the same database transaction as your domain change - A separate relay process reads the outbox table and publishes events to the broker
- 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