Contract Testing for Event-Driven Systems with Pact
Most contract testing material focuses on REST APIs. But many modern systems communicate through message queues — Kafka, RabbitMQ, SNS/SQS, or custom event buses. The same integration problems that contract testing solves for HTTP — a producer changes a message schema and consumers silently break — exist in event-driven architectures, and they're harder to catch because there's no synchronous failure.
Pact supports asynchronous message contracts through its messaging interface. This post covers how to write, verify, and maintain message contracts for event-driven systems.
The Problem with Message Schema Changes
In a typical event-driven setup, a order-service publishes an OrderPlaced event to a Kafka topic. Three consumers subscribe: inventory-service, notification-service, and analytics-service. Each consumer deserializes the message and expects specific fields.
When order-service renames customerId to userId — a reasonable refactor — every consumer breaks silently. Kafka doesn't enforce schemas unless you've set up a Schema Registry. Even with a Schema Registry, consumers often use weaker schema validation than they should.
Contract testing solves this by making the consumers' field expectations explicit and running them against the producer's actual message output.
Pact Messaging Concepts
For HTTP contracts:
- Consumer sends a request and expects a response
- Provider receives a request and must produce the expected response
For message contracts:
- Consumer receives a message and has expectations about its shape
- Provider (message producer) must be able to produce a message that satisfies the consumer
The consumer defines what fields it reads from the message. The producer verifies it can generate messages that satisfy all registered consumers.
Consumer Side: Defining Message Expectations
// order-placed.consumer.pact.spec.ts
import { PactV3, MatchersV3, MessageConsumerPact } from '@pact-foundation/pact';
import { processOrderPlaced } from '../src/inventory-handler';
import path from 'path';
const { like, string, integer, datetime, eachLike } = MatchersV3;
const messagePact = new MessageConsumerPact({
consumer: 'inventory-service',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
describe('OrderPlaced message contract', () => {
it('can process an OrderPlaced event', () => {
return messagePact
.given('an order has been placed')
.expectsToReceive('an OrderPlaced event')
.withContent({
orderId: string('ord_123'),
customerId: string('cust_456'),
placedAt: datetime("yyyy-MM-dd'T'HH:mm:ss.SSSX", '2026-01-15T10:00:00.000Z'),
items: eachLike({
productId: string('prod_789'),
quantity: integer(2),
unitPrice: like(29.99),
}),
totalAmount: like(59.98),
currency: string('USD'),
})
.withMetadata({
'content-type': 'application/json',
})
.verify(async (message) => {
// This is the actual consumer code — the code that processes the message.
// If this throws, the test fails.
const result = await processOrderPlaced(JSON.parse(message.contents as string));
expect(result.reservationId).toBeDefined();
});
});
});A few things to notice:
The .verify() callback receives the message and runs your actual consumer handler against it. This means the contract test also tests that your handler works correctly. If your handler throws because message.items is undefined, the contract test fails — which is exactly what you want.
The consumer only declares fields it actually uses. If inventory-service doesn't use currency, it shouldn't appear in the contract. This prevents false coupling between consumers and producer.
Producer Side: Verifying Message Contracts
The producer verification looks different from HTTP verification. Instead of starting a server, you provide a function that returns the message:
// order-service.provider.pact.spec.ts
import { MessageProviderPact, providerWithMetadata } from '@pact-foundation/pact';
import { OrderEventPublisher } from '../src/order-event-publisher';
describe('OrderPlaced message provider verification', () => {
it('can produce messages satisfying all consumer contracts', () => {
const publisher = new OrderEventPublisher();
return new MessageProviderPact({
provider: 'order-service',
pactBrokerUrl: process.env.PACT_BROKER_URL!,
pactBrokerUsername: process.env.PACT_BROKER_USERNAME,
pactBrokerPassword: process.env.PACT_BROKER_PASSWORD,
consumerVersionSelectors: [
{ mainBranch: true },
{ deployedOrReleased: true },
],
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT!,
messageProviders: {
'an OrderPlaced event': providerWithMetadata(
async () => {
// Return the message your service would actually produce
const order = await OrderFixture.create({
customerId: 'cust_456',
items: [{ productId: 'prod_789', quantity: 2, unitPrice: 29.99 }],
});
return publisher.buildOrderPlacedPayload(order);
},
{ 'content-type': 'application/json' }
),
},
}).verify();
});
});The messageProviders map interaction descriptions to functions that produce example messages. Pact runs each consumer's expectations against the message returned by the corresponding function.
The key is calling publisher.buildOrderPlacedPayload() — the real serialisation code — rather than constructing a mock payload manually. If someone refactors buildOrderPlacedPayload to rename customerId to userId, the consumer contracts fail at verification time.
Multiple Consumers, Different Fields
The real value of message contracts shows up when you have multiple consumers with different needs:
// notification-service consumer test
messagePact
.expectsToReceive('an OrderPlaced event')
.withContent({
orderId: string('ord_123'),
customerId: string('cust_456'),
totalAmount: like(59.98),
// notification-service doesn't care about items — not in the contract
})
.verify(async (message) => {
const payload = JSON.parse(message.contents as string);
await sendOrderConfirmationEmail(payload);
});// analytics-service consumer test
messagePact
.expectsToReceive('an OrderPlaced event')
.withContent({
orderId: string('ord_123'),
placedAt: datetime("yyyy-MM-dd'T'HH:mm:ss.SSSX", '2026-01-15T10:00:00.000Z'),
items: eachLike({
productId: string('prod_789'),
quantity: integer(2),
}),
// analytics cares about items but not totalAmount
})
.verify(async (message) => {
const payload = JSON.parse(message.contents as string);
await recordOrderEvent(payload);
});Now order-service must satisfy all three contracts. If notification-service needs customerId and you rename that field, the verification fails for notification-service even though analytics-service didn't use it. You can't accidentally break one consumer while thinking "well, I checked inventory-service's tests".
Handling Kafka-Specific Concerns
If you use Kafka with kafkajs, the message structure includes metadata beyond the payload — topic, partition, offset, key, headers. Your consumer tests should reflect this if your handler uses any of it:
.withContent({
value: like({
orderId: string('ord_123'),
customerId: string('cust_456'),
}),
key: string('cust_456'), // If your handler uses the Kafka message key
headers: like({
'correlation-id': string('req-abc-123'),
}),
})
.verify(async (message) => {
const kafkaMessage = JSON.parse(message.contents as string);
await handler(kafkaMessage);
});For Avro-serialized messages, convert to/from JSON in your message provider and consumer tests. Pact works with JSON; the serialisation format is an infrastructure concern separate from the schema contract.
Schema Registry vs Pact Contracts
If you're using Confluent Schema Registry or AWS Glue, you might wonder whether you need Pact at all. The answer is yes, and here's why:
Schema registries enforce structural compatibility — you can't remove a required field without a schema version bump. But they don't enforce semantic compatibility — renaming a field from customerId to userId while keeping the structure compatible breaks consumers that read the old field name, even though both schemas are structurally valid JSON.
Pact catches the semantic layer. Schema Registry catches the structural layer. Use both.
Testing Retry and Dead-Letter Queue Behaviour
Contract tests don't cover failure scenarios well — they're about the happy-path schema agreement. Retry logic, DLQ routing, and poison message handling belong in end-to-end tests where you can actually drop messages, introduce delays, and verify recovery.
HelpMeTest handles these more complex scenarios well — its AI-powered test generation can model event-driven user journeys and verify observable outcomes (order confirmation emails sent, inventory counts updated) rather than internal message shapes. Think of Pact as covering the "can this message be understood" question, and end-to-end tests covering the "did the right thing happen as a result" question.
Putting It Together
A full message contract workflow:
- Consumer writes message contract test with
.verify()running real handler code - Consumer publishes contract to Pact Broker
- Producer writes message provider test calling real serialisation code
- Producer verifies against all consumer contracts from the Broker
can-i-deploygates deployments on both sides
When order-service adds a new event type, consumers add new contracts for it before the feature is built — contract tests as spec, exactly as in HTTP testing.
The mental shift required: stop thinking about "what does the producer send?" and start thinking about "what does each consumer need to receive?" Your event schema becomes a product with multiple stakeholders, and their needs are documented in code rather than Slack messages and wiki pages nobody updates.