Event-Driven Architecture Testing Patterns
Event-driven systems fail in slow, invisible ways. A service publishes an event with a renamed field. The consumers don't know yet. Production breaks hours later when the queue drains. Your unit tests all pass because each service tests in isolation.
This is the core challenge of EDA testing: the contracts live at the boundaries, not inside services. Getting this right requires a different testing strategy than what works for synchronous REST APIs.
The Testing Pyramid Is Wrong for EDA
The standard testing pyramid assumes that unit tests catch most bugs and integration tests are expensive edge cases. In event-driven systems, the most dangerous bugs — schema drift, routing failures, saga logic errors — can't be caught by unit tests at all. You need tests that operate at the integration level, and you need them to run fast enough to be in CI.
The practical pyramid for EDA:
- Contract tests — verify event schemas between producers and consumers (fast, can run in CI)
- Component tests — test a single service with real broker, mocked downstream (medium)
- End-to-end flow tests — test complete sagas across real services (slow, run on merge)
Contract Testing Events with Pact
Pact was designed for synchronous HTTP, but its message pact support handles async events well. The consumer defines what events it expects, the producer verifies it can produce them.
Consumer side — define the contract:
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "OrderService", providerType = ProviderType.ASYNCH)
class NotificationServicePactTest {
@Pact(consumer = "NotificationService")
MessagePact orderCreatedContract(MessagePactBuilder builder) {
return builder
.hasPactWith("OrderService")
.expectsToReceive("an order created event")
.withContent(new PactDslJsonBody()
.stringType("orderId")
.stringType("userId")
.decimalType("amount")
.stringMatcher("status", "CREATED|PENDING", "CREATED")
.datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ss'Z'")
)
.toPact();
}
@Test
@PactTestFor(pactMethod = "orderCreatedContract")
void consumer_handlesOrderCreatedEvent(List<Message> messages) {
// Verify the consumer can handle an event matching the contract
String eventJson = new String(messages.get(0).contentsAsBytes());
NotificationRequest request = notificationHandler.handle(eventJson);
assertThat(request.getUserId()).isNotNull();
assertThat(request.getMessage()).contains("Order");
}
}Producer side — verify the contract is satisfied:
@Provider("OrderService")
@PactFolder("pacts")
class OrderServicePactVerificationTest {
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerification(PactVerificationContext context) {
context.verifyInteraction();
}
@State("an order exists")
void orderExists() {
// Set up test state
}
@PactVerifyProvider("an order created event")
String orderCreatedEvent() {
Order order = new Order("test-123", "user-456", BigDecimal.valueOf(99.00));
OrderCreatedEvent event = OrderCreatedEvent.from(order);
return new ObjectMapper().writeValueAsString(event);
}
}When the consumer publishes a new pact, the CI pipeline for the producer runs verification. Schema drift fails fast — before it reaches production.
Testing Choreography Sagas
In choreography, services react to each other's events without a central coordinator. The saga logic is implicit in the event flow, which makes it hard to test in isolation.
The approach: use Testcontainers to run a real broker, start all participating services with their test profiles, and verify the end state after triggering the initial event.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderFulfillmentSagaTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
).withReuse(true);
// All services connect to the same broker
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
@Autowired
private OrderRepository orderRepository;
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private ShipmentRepository shipmentRepository;
@Test
void saga_orderPlacedTriggersFullFulfillmentFlow() {
// Trigger: place an order
OrderPlacedEvent event = new OrderPlacedEvent("saga-order-1", "user-789",
List.of(new LineItem("product-A", 2)));
kafkaTemplate.send("orders.placed", event.getOrderId(), event);
// Eventually: order is reserved in inventory, shipment is created
await().atMost(Duration.ofSeconds(30))
.untilAsserted(() -> {
assertThat(inventoryRepository.findReservation("saga-order-1")).isPresent();
assertThat(shipmentRepository.findByOrderId("saga-order-1")).isPresent();
assertThat(orderRepository.findById("saga-order-1"))
.hasValueSatisfying(o -> assertThat(o.getStatus()).isEqualTo("FULFILLED"));
});
}
@Test
void saga_outOfStockTriggersOrderCancellation() {
// Pre-condition: inventory has 0 units of product-B
inventoryRepository.setStock("product-B", 0);
OrderPlacedEvent event = new OrderPlacedEvent("saga-order-2", "user-789",
List.of(new LineItem("product-B", 1)));
kafkaTemplate.send("orders.placed", event.getOrderId(), event);
await().atMost(Duration.ofSeconds(30))
.untilAsserted(() -> {
assertThat(orderRepository.findById("saga-order-2"))
.hasValueSatisfying(o -> assertThat(o.getStatus()).isEqualTo("CANCELLED"));
// Compensation: no shipment created
assertThat(shipmentRepository.findByOrderId("saga-order-2")).isEmpty();
});
}
}Testing Orchestration Sagas
Orchestration sagas have a central coordinator that issues commands and reacts to events. These are easier to test because the logic is centralized, but you need to test both the happy path and every compensation path.
@Test
void orchestrator_compensatesInventoryOnPaymentFailure() {
// Start the saga
String sagaId = sagaOrchestrator.start(new CreateOrderCommand("order-comp-1", "user-fail", items));
// Simulate: inventory reserved successfully
sagaOrchestrator.handleEvent(new InventoryReservedEvent(sagaId, "order-comp-1"));
// Simulate: payment fails
sagaOrchestrator.handleEvent(new PaymentFailedEvent(sagaId, "order-comp-1", "INSUFFICIENT_FUNDS"));
// Assert: compensation command was issued
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
List<Command> issued = commandCaptor.getCapturedCommands(sagaId);
assertThat(issued).extracting(Command::getType)
.contains("RELEASE_INVENTORY");
assertThat(orderRepository.findById("order-comp-1"))
.hasValueSatisfying(o -> assertThat(o.getStatus()).isEqualTo("CANCELLED"));
});
}Testing Eventual Consistency
Eventual consistency means that after a write, reads may temporarily return stale data. Tests that assert immediately after a write will be flaky. The pattern is: assert on the final consistent state with a timeout, never assert on intermediate state.
Bad:
orderService.placeOrder(order);
assertThat(readModelRepository.findOrder(order.getId())).isPresent(); // flakyGood:
orderService.placeOrder(order);
await().atMost(Duration.ofSeconds(15))
.pollInterval(Duration.ofMillis(100))
.untilAsserted(() ->
assertThat(readModelRepository.findOrder(order.getId()))
.isPresent()
.hasValueSatisfying(view -> {
assertThat(view.getStatus()).isEqualTo("PLACED");
assertThat(view.getTotal()).isEqualByComparingTo(order.getTotal());
})
);For testing read model projections specifically, publish events directly to the event store and assert on the projection:
@Test
void projection_buildsOrderViewFromEvents() {
String orderId = "proj-order-1";
// Replay events in sequence
eventBus.publish(new OrderCreatedEvent(orderId, "user-1", BigDecimal.valueOf(100)));
eventBus.publish(new OrderItemAddedEvent(orderId, new LineItem("sku-A", 2, BigDecimal.valueOf(50))));
eventBus.publish(new OrderConfirmedEvent(orderId));
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
OrderView view = orderViewRepository.findById(orderId).orElseThrow();
assertThat(view.getStatus()).isEqualTo("CONFIRMED");
assertThat(view.getItems()).hasSize(1);
assertThat(view.getTotal()).isEqualByComparingTo("100.00");
});
}Testing with WireMock for Downstream HTTP Calls
When an event consumer needs to call a synchronous HTTP service as part of processing, mock that service with WireMock:
@Autowired
private WireMockServer wireMock;
@Test
void consumer_callsPaymentServiceOnOrderReceived() {
wireMock.stubFor(post(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.orderId", equalTo("order-wire-1")))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"transactionId\": \"txn-abc\"}")
)
);
kafkaTemplate.send("orders", "order-wire-1",
new Order("order-wire-1", "user-1", BigDecimal.valueOf(50)));
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() ->
wireMock.verify(postRequestedFor(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.orderId", equalTo("order-wire-1"))))
);
}Schema Evolution Testing
Events change over time. Test that your consumers handle both old and new schema versions:
@ParameterizedTest
@MethodSource("orderEventVersions")
void consumer_handlesAllEventVersions(String eventJson) {
kafkaTemplate.send("orders", "order-schema-test",
new RawEventMessage(eventJson));
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() ->
assertThat(orderRepository.findById("order-schema-test")).isPresent()
);
}
static Stream<String> orderEventVersions() {
return Stream.of(
// v1 schema — orderId field
"{\"orderId\": \"order-schema-test\", \"userId\": \"u1\", \"amount\": 10.00}",
// v2 schema — id field (renamed)
"{\"id\": \"order-schema-test\", \"userId\": \"u1\", \"amount\": 10.00, \"currency\": \"USD\"}",
// v3 schema — nested customer object
"{\"id\": \"order-schema-test\", \"customer\": {\"id\": \"u1\"}, \"amount\": 10.00, \"currency\": \"USD\"}"
);
}Making EDA Tests Reliable
The main reliability challenge is timing. A few rules:
Always use awaitility with meaningful timeouts. 15-30 seconds for saga tests is reasonable. Don't use Thread.sleep.
Reset broker state between tests. Purge queues in @BeforeEach, or use unique topic names per test. Message pollution from previous tests causes intermittent failures that are nearly impossible to diagnose.
Test idempotency. Publish the same event twice and assert the system handles it correctly. This is especially important for saga steps that trigger side effects.
Test timeouts explicitly. If your saga has a timeout (e.g., payment must complete within 60 seconds), test what happens when the timeout fires. You can mock a slow downstream with WireMock's delay configuration.
Bringing It Together
EDA testing is not harder than REST API testing — it's just different. The key shift is from testing inputs/outputs to testing event flows and final states. Contract tests catch schema drift at the boundary. Component tests verify individual service behavior with a real broker. Saga tests verify end-to-end flows including compensation.
HelpMeTest extends this further by running behavioral tests against your deployed system — verifying that user-visible outcomes match what your event flows are supposed to produce. When the events say an order was fulfilled but the UI still shows it as pending, HelpMeTest catches that.