Consumer-Driven Contract Testing with Pact: A Practical Introduction
Integration tests are slow, brittle, and expensive to maintain. You spin up multiple services, wire them together in a test environment, and hope that everything behaves the way it did last Tuesday. When a test fails, you spend twenty minutes figuring out whether the problem is in the consumer, the provider, or the network configuration.
Contract testing offers a better approach. Instead of testing services together, you test the agreement between them — independently, fast, and without shared infrastructure.
What Is Consumer-Driven Contract Testing?
In a microservices architecture, a consumer is any service that calls another. A provider is the service being called. Contract testing captures the consumer's expectations as a formal artifact — the contract — and then verifies that the provider satisfies those expectations.
The key insight is consumer-driven: the consumer defines what it needs, not what the provider exposes. This flips the typical API design conversation. Instead of the provider publishing a spec and hoping consumers use it correctly, consumers document their actual usage, and providers verify they can meet it.
This approach surfaces breaking changes before deployment. If a provider renames a field that a consumer relies on, the contract test fails — no integration environment required.
Enter Pact
Pact is the de facto standard for consumer-driven contract testing. It supports multiple languages and has a mature ecosystem. For JavaScript and TypeScript projects, the @pact-foundation/pact package handles everything from generating contracts to running provider verification.
Install it:
npm install --save-dev @pact-foundation/pactWriting Your First Consumer Test
Imagine a frontend application that fetches user profiles from a user-service. The consumer test defines what the response must look like — not what the provider actually returns today, but what the consumer requires.
// user-service.consumer.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserService } from '../src/user-service';
import path from 'path';
const { like, string, integer } = MatchersV3;
const provider = new PactV3({
consumer: 'frontend-app',
provider: 'user-service',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
describe('UserService contract', () => {
describe('GET /users/:id', () => {
it('returns a user profile', async () => {
await provider
.given('user 42 exists')
.uponReceiving('a request for user 42')
.withRequest({
method: 'GET',
path: '/users/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: like({
id: integer(42),
name: string('Alice'),
email: string('alice@example.com'),
}),
})
.executeTest(async (mockServer) => {
const service = new UserService(mockServer.url);
const user = await service.getUser(42);
expect(user.id).toBe(42);
expect(user.name).toBeDefined();
expect(user.email).toContain('@');
});
});
});
});When this test runs, Pact starts a mock server that responds according to your defined interaction. If UserService.getUser makes the correct HTTP call, the test passes and Pact writes a contract file to pacts/frontend-app-user-service.json.
Understanding Matchers
The like() matcher is doing important work here. It says: "I care about the shape of this response, not the specific values." This is usually what you want — consumers rarely care whether the test user's name is "Alice" or "Bob", but they absolutely care that a name field exists and is a string.
Pact ships with a rich set of matchers:
import { MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, string, integer, boolean, regex, datetime } = MatchersV3;
// Match any integer
integer(42)
// Match any string
string('example')
// Match an array with at least one element of this shape
eachLike({ id: integer(), name: string() })
// Match a specific pattern
regex('\\d{4}-\\d{2}-\\d{2}', '2026-01-15')
// Match any ISO datetime
datetime("yyyy-MM-dd'T'HH:mm:ss.SSSX", '2026-01-15T10:00:00.000Z')The contract file Pact generates is plain JSON. Check it into version control or, better, publish it to a Pact Broker — which we'll cover in the next post in this series.
What Happens on the Provider Side?
Generating the contract is only half the story. The provider needs to verify it can satisfy the consumer's expectations. Here's a minimal provider verification test:
// user-service.provider.pact.spec.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { startServer } from '../src/server';
describe('Pact verification: user-service', () => {
let server: ReturnType<typeof startServer>;
beforeAll(() => {
server = startServer(3001);
});
afterAll(() => server.close());
it('validates the consumer contracts', () => {
return new Verifier({
provider: 'user-service',
providerBaseUrl: 'http://localhost:3001',
pactUrls: [
path.resolve(process.cwd(), '../frontend-app/pacts/frontend-app-user-service.json'),
],
stateHandlers: {
'user 42 exists': async () => {
// Seed your database or set up test fixtures here
await db.users.upsert({ id: 42, name: 'Alice', email: 'alice@example.com' });
},
},
}).verifyProvider();
});
});The stateHandlers are critical. When the contract says "given user 42 exists", the provider test needs to set up that precondition. This is where most teams stumble — state management is the hard part of provider verification, and we'll dig into it deeply in the provider strategies post later in this series.
Contract Testing vs Integration Testing
It's worth being explicit about what contract testing doesn't replace.
Contract tests verify that the interface agreement is honoured — they don't test business logic, performance, or real network behaviour. A provider can pass all its contract tests and still have a bug in how it processes a request internally.
End-to-end tests still have a role. Tools like HelpMeTest complement contract testing by running AI-powered end-to-end tests across your full stack — catching the category of bugs that only surface when real services talk to each other in a real environment. With usage-based pricing and no infrastructure to manage, it's a practical addition to a contract-testing-first strategy rather than a replacement for it.
The right layered strategy looks like this:
- Unit tests — business logic in isolation
- Contract tests — interface compatibility between services
- End-to-end tests — critical user journeys across the full system
A Note on Async Interactions
The examples above cover synchronous HTTP. If your services communicate via message queues (Kafka, RabbitMQ, SQS), Pact handles that too through its messaging interface. We'll cover that in the event-driven systems post in this series.
Getting Started
The fastest way to adopt Pact is to start with one consumer-provider pair where integration failures have burned you before. Write a consumer test that captures your actual HTTP calls, run it once to generate the contract, then add the provider verification to the provider's CI pipeline.
Once that's working, you'll wonder how you lived without it. The contract file becomes a living document of what consumers actually need — far more useful than a Swagger spec that no one updates.
Key takeaways:
- Contract testing isolates interface verification from shared infrastructure
- Consumers define the contract; providers verify they satisfy it
- Pact matchers let you assert shape without being brittle about values
- State handlers on the provider side are the trickiest part to get right
- Contract tests don't replace end-to-end tests — they complement them
In the next post, we'll set up a Pact Broker and look at how to wire contracts into your CI/CD pipeline so breaking changes are caught automatically on every pull request.