Vai al contenuto
Testing

Testing in EDA: come verificare ciò che non vedi 🧪

In un sistema sincrono, se il test passa hai una certa fiducia. In un sistema event-driven, il test può passare e il sistema comunque fallire in produzione — perché hai testato il publisher senza verificare che il consumer sappia interpretare quello che riceve. Questa guida affronta esattamente questo problema.

La piramide di testing adattata all’EDA 🔺

La piramide classica (unit → integration → e2e) si adatta così:

    flowchart TD
  subgraph Alto
    E2E[E2E — flussi di business completi]
  end
  subgraph Medio
    INT[Integration test con broker reale]
    CC[Consumer contract test]
  end
  subgraph Base
    UNIT[Unit test — logica handler]
    SCHEMA[Schema validation test]
  end
  

Il livello più critico nei sistemi EDA è quello medio: i contract test e i test di integrazione con broker reale rilevano il 90% dei bug di interfaccia tra servizi — quelli che le unit test non possono intercettare perché testano un solo lato.

Unit test: isola l’handler 🔬

Il consumer è prima di tutto una funzione (evento) → side effect. È testabile in isolamento, senza broker.

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

    evento := OrdineCreato{OrdineID: "ord-123", ClienteID: "cli-456", Importo: 99.90}
    err := svc.Processa(evento)

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

Da coprire con unit test:

  • logica di business del consumer;
  • mapping evento → modello interno;
  • casi di errore (campo mancante, valore invalido, tipo inatteso);
  • idempotenza: processare lo stesso evento due volte deve produrre lo stesso risultato.

Schema validation test 📋

Prima che un evento arrivi in produzione, verifica che rispetti lo schema dichiarato — e che le versioni siano compatibili tra loro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func TestSchemaOrdineCreato(t *testing.T) {
    schema := loadAvroSchema("ordine-creato.avsc")

    // payload corrente: include il campo "canale" aggiunto nella v2 dello schema
    payload := map[string]interface{}{
        "ordine_id":  "ord-123",
        "cliente_id": "cli-456",
        "importo":    99.90,
        "canale":     "web",
    }
    assert.NoError(t, schema.Validate(payload))

    // backward compatibility: payload vecchio (campo "canale" assente) deve essere ok con default
    oldPayload := map[string]interface{}{
        "ordine_id":  "ord-123",
        "cliente_id": "cli-456",
        "importo":    99.90,
        // "canale" assente → il consumer deve ricevere il valore di default
    }
    assert.NoError(t, schema.Validate(oldPayload))
}

Integra la schema validation nel CI: un breaking change deve fare fallire la pipeline, non arrivare in staging.

Consumer contract testing con Pact 🤝

Il consumer contract testing verifica che producer e consumer concordino sul contratto dell’evento. Il test viene scritto dal consumer (che dichiara cosa si aspetta) e verificato dal producer (che deve soddisfare le aspettative dichiarate).

    flowchart LR
  C["Consumer<br/>(scrive il test)"] -->|genera| PACT["Pact file<br/>(contratto JSON)"]
  PACT --> PB[(Pact Broker)]
  P[Producer] -->|verifica| PB
  P -->|pubblica risultato| PB
  PB -->|can-i-deploy?| C
  PB -->|can-i-deploy?| P
  

Flusso pratico:

  1. Il team consumer scrive un test che dichiara: “mi aspetto un evento OrdineCreato con questi campi e questi tipi”.
  2. Pact genera un pact file (JSON) che descrive il contratto.
  3. Il pact file viene pubblicato su un Pact Broker.
  4. Nel CI del producer, si esegue la verifica: il producer deve produrre un messaggio che soddisfa tutti i pact registrati.
  5. Prima del deploy, si esegue can-i-deploy? per bloccare il rilascio se esistono consumer rotti.

Cosa non è Pact: non testa il comportamento del broker e non è un test e2e. Verifica solo il contratto tra producer e consumer — che è esattamente quello che di solito manca.

Strumenti: Pact (Go, Java, JS, Python, .NET), PactFlow per il broker hosted con UI.

Integration test con broker reale 🧰

Per testare il flusso completo (producer → broker → consumer) serve un broker reale. Testcontainers permette di avviare Kafka, RabbitMQ, ecc. come container Docker durante i test.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestFlussoOrdineCompleto(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], "ordine-creato")

    err = producer.Publish(OrdineCreato{OrdineID: "ord-123", Importo: 99.90})
    require.NoError(t, err)

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

Strumenti:

Test di idempotenza ↩️

Un consumer idempotente deve produrre lo stesso risultato se riceve lo stesso evento più volte. Questo va testato esplicitamente — non basta assumerlo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func TestIdempotenzaConsumer(t *testing.T) {
    repo := newInMemoryRepo()
    consumer := NewOrdineConsumer(repo, newDedupStore())
    evento := OrdineCreato{OrdineID: "ord-123", EventID: "evt-abc"}

    // prima elaborazione
    require.NoError(t, consumer.Processa(evento))
    count1, err := repo.CountOrdini("ord-123")
    require.NoError(t, err)

    // seconda elaborazione (duplicato)
    require.NoError(t, consumer.Processa(evento))
    count2, err := repo.CountOrdini("ord-123")
    require.NoError(t, err)

    // il risultato deve essere identico
    assert.Equal(t, 1, count1)
    assert.Equal(t, count1, count2)
}

Test di error handling: retry e DLQ 🧯

Verifica che il comportamento su errore sia quello atteso:

  • un evento con payload invalido finisce in DLQ dopo N tentativi;
  • un errore transitorio provoca retry con backoff e poi successo;
  • il retry non rompe l’idempotenza (processo riuscito al secondo tentativo = stesso stato di un successo al primo).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func TestEventoInvalidoVersoDLQ(t *testing.T) {
    // producer e dlqConsumer sono fixture di test inizializzate nel setup (es. TestMain o suite)
    err := producer.PublishRaw([]byte(`{"ordine_id": null}`))
    require.NoError(t, err)

    // attendi che finisca in DLQ dopo i retry
    dlqMsg, err := dlqConsumer.ReadWithTimeout(10 * time.Second)
    require.NoError(t, err)
    assert.Contains(t, dlqMsg.Headers["error-reason"], "schema validation")
}

Test di ordering (quando è un requisito esplicito) 🧵

Se l’ordering per chiave è un requisito del sistema, testalo con scenari multi-messaggio. Pubblica N eventi per la stessa chiave in ordine, verifica che il consumer li riceva nello stesso ordine.

Questo test è spesso mancante e si scopre il problema solo in produzione, durante un reprocessing o un failover.

Checklist testing EDA ✅

  • Unit test per ogni consumer handler (logica, mapping, casi di errore, idempotenza).
  • Schema validation test in CI (verifica compatibilità tra versioni di schema).
  • Consumer contract test (Pact o equivalente) per i contratti tra team.
  • Integration test con broker reale (Testcontainers / LocalStack) almeno per i flussi critici.
  • Test espliciti di idempotenza su duplicati.
  • Test di error handling: errori transitori (retry + backoff), errori permanenti (DLQ).
  • Test di ordering per i flussi che lo richiedono.
  • Ambiente di staging con replay controllato per validare nuove proiezioni o nuovi consumer.

Prossimi passi 🚀

Ultimo aggiornamento il