The most surprising thing about testing event-driven systems end-to-end without mocking is that you can achieve high confidence by focusing on the interactions and eventual consistency, not by trying to simulate every single component’s internal state.

Let’s watch a simple order processing system in action.

Imagine a customer places an order. This triggers an OrderPlaced event.

{
  "eventType": "OrderPlaced",
  "eventId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "timestamp": "2023-10-27T10:00:00Z",
  "payload": {
    "orderId": "ORD12345",
    "customerId": "CUST987",
    "items": [
      {"sku": "SKU001", "quantity": 2},
      {"sku": "SKU005", "quantity": 1}
    ],
    "totalAmount": 150.75
  }
}

This event is published to a message broker, say Kafka, on a topic named orders.

A PaymentService subscribes to orders. It consumes the OrderPlaced event, processes the payment, and publishes a PaymentProcessed event.

{
  "eventType": "PaymentProcessed",
  "eventId": "b2c3d4e5-f6a7-8901-2345-67890abcdef1",
  "timestamp": "2023-10-27T10:01:00Z",
  "payload": {
    "orderId": "ORD12345",
    "paymentId": "PAY9876",
    "status": "SUCCESS",
    "amount": 150.75
  }
}

This event goes to a payments topic.

An InventoryService also subscribes to orders. It consumes the OrderPlaced event, attempts to reserve the items, and publishes an InventoryReserved event.

{
  "eventType": "InventoryReserved",
  "eventId": "c3d4e5f6-a7b8-9012-3456-7890abcdef12",
  "timestamp": "2023-10-27T10:01:30Z",
  "payload": {
    "orderId": "ORD12345",
    "status": "SUCCESS",
    "reservedItems": [
      {"sku": "SKU001", "quantity": 2},
      {"sku": "SKU005", "quantity": 1}
    ]
  }
}

This event goes to an inventory topic.

Finally, an OrderService subscribes to both payments and inventory topics. When it sees PaymentProcessed and InventoryReserved for the same orderId, it updates the order status to PROCESSING and publishes an OrderProcessingStarted event.

The core problem event-driven systems solve is decoupling. Services don’t call each other directly; they react to events. This makes them resilient and scalable, but testing them end-to-end is tricky. You can’t just call a function and assert a return value. You need to assert that the right events are produced and that the eventual state across services is consistent.

Your mental model should revolve around event flows and state transformations. Think of events as immutable facts that propagate through the system, triggering state changes in different services. The "end-to-end" test isn’t about simulating a single request’s journey through synchronous calls. It’s about simulating an initial event and observing the cascade of subsequent events and the final, consistent state of the involved services.

The key to testing this without heavy mocking is to use your message broker as the central nervous system. Your test framework should be able to:

  1. Publish an initial event (e.g., OrderPlaced) to the relevant topic.
  2. Consume events from other topics (e.g., payments, inventory, orders) and assert their contents.
  3. Query the state of individual services (e.g., via their API or database) to verify final consistency.

For example, a test might look like this:

  1. Publish an OrderPlaced event to orders.
  2. Assert that a PaymentProcessed event appears on payments within 5 seconds, with status: "SUCCESS".
  3. Assert that an InventoryReserved event appears on inventory within 5 seconds, with status: "SUCCESS".
  4. Assert that an OrderProcessingStarted event appears on orders within 10 seconds.
  5. Query the OrderService’s database to confirm the order ORD12345 has status: "PROCESSING".

This approach verifies the entire flow, from event emission to state change, without needing to mock the internal logic of the PaymentService or InventoryService. You’re testing their behavior in response to events, which is what matters for end-to-end correctness.

A common pitfall is to try and assert that services immediately reach a consistent state. Event-driven systems are inherently asynchronous. The OrderService might not see the PaymentProcessed and InventoryReserved events simultaneously. It reacts to them as they arrive. Your tests must account for this eventual consistency, using timeouts and retry mechanisms when consuming events. You also need to consider distributed tracing for debugging, as an event’s journey can be complex.

The next concept you’ll grapple with is handling failures and idempotency in this event-driven landscape.

Want structured learning?

Take the full Event-driven course →