Event Contract Testing: Producer Tests, Consumer Tests, Schema Registry Gates, and Replay Tests
Learn Java API Contract Engineering, Event Contract Engineering & Schema Governance - Part 021
Event contract testing for Java event-driven systems: producer tests, consumer tests, schema registry gates, golden event samples, replay tests, DLQ tests, Testcontainers, and false-confidence traps.
Part 021 — Event Contract Testing: Producer Tests, Consumer Tests, Schema Registry Gates, and Replay Tests
Tujuan Pembelajaran
Event contract tanpa test adalah asumsi yang dikirim ke broker. Dalam sistem event-driven, kontrak bisa drift di banyak tempat sekaligus:
- producer mengubah payload;
- schema registry menerima schema yang ternyata secara semantic breaking;
- Kafka key berubah;
- topic retention berubah;
- consumer tidak tahan unknown event type;
- retry topic mengubah ordering;
- DLQ kehilangan original envelope;
- replay memicu side effect ulang;
- generated Java class berubah;
- event example di dokumentasi sudah tidak valid.
Part ini membahas cara membuktikan event contract secara otomatis.
Setelah part ini, kamu harus mampu:
- membuat producer contract tests untuk topic, key, envelope, schema, headers, and semantic trigger;
- membuat consumer contract tests untuk duplicate, unknown event type, out-of-order, old schema, poison message, and DLQ behavior;
- memakai schema registry compatibility gates di CI;
- membuat golden event samples untuk regression and documentation;
- menjalankan Kafka-oriented integration tests dengan Testcontainers;
- membuat replay tests untuk projection rebuild;
- membedakan schema compatibility test dan semantic contract test;
- mendesain CI pipeline untuk event contract changes;
- menghindari false confidence dari tests yang hanya memvalidasi schema.
1. Why Event Contract Testing Is Harder Than API Contract Testing
HTTP API contract test biasanya menguji:
request -> response
Event contract test harus menguji:
domain action -> event persisted -> event published -> broker metadata -> schema -> consumer behavior -> replay behavior
Drift can happen at each node.
2. Event Contract Test Taxonomy
| Test type | Purpose | Example |
|---|---|---|
| Schema validation test | payload/envelope matches schema | CaseApproved validates against Avro/JSON Schema/Protobuf |
| Schema compatibility test | new schema compatible with old versions | registry check backward/full/transitive |
| Producer contract test | producer emits correct event | approval publishes CaseApproved to case-events with key caseId |
| Consumer contract test | consumer handles documented event | projection updates when receiving CaseApproved |
| Golden sample test | examples remain valid and parseable | case-approved-v1.json still maps |
| Replay test | historical events rebuild state | replay all Case* events to projection |
| Idempotency test | duplicate event harmless | same eventId processed once |
| Ordering test | out-of-order behavior correct | version gap quarantined |
| DLQ test | poison message preserves original context | invalid payload sent to case-events-dlq |
| Registry gate | incompatible schema blocked before merge | added required Avro field without default fails |
| Generated code test | Java generated models compile and parse | Avro/Protobuf generated code compiles |
| Semantic test | meaning remains correct | CaseApproved emitted only after commit |
A mature event platform uses several of these, not just one.
3. The Three Contract Truths
For events, there are at least three truths:
Testing must answer:
- Does producer publish what contract says?
- Can consumer handle what contract says?
- Does declared contract match actual examples?
- Does registry prevent unsafe structural changes?
- Are semantic invariants tested outside schema?
- Are Kafka metadata assumptions tested?
4. Producer Contract Testing
Producer contract test starts from a domain action and verifies emitted event.
Example scenario:
When a submitted case is approved,
case-service must publish CaseApproved to topic case-events,
with key = caseId,
eventType = CaseApproved,
aggregateVersion incremented,
schemaRef = case.CaseApproved:1,
and occurredAt = approval decision time.
4.1 Test Shape
@Test
void approvingSubmittedCasePublishesCaseApprovedEvent() {
CaseId caseId = testData.submittedCase();
ApproveCaseCommand command = approveCommand("EVIDENCE_COMPLETE");
caseApplicationService.approve(caseId, command);
PublishedKafkaRecord record = kafkaProbe.singleRecord("case-events");
assertThat(record.key()).isEqualTo(caseId.value());
EventEnvelope<CaseApprovedPayload> event = record.valueAs(CaseApprovedEvent.class);
assertThat(event.metadata().eventType()).isEqualTo("CaseApproved");
assertThat(event.metadata().aggregateType()).isEqualTo("Case");
assertThat(event.metadata().aggregateId()).isEqualTo(caseId.value());
assertThat(event.metadata().aggregateVersion()).isEqualTo(2L);
assertThat(event.metadata().schemaRef()).isEqualTo("case.CaseApproved:1");
assertThat(event.payload().reasonCode()).isEqualTo("EVIDENCE_COMPLETE");
}
The exact helper classes are custom. The pattern matters.
4.2 Verify Semantic Trigger
Do not only assert an event exists. Assert why it exists.
Bad:
assertThat(kafkaProbe.records()).isNotEmpty();
Good:
assertThat(record.value().metadata().eventType()).isEqualTo("CaseApproved");
assertThat(caseRepository.get(caseId).status()).isEqualTo(CaseStatus.APPROVED);
For outbox pattern, also verify event inserted after state transition.
4.3 Verify No Event on Rejected Command
@Test
void approvingDraftCaseDoesNotPublishCaseApproved() {
CaseId caseId = testData.draftCase();
assertThatThrownBy(() -> caseApplicationService.approve(caseId, approveCommand()))
.isInstanceOf(CaseStateConflictException.class);
assertThat(kafkaProbe.records("case-events")).isEmpty();
}
This prevents false event facts.
5. Testing Topic and Key
Kafka topic and key are part of contract.
@Test
void caseApprovedUsesCaseIdAsKafkaKey() {
EventEnvelope<CaseApprovedPayload> event = eventFactory.caseApproved(caseId);
producer.publish(event);
ProducerRecord<String, ?> record = kafkaProbe.singleProducedRecord();
assertThat(record.topic()).isEqualTo("case-events");
assertThat(record.key()).isEqualTo(caseId.value());
}
This catches accidental changes such as:
kafkaTemplate.send("case-events", event.metadata().eventId(), event);
which would destroy per-case ordering if contract says key is caseId.
6. Testing Envelope Metadata
Minimum metadata tests:
- eventId present;
- eventType correct;
- source correct;
- aggregateId present;
- aggregateVersion present;
- occurredAt present;
- publishedAt present if policy requires;
- correlationId propagated;
- causationId set;
- schemaRef correct;
- tenant/jurisdiction/classification present where required.
Example:
@Test
void eventEnvelopeContainsGovernanceMetadata() {
EventEnvelope<CaseApprovedPayload> event = produceCaseApproved();
assertThat(event.metadata().eventId()).startsWith("evt_");
assertThat(event.metadata().source()).isEqualTo("case-service");
assertThat(event.metadata().correlationId()).isEqualTo(testCorrelationId);
assertThat(event.metadata().dataClassification()).isEqualTo("CONFIDENTIAL");
}
Metadata is not decoration. Missing metadata breaks observability and governance.
7. Testing Schema Validation
Producer-side schema validation test:
@Test
void producedCaseApprovedMatchesRegisteredSchema() {
EventEnvelope<CaseApprovedPayload> event = produceCaseApproved();
schemaValidator.validate("case.CaseApproved", event);
}
For Avro:
@Test
void avroSpecificRecordMatchesSchema() {
CaseApproved event = caseApprovedAvro();
byte[] bytes = avroSerializer.serialize("case-events", event);
CaseApproved parsed = avroDeserializer.deserialize("case-events", bytes);
assertThat(parsed.getMetadata().getEventType()).isEqualTo("CaseApproved");
}
For Protobuf:
@Test
void protobufEventRoundTrips() throws Exception {
CaseApproved event = caseApprovedProto();
byte[] bytes = event.toByteArray();
CaseApproved parsed = CaseApproved.parseFrom(bytes);
assertThat(parsed.getMetadata().getEventType()).isEqualTo("CaseApproved");
}
For JSON Schema:
@Test
void jsonEventMatchesSchema() {
JsonNode event = objectMapper.valueToTree(produceCaseApproved());
ValidationResult result = jsonSchemaValidator.validate(
"https://schemas.acme.com/case/CaseApproved.schema.json",
event
);
assertThat(result.isValid()).isTrue();
}
8. Schema Registry Compatibility Gate
A CI gate should check new schemas against registry rules before merge.
Flow:
Compatibility checks depend on registry:
- Confluent Schema Registry subject compatibility;
- Apicurio Registry global/group/artifact rules;
- custom JSON Schema diff;
- Buf/Protobuf breaking check;
- Avro compatibility checker.
8.1 What Registry Gate Catches
- added required Avro field without default;
- Protobuf tag reuse if tool checks;
- JSON Schema type change if diff supports;
- schema parse failure;
- invalid references;
- incompatible enum change depending format/tooling.
8.2 What Registry Gate Does Not Catch
- Kafka key changed;
- topic changed;
- event meaning changed;
- event emitted at different lifecycle point;
- retention changed;
- DLQ behavior changed;
- consumer side effect duplicate;
- default value semantically wrong;
- generated SDK source break;
- data classification missing unless custom rule exists.
Therefore registry gate is necessary but not sufficient.
9. Golden Event Samples
Golden samples are canonical event examples stored as files.
src/test/resources/events/
├── case-approved-v1.json
├── case-approved-v2.json
├── case-submitted-v1.json
├── customer-registered-v1.avro.json
└── payment-captured-v1.json
Golden sample should include realistic:
- metadata;
- payload;
- optional fields absent;
- nullable fields present/null;
- boundary values;
- old versions;
- failure/DLQ examples.
9.1 Golden Sample Test
@Test
void allGoldenEventsValidateAgainstSchemas() {
goldenEventRepository.all().forEach(sample -> {
ValidationResult result = schemaValidator.validate(
sample.schemaRef(),
sample.payload()
);
assertThat(result.isValid())
.as(sample.fileName())
.isTrue();
});
}
9.2 Golden Samples as Documentation
Examples in AsyncAPI/schema docs should come from golden sample files, not manually duplicated snippets.
This avoids stale documentation.
10. Consumer Contract Testing
Consumer tests prove that handler can process documented events.
Example:
@Test
void caseProjectionHandlesCaseApproved() {
EventEnvelope<CaseApprovedPayload> event =
goldenEvents.load("case-approved-v1.json", CaseApprovedPayload.class);
caseProjectionConsumer.handle(event);
CaseProjection projection = projectionRepository.get(event.payload().caseId());
assertThat(projection.status()).isEqualTo("APPROVED");
assertThat(projection.version()).isEqualTo(event.payload().caseVersion());
}
10.1 Consumer Should Test What It Uses
If consumer uses:
caseId;caseVersion;reasonCode;approvedAt;
then test those fields. Do not only assert consumer does not crash.
10.2 Unknown Field Tolerance
For JSON consumers:
@Test
void consumerIgnoresUnknownOptionalFields() {
JsonNode event = goldenEvents.loadJson("case-approved-v1.json");
((ObjectNode) event.get("payload")).put("futureField", "futureValue");
assertThatCode(() -> consumer.handle(event))
.doesNotThrowAnyException();
}
For generated Avro/Protobuf, unknown field handling differs. Test according to chosen format.
11. Unknown Event Type Test
For multi-type topic consumers:
@Test
void consumerIgnoresUnknownEventTypeOnDomainTopic() {
EventEnvelope<JsonNode> event = EventEnvelope.builder()
.metadata(metadata("CaseFutureEvent"))
.payload(objectMapper.createObjectNode())
.build();
consumer.handle(event);
assertThat(dlqRepository.count()).isZero();
assertThat(metrics.counter("event.ignored", "eventType", "CaseFutureEvent"))
.hasCount(1);
}
If consumer throws on unknown event type, adding new event type to topic breaks old consumers.
Contract should state whether unknown event types must be ignored.
12. Duplicate Event Test
At-least-once delivery means duplicates are normal.
@Test
void duplicateEventIsProcessedOnce() {
EventEnvelope<CaseApprovedPayload> event =
goldenEvents.load("case-approved-v1.json", CaseApprovedPayload.class);
consumer.handle(event);
consumer.handle(event);
CaseProjection projection = projectionRepository.get(event.payload().caseId());
assertThat(projection.appliedEventIds())
.containsExactly(event.metadata().eventId());
}
12.1 Dedup Boundary
Test crash boundary if possible:
- process side effect then crash before commit;
- commit processed ID then crash before side effect;
- retry after partial failure.
For external side effects, idempotency must be tested at business key or provider API level.
13. Out-of-Order and Gap Tests
For projection consumers:
@Test
void eventWithVersionGapIsQuarantined() {
projectionRepository.save(new CaseProjection("case_123", 3));
EventEnvelope<CaseApprovedPayload> event =
caseApprovedEvent("case_123", 5);
consumer.handle(event);
assertThat(quarantineRepository.contains(event.metadata().eventId())).isTrue();
assertThat(projectionRepository.get("case_123").version()).isEqualTo(3);
}
Test duplicate/old version:
@Test
void olderEventIsIgnored() {
projectionRepository.save(new CaseProjection("case_123", 5));
EventEnvelope<CaseApprovedPayload> event =
caseApprovedEvent("case_123", 4);
consumer.handle(event);
assertThat(quarantineRepository.contains(event.metadata().eventId())).isFalse();
assertThat(projectionRepository.get("case_123").version()).isEqualTo(5);
}
14. Replay Tests
Replay test validates historical events rebuild expected projection.
@Test
void replayCaseLifecycleEventsBuildsFinalProjection() {
List<EventEnvelope<?>> events = goldenEvents.loadSequence(
"case-submitted-v1.json",
"case-assigned-v1.json",
"case-approved-v1.json",
"case-closed-v1.json"
);
CaseProjection projection = replayEngine.rebuild(events);
assertThat(projection.status()).isEqualTo("CLOSED");
assertThat(projection.version()).isEqualTo(4L);
}
14.1 Replay Test Must Include Old Versions
@Test
void replaySupportsHistoricalSchemaVersions() {
List<EventEnvelope<?>> events = goldenEvents.loadSequence(
"case-submitted-v1.json",
"case-approved-v1.json",
"case-approved-v2.json"
);
assertThatCode(() -> replayEngine.rebuild(events))
.doesNotThrowAnyException();
}
14.2 Replay Side-Effect Test
For side-effect consumers:
@Test
void replayDoesNotSendDuplicateEmail() {
EventEnvelope<CustomerRegisteredPayload> event =
goldenEvents.load("customer-registered-v1.json", CustomerRegisteredPayload.class);
emailConsumer.handle(event);
emailConsumer.handle(replayMarked(event));
assertThat(emailGateway.sentMessagesFor(event.payload().customerId()))
.hasSize(1);
}
If replay is not allowed for side-effect consumer, test that replay marker causes skip.
15. DLQ and Quarantine Tests
15.1 Invalid Schema Goes to DLQ
@Test
void invalidPayloadGoesToDlqWithOriginalEnvelope() {
EventEnvelope<JsonNode> invalidEvent = invalidCaseApprovedMissingCaseId();
consumer.handleRaw(invalidEvent);
DlqMessage dlq = dlqRepository.single();
assertThat(dlq.failure().failureCode()).isEqualTo("SCHEMA_VALIDATION_FAILED");
assertThat(dlq.original().metadata().eventId()).isEqualTo(invalidEvent.metadata().eventId());
assertThat(dlq.original().metadata().correlationId()).isEqualTo(invalidEvent.metadata().correlationId());
}
15.2 Poison Domain State Goes to Quarantine
@Test
void stateConflictEventIsQuarantined() {
projectionRepository.save(new CaseProjection("case_123", 10));
EventEnvelope<CaseApprovedPayload> event = caseApprovedEvent("case_123", 3);
consumer.handle(event);
QuarantinedEvent quarantined = quarantineRepository.single();
assertThat(quarantined.reason()).isEqualTo("STALE_EVENT");
assertThat(quarantined.originalEventId()).isEqualTo(event.metadata().eventId());
}
15.3 DLQ Contract Test
Verify DLQ includes:
- original topic;
- original partition;
- original offset;
- original key;
- original headers;
- original envelope;
- failure metadata;
- consumer group;
- attempt count;
- correlation ID.
16. Testcontainers for Kafka Contract Tests
For integration-level tests, use Kafka container instead of mocks when testing real serialization, topic/key, headers, consumer group behavior, or offset behavior.
Example conceptual setup:
@Testcontainers
class CaseEventKafkaContractTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("apache/kafka-native:3.8.0")
);
@BeforeAll
static void setup() {
System.setProperty("spring.kafka.bootstrap-servers", kafka.getBootstrapServers());
}
}
Depending on Testcontainers version and Kafka image, container class/package can differ. Keep this in test infrastructure and pin versions.
16.1 What to Test with Real Kafka
- serialization/deserialization;
- topic name and key;
- headers;
- consumer group behavior;
- offset commit/retry;
- DLQ publishing;
- ordering within partition;
- schema registry integration if registry container available.
16.2 What Not to Overdo
Do not make every unit test start Kafka. Use layers:
| Test | Tool |
|---|---|
| mapper validation | unit test |
| schema validation | local validator |
| producer event shape | fake publisher/probe |
| real serialization | Kafka/Testcontainers |
| consumer group/offset | Kafka/Testcontainers |
| end-to-end topology | integration test |
| production broker config | staging smoke test |
17. Schema Registry in Tests
You can test against:
- mock schema registry client;
- embedded registry/test double;
- Testcontainers registry;
- real staging registry.
Use cases:
| Approach | Good for |
|---|---|
| mock registry | unit tests |
| local compatibility checker | fast CI |
| registry container | integration confidence |
| staging registry | pre-release validation |
Test registry behavior:
- schema registration;
- compatibility rejection;
- schema ID resolution;
- subject naming;
- serializer behavior;
- old schema fetch during deserialization.
18. Producer + Registry Integration Test
@Test
void producerRegistersCompatibleSchemaAndPublishesEvent() {
CaseApproved event = caseApprovedAvro();
kafkaTemplate.send("case-events", event.getPayload().getCaseId(), event).get();
ConsumerRecord<String, CaseApproved> record =
testConsumer.pollOne("case-events");
assertThat(record.value().getMetadata().getEventType()).isEqualTo("CaseApproved");
}
This catches:
- serializer misconfiguration;
- subject naming issue;
- registry connectivity;
- schema ID mismatch;
- consumer deserialization failure.
19. Testing Schema Evolution with Old Fixtures
19.1 Avro
Test new reader reads old writer data.
@Test
void caseApprovedV2ReaderReadsV1Data() {
byte[] oldBytes = fixtureBytes("case-approved-v1.avro");
Schema writerSchema = schema("case-approved-v1.avsc");
Schema readerSchema = schema("case-approved-v2.avsc");
CaseApprovedV2 event = avro.read(oldBytes, writerSchema, readerSchema);
assertThat(event.getReasonCode()).isEqualTo("UNKNOWN");
}
19.2 Protobuf
Old binary parse test.
@Test
void newProtoParsesOldBinary() throws Exception {
byte[] oldBytes = fixtureBytes("case-approved-v1.bin");
CaseApproved event = CaseApproved.parseFrom(oldBytes);
assertThat(event.getCaseId()).isEqualTo("case_123");
}
19.3 JSON Schema
Validate old events against current reader/upcaster path.
@Test
void currentConsumerCanHandleOldJsonEvent() {
JsonNode oldEvent = fixtureJson("case-approved-v1.json");
EventEnvelope<JsonNode> current = upcaster.upcastToLatest(oldEvent);
assertThat(schemaValidator.validate("case.CaseApproved.latest", current).isValid())
.isTrue();
}
20. Semantic Contract Tests
Schema tests cannot prove semantics.
Examples of semantic tests:
20.1 Event Emitted After Commit
@Test
void eventIsPublishedOnlyAfterCaseStateIsCommitted() {
caseService.approve(caseId, command);
EventEnvelope<CaseApprovedPayload> event = kafkaProbe.singleEvent();
Case persisted = caseRepository.get(caseId);
assertThat(persisted.status()).isEqualTo(APPROVED);
assertThat(event.payload().caseVersion()).isEqualTo(persisted.version());
}
20.2 No Event on Rollback
@Test
void rollbackDoesNotPublishEvent() {
forceDatabaseFailureOnCommit();
assertThatThrownBy(() -> caseService.approve(caseId, command))
.isInstanceOf(PersistenceException.class);
assertThat(kafkaProbe.records("case-events")).isEmpty();
}
20.3 Correct Domain Meaning
@Test
void caseApprovedMeansStateTransitionToApproved() {
caseService.approve(caseId, command);
EventEnvelope<CaseApprovedPayload> event = kafkaProbe.singleEvent();
assertThat(event.metadata().eventType()).isEqualTo("CaseApproved");
assertThat(caseRepository.get(caseId).status()).isEqualTo(APPROVED);
}
This catches event name misuse.
21. Testing Event Version Migration
21.1 Dual Publish Test
@Test
void customerActivationDualPublishesLegacyAndNewEventsDuringMigration() {
customerService.activate(customerId);
List<EventEnvelope<?>> events = kafkaProbe.records("customer-events");
assertThat(events)
.extracting(e -> e.metadata().eventType())
.contains("CustomerActivated", "CustomerLifecycleActivated");
}
Also test not double side effect in consumer.
21.2 Upcaster Test
@Test
void upcasterAddsReasonCodeDefaultForOldCaseApproved() {
EventEnvelope<JsonNode> old = goldenEvents.loadJsonEnvelope("case-approved-v1.json");
EventEnvelope<JsonNode> upcasted = upcaster.upcastToLatest(old);
assertThat(upcasted.payload().get("reasonCode").asText()).isEqualTo("UNKNOWN");
}
21.3 Translation Topic Test
@Test
void translatorPreservesKeyAndCorrelation() {
EventEnvelope<OldCustomerActivated> oldEvent = oldCustomerActivated();
translator.handle(oldEvent);
ProducerRecord<String, EventEnvelope<CustomerLifecycleActivated>> newRecord =
kafkaProbe.singleProducedRecord("customer-events-v2");
assertThat(newRecord.key()).isEqualTo(oldEvent.metadata().aggregateId());
assertThat(newRecord.value().metadata().correlationId())
.isEqualTo(oldEvent.metadata().correlationId());
}
22. CI Pipeline for Event Contract Changes
Dangerous changes:
- enum value added;
- key changed;
- retention reduced;
- topic changed;
- event meaning changed;
- event deprecated/retired;
- schema compatibility mode changed;
- data classification changed;
- envelope changed;
- DLQ behavior changed.
23. Event Contract Test Matrix Template
For each event:
eventType: CaseApproved
producerTests:
- emits only after state committed
- uses topic case-events
- uses key caseId
- includes eventId
- includes aggregateVersion
- validates schema
- includes correlationId
consumerTests:
- handles valid golden sample
- handles duplicate event
- handles old schema version
- handles unknown optional field
- quarantines sequence gap
- ignores unknown event type on same topic
replayTests:
- rebuilds projection from v1-vN fixtures
- no duplicate side effects
dlqTests:
- invalid schema goes to DLQ
- DLQ preserves original envelope
registryGates:
- backward-transitive compatibility
- generated code compile
24. Testing Event Catalog Consistency
Event catalog should match actual contracts.
Tests can verify:
- every schema has catalog entry;
- every AsyncAPI message has owner;
- every stable event has examples;
- every event has data classification;
- every Kafka topic has key documented;
- every deprecated event has replacement/migration;
- every schemaRef resolves;
- every topic in AsyncAPI exists in infra config.
Pseudo:
@Test
void everyStableEventHasOwnerAndExample() {
eventCatalog.stableEvents().forEach(event -> {
assertThat(event.ownerTeam()).isNotBlank();
assertThat(event.examples()).isNotEmpty();
});
}
This is governance-as-test.
25. False Confidence Traps
25.1 Schema Registry Passes, Consumer Breaks
Example: added enum symbol. Registry may allow, old consumer switch lacks default.
Mitigation: unknown enum tests.
25.2 Producer Test Uses Mock Publisher Only
Mock verifies method called, not serialization/topic/key.
Mitigation: add Kafka/Testcontainers integration for selected paths.
25.3 Consumer Test Uses Hand-Built Object
Hand-built Java object may not match real serialized payload.
Mitigation: load golden sample or deserialize real bytes.
25.4 Replay Test Uses Only Latest Schema
Old history ignored.
Mitigation: keep old fixtures.
25.5 DLQ Test Only Checks Count
DLQ message lacks original envelope.
Mitigation: assert DLQ contract shape.
25.6 Unknown Event Type Not Tested
New event breaks old consumers.
Mitigation: unknown event type test for multi-type topics.
25.7 Topic Key Not Tested
Ordering contract silently changes.
Mitigation: producer test asserts key.
25.8 Upcaster Untested
Old replay fails months later.
Mitigation: fixture-driven upcaster tests.
25.9 Tests Ignore Headers
Trace/correlation/security missing in production.
Mitigation: assert headers/envelope metadata.
25.10 Contract Tests Not Blocking Release
Tests exist but not in CI/CD gates.
Mitigation: make contract pipeline required.
26. Practice Lab
Lab 1 — Producer Test Matrix
For event PaymentCaptured, define tests for:
- domain trigger;
- topic;
- key;
- schema;
- envelope metadata;
- no event on failed capture;
- idempotent publish retry.
Lab 2 — Consumer Test Matrix
For consumer ledger-projection, define tests for:
- valid event;
- duplicate;
- out-of-order;
- unknown event type;
- old schema version;
- invalid schema;
- DLQ/quarantine;
- replay.
Lab 3 — Golden Samples
Create sample list for CaseApproved versions v1, v2, v3. Explain which fields differ and how tests use them.
Lab 4 — Registry Gate
Design CI gate for Avro schemas with backward-transitive compatibility. Add extra checks for Kafka key and semantic review.
Lab 5 — Replay Safety
Consumer sends SMS on CustomerRegistered. Design tests to ensure replay does not send duplicate SMS.
Lab 6 — False Confidence Review
Given pipeline:
schema registry compatibility only
List at least 10 event contract risks it does not catch.
27. Senior Engineer Heuristics
- Event contract tests must verify topic, key, and semantics, not only schema.
- Golden event samples are long-term compatibility assets.
- Replay tests are the strongest proof of historical compatibility.
- At-least-once delivery requires duplicate tests.
- Multi-type topics require unknown event type tests.
- DLQ without original envelope is operational debt.
- Schema registry gates are necessary but insufficient.
- Producer tests must prove event emitted at the correct lifecycle point.
- Consumer tests should load real samples or serialized bytes.
- Semantic invariants need explicit tests outside schema.
- Testcontainers is valuable for serialization, topic/key, headers, and offsets.
- Upcasters must be tested with old fixtures.
- Side-effect consumers need replay/idempotency tests.
- Contract tests that do not block release are documentation.
- A good event test fails before a consumer learns the breakage in production.
28. Summary
Event contract testing is the discipline that keeps producer behavior, schema registry, broker metadata, consumer expectations, replay behavior, and governance metadata aligned. It requires more than schema compatibility.
Main takeaways:
- test producer semantics, topic, key, envelope, and schema;
- test consumer handling of valid, duplicate, old, unknown, and invalid events;
- use golden samples as documentation and regression fixtures;
- use replay tests for historical compatibility;
- test DLQ/quarantine as a contract;
- use schema registry gates but do not rely only on them;
- use Kafka/Testcontainers selectively for real serialization and broker behavior;
- test semantic invariants explicitly;
- make event contract tests required in CI;
- treat event compatibility as an engineering system, not a one-time validation.
Part berikutnya membahas schema registry architecture: subject naming, artifact identity, compatibility rules, access control, references, environment promotion, and registry-as-governance-control-point.
You just completed lesson 21 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.