Stage 7 · Master
Phase 16 — Kafka Integration
Event Testing
Give this module three distinct test layers — none of which duplicate each other — so a broken poison-classification rule, a broken idempotency guarantee, and a silent schema drift each fail loudly in the layer built to catch it.
Unit, Integration, and Contract Tests Each Catch a Different Kind of Bug
Two of these layers are familiar and are set up exactly as earlier phases established them. The third is new, and it exists because event-driven services fail in a way request/response services cannot: producer and consumer are deployed separately, so nothing forces them to agree on what an envelope looks like. A rename that would have been a compile error inside one service becomes a runtime decode failure in a different service, discovered whenever that service next consumes. The contract fixture below is the only thing in this module that catches that drift before deploy.
Table-Driven Tests for ErrPoisonEvent Classification
package consumer
import (
"errors"
"testing"
)
func TestErrPoisonEvent_Classification(t *testing.T) {
cases := []struct {
name string
err error
wantPoison bool
}{
{"wrapped poison event", errPoisonEvent(errors.New("bad json")), true},
{"plain database error", errors.New("connection refused"), false},
{"nil is not poison", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := errors.Is(tc.err, ErrPoisonEvent)
if got != tc.wantPoison {
t.Fatalf("errors.Is(%v, ErrPoisonEvent) = %v, want %v", tc.err, got, tc.wantPoison)
}
})
}
}
docker-compose.test.yml Gains a kafka-test Service
Phase 13 and Phase 14's integration tests already run against a disposable postgres-test service defined in docker-compose.test.yml. This module's integration tests need a disposable Kafka broker alongside it, so the compose file gains one more service rather than a second, parallel test-infra file.
kafka-test:
image: bitnami/kafka:3.7
environment:
KAFKA_CFG_NODE_ID: "1"
KAFKA_CFG_PROCESS_ROLES: "broker,controller"
KAFKA_CFG_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
KAFKA_CFG_ADVERTISED_LISTENERS: "PLAINTEXT://kafka-test:9092"
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: "1@kafka-test:9093"
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
ALLOW_PLAINTEXT_LISTENER: "yes"
ports:
- "19092:9092"
TestHandlePaymentCompleted_DuplicateDeliveryCreatesOneJob
This is the single most important test in this module. Everything from Lesson 2's at-least-once acceptance through Lesson 3's inbox claim exists to guarantee one specific outcome: a redelivered event never creates a second job. This test asserts exactly that outcome directly, rather than trusting the individual pieces to compose correctly by inspection.
//go:build integration
package consumer_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/notification-service/internal/consumer"
)
func TestHandlePaymentCompleted_DuplicateDeliveryCreatesOneJob(t *testing.T) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, testDatabaseURL(t))
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
handler := consumer.NewHandler(pool, consumer.NewInboxStore())
raw := samplePaymentCompletedJSON(uuid.New())
if err := handler.HandlePaymentCompleted(ctx, "payment.completed", raw); err != nil {
t.Fatalf("first delivery: %v", err)
}
if err := handler.HandlePaymentCompleted(ctx, "payment.completed", raw); err != nil {
t.Fatalf("redelivery: %v", err)
}
var count int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM notification_jobs").Scan(&count); err != nil {
t.Fatalf("count jobs: %v", err)
}
if count != 1 {
t.Fatalf("notification_jobs count = %d, want 1 after duplicate delivery", count)
}
}
Both are small unshown helper functions local to this test package — the first reads NOTIFICATION_SERVICE_TEST_DB_URL (set by whatever runs against docker-compose.test.yml's postgres-test), the second returns a fixed valid JSON envelope with the given event_id substituted in, matching Lesson 3's paymentCompletedEnvelope shape exactly.
A Golden Fixture Shared Between Both Services Catches Silent Envelope Drift
Neither the unit test nor the integration test above would notice if payment-service's relay started producing occurred_at as a Unix timestamp instead of an RFC3339 string — the integration test uses this package's own fixture, which would simply be updated to match, silently hiding the drift instead of catching it. A contract test closes that gap by having both services read the exact same fixture file as their source of truth for what an envelope looks like.
{
"event_id": "00000000-0000-0000-0000-000000000001",
"event_type": "payment.completed",
"event_version": 1,
"org_id": "00000000-0000-0000-0000-000000000002",
"aggregate_id": "00000000-0000-0000-0000-000000000003",
"occurred_at": "2024-01-01T00:00:00Z",
"payload": {
"amount_paise": 100000,
"currency": "INR",
"provider_reference": "pay_contract_fixture"
}
}
payment-service's own test suite gains an assertion that its envelope-building code, given fixed inputs matching this fixture's identifiers, produces byte-for-byte this JSON. notification-service's test suite gains the mirror assertion: that unmarshaling this exact file into paymentCompletedEnvelope succeeds and populates every field correctly. Neither service reads the other's Go types directly — the shared JSON file is the entire contract, checked independently by each side.
Run Each Layer and Confirm It Fails for the Right Reason
Applied exercise
Add a golden-fixture test for the DLQ envelope
The payment_completed.v1.json contract above only covers the happy-path envelope. The DLQ envelope from Lesson 6 has no equivalent contract test yet, and it is exactly as prone to silent drift.
- Create testdata/contracts/payment_completed_dlq.v1.json with a fixed, representative deadLetter payload.
- Write a test in notification-service asserting DLQProducer.Send's marshaled output matches the fixture for fixed inputs.
- Explain why this fixture only needs one side (notification-service both produces and would consume its own DLQ messages), unlike payment_completed.v1.json which needs both services.
Deliverable
The new fixture file plus the passing test asserting against it.
Completion checks
- The test fails if a field is renamed or removed from the deadLetter struct without updating the fixture — proving the contract actually constrains the code.
- The explanation correctly identifies that the DLQ envelope is single-producer/single-consumer within this service, unlike the cross-service payment.completed contract.
Why does this lesson keep unit, integration, and contract tests as three separate layers instead of one comprehensive integration suite that exercises everything?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.