Skip to content
Testing

Testing in EDA: how to verify what you cannot see 🧪

In a synchronous system, if the test passes you have some confidence. In an event-driven system, a test can pass and the system can still fail in production because you tested the publisher without verifying that the consumer can interpret what it receives. This guide tackles exactly that problem.

The testing pyramid adapted for EDA 🔺

The classic pyramid (unit → integration → e2e) adapts like this:

    flowchart TD
  subgraph Top
    E2E[E2E — complete business flows]
  end
  subgraph Middle
    INT[Integration tests with a real broker]
    CC[Consumer contract tests]
  end
  subgraph Base
    UNIT[Unit tests — handler logic]
    SCHEMA[Schema validation tests]
  end
  

The most critical layer in EDA is the middle one: contract tests and integration tests with a real broker catch most interface bugs between services—the ones that unit tests cannot intercept because they only test one side.

Unit tests: isolate the handler 🔬

The consumer is first of all a function (event) → side effect. It is testable in isolation, without a broker.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func TestProcessOrderCreated(t *testing.T) {
    repo := &FakeOrderRepo{}
    svc := NewOrderService(repo)

    event := OrderCreated{OrderID: "ord-123", CustomerID: "cli-456", Amount: 99.90}
    err := svc.Process(event)

    assert.NoError(t, err)
    assert.True(t, repo.WasSaved("ord-123"))
}

Unit tests should cover:

  • business logic of the consumer;
  • event → internal model mapping;
  • error cases (missing field, invalid value, unexpected type);
  • idempotency: processing the same event twice must produce the same result.

Schema validation tests 📋

Before an event reaches production, verify that it respects the declared schema—and that versions are compatible with one another.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func TestOrderCreatedSchema(t *testing.T) {
    schema := loadAvroSchema("order-created.avsc")

    payload := map[string]interface{}{
        "order_id":  "ord-123",
        "customer_id": "cli-456",
        "amount":    99.90,
        "channel":   "web",
    }
    assert.NoError(t, schema.Validate(payload))

    oldPayload := map[string]interface{}{
        "order_id":  "ord-123",
        "customer_id": "cli-456",
        "amount":    99.90,
    }
    assert.NoError(t, schema.Validate(oldPayload))
}

Integrate schema validation into CI: a breaking change must fail the pipeline, not arrive in staging.

Consumer contract testing with Pact 🤝

Consumer contract testing verifies that the producer and consumer agree on the event contract. The test is written by the consumer (declaring what it expects) and verified by the producer (which must satisfy these expectations).

    flowchart LR
  C[Consumer<br/>(writes the test)] -->|generates| PACT[Pact file<br/>(JSON contract)]
  PACT --> PB[(Pact Broker)]
  P[Producer] -->|verifies| PB
  P -->|publishes result| PB
  PB -->|can-i-deploy?| C
  PB -->|can-i-deploy?| P
  

Practical flow:

  1. The consumer team writes a test that declares: “I expect an OrderCreated event with these fields and types.”
  2. Pact generates a pact file (JSON) describing the contract.
  3. The pact file is published to a Pact Broker.
  4. In the producer’s CI, the verification runs: the producer must produce a message that satisfies all registered pacts.
  5. Before deployment, can-i-deploy? blocks release if there are broken consumers.

What Pact is not: it does not test broker behavior and is not an e2e test. It verifies only the contract between producer and consumer—which is exactly what is usually missing.

Tools: Pact (Go, Java, JS, Python, .NET), PactFlow for the hosted broker with UI.

Integration tests with a real broker 🧰

To test the full flow (producer → broker → consumer), you need a real broker. Testcontainers can start Kafka, RabbitMQ, and others as Docker containers during tests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestFullOrderFlow(t *testing.T) {
    ctx := context.Background()

    kafkaContainer, err := kafka.RunContainer(ctx,
        kafka.WithClusterID("test-cluster"),
        testcontainers.WithImage("confluentinc/cp-kafka:7.5.0"),
    )
    require.NoError(t, err)
    defer kafkaContainer.Terminate(ctx)

    brokers, err := kafkaContainer.Brokers(ctx)
    require.NoError(t, err)
    producer := newTestProducer(brokers[0])
    consumer := newTestConsumer(brokers[0], "order-created")

    err = producer.Publish(OrderCreated{OrderID: "ord-123", Amount: 99.90})
    require.NoError(t, err)

    msg, err := consumer.ReadWithTimeout(5 * time.Second)
    require.NoError(t, err)
    assert.Equal(t, "ord-123", msg.OrderID)
}

Tools:

Idempotency tests ↩️

An idempotent consumer must produce the same result if it receives the same event multiple times. This must be tested explicitly—do not assume it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func TestConsumerIdempotency(t *testing.T) {
    repo := newInMemoryRepo()
    consumer := NewOrderConsumer(repo, newDedupStore())
    event := OrderCreated{OrderID: "ord-123", EventID: "evt-abc"}

    require.NoError(t, consumer.Process(event))
    count1, err := repo.CountOrders("ord-123")
    require.NoError(t, err)

    require.NoError(t, consumer.Process(event))
    count2, err := repo.CountOrders("ord-123")
    require.NoError(t, err)

    assert.Equal(t, 1, count1)
    assert.Equal(t, count1, count2)
}

Error handling tests: retry and DLQ 🧯

Verify that error behavior is what you expect:

  • an event with invalid payload ends in DLQ after N attempts;
  • a transient error causes retry with backoff and then success;
  • the retry does not break idempotency (a successful second attempt equals a successful first attempt).
1
2
3
4
5
6
7
8
func TestInvalidEventToDLQ(t *testing.T) {
    err := producer.PublishRaw([]byte(`{"order_id": null}`))
    require.NoError(t, err)

    dlqMsg, err := dlqConsumer.ReadWithTimeout(10 * time.Second)
    require.NoError(t, err)
    assert.Contains(t, dlqMsg.Headers["error-reason"], "schema validation")
}

Ordering tests (when it is an explicit requirement) 🧵

If ordering per key is a system requirement, test it with multi-message scenarios. Publish N events for the same key in order and verify that the consumer receives them in the same order.

This test is often missing and the problem is discovered only in production during reprocessing or failover.

EDA testing checklist ✅

  • Unit tests for every consumer handler (logic, mapping, error cases, idempotency).
  • Schema validation tests in CI (verify compatibility between schema versions).
  • Consumer contract tests (Pact or equivalent) for contracts between teams.
  • Integration tests with a real broker (Testcontainers / LocalStack) for the critical flows.
  • Explicit idempotency tests for duplicates.
  • Error handling tests: transient errors (retry + backoff), permanent errors (DLQ).
  • Ordering tests for flows that require it.
  • A staging environment with controlled replay to validate new projections or new consumers.

Next steps 🚀

Last updated on