Consumer-Driven Contract Testing with Pact
Schema validation proves your mocks match the documented shapes, but it says nothing about whether the real provider still returns what your app depends on. A field your frontend reads gets dropped by the backend, the spec is updated late, and every mock-backed test stays green while production breaks. Pact closes that gap by recording exactly what a consumer needs and replaying it against the live provider.
Why this scenario arises
Mock-based tests are inherently optimistic: they assert your code against a fixture you wrote, so they can only ever prove your code is internally consistent. The provider is a separate deployable that changes on its own schedule. When it drops email from a user response, your MSW handlers keep returning email because nobody told them to stop, and the divergence hides until a real integration.
Consumer-driven contract testing inverts the flow. Instead of the provider publishing a spec and hoping consumers keep up, each consumer records the precise interactions it relies on into a pact file, and the provider must prove it satisfies every consumer’s pact. This is the provider-guarantee half of the contract testing and drift detection loop; the spec-level half is covered in detecting OpenAPI contract drift in CI.
Solution
1. Install Pact JS
npm i -D @pact-foundation/pact vitest tsx
Pact JS bundles the native verification core, so no separate binary install is needed on Linux, macOS, or Windows.
2. Write the consumer test
The consumer test stands up an in-process Pact mock provider, declares the interactions the consumer depends on, runs the real client code against the mock, and — if the assertions pass — writes a pact file to ./pacts. Use matchers so the contract asserts shape and type rather than pinning exact values.
// contract/consumer/orders.consumer.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { fetchOrder } from '../../src/clients/orders';
const { like, string, integer, eachLike, iso8601DateTimeWithMillis } = MatchersV3;
const provider = new PactV3({
consumer: 'checkout-web',
provider: 'orders-api',
dir: resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
describe('orders API contract', () => {
it('returns an order with its line items', async () => {
provider
.given('order 5001 exists with two line items')
.uponReceiving('a request for order 5001')
.withRequest({
method: 'GET',
path: '/api/v1/orders/5001',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: like({
id: integer(5001),
status: string('confirmed'),
createdAt: iso8601DateTimeWithMillis('2026-01-15T09:30:00.000Z'),
total: like(129.98),
items: eachLike({
sku: string('WIDGET-PRO'),
quantity: integer(2),
unitPrice: like(49.99),
}),
}),
});
await provider.executeTest(async (mockServer) => {
const order = await fetchOrder(mockServer.url, 5001);
expect(order.id).toBe(5001);
expect(order.items.length).toBeGreaterThan(0);
expect(order.items[0].sku).toBeDefined();
});
});
it('returns 404 for an unknown order', async () => {
provider
.given('no order 9999 exists')
.uponReceiving('a request for a missing order')
.withRequest({
method: 'GET',
path: '/api/v1/orders/9999',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 404,
headers: { 'Content-Type': 'application/json' },
body: like({ error: string('order not found') }),
});
await provider.executeTest(async (mockServer) => {
await expect(fetchOrder(mockServer.url, 9999)).rejects.toThrow(/404/);
});
});
});
The client under test is plain application code with no Pact awareness — that is the point, since you are testing the code that will actually run in production:
// src/clients/orders.ts
export interface OrderItem {
sku: string;
quantity: number;
unitPrice: number;
}
export interface Order {
id: number;
status: string;
createdAt: string;
total: number;
items: OrderItem[];
}
export async function fetchOrder(baseUrl: string, id: number): Promise<Order> {
const res = await fetch(`${baseUrl}/api/v1/orders/${id}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`order ${id}: HTTP ${res.status}`);
return (await res.json()) as Order;
}
Run it and inspect the generated pact:
npx vitest run contract/consumer
cat pacts/checkout-web-orders-api.json
3. Verify the provider
Verification replays every interaction in the pact against a real provider instance. Each given(...) state from the consumer test maps to a stateHandlers entry that seeds the data that interaction assumes. The harness below boots an in-repo provider; substitute your own start/stop/seed functions.
// contract/provider/orders.provider.test.ts
import { Verifier } from '@pact-foundation/pact';
import { resolve } from 'node:path';
import { describe, it } from 'vitest';
import { startProvider, stopProvider, seedOrder, clearOrders } from './provider-harness';
describe('orders-api provider verification', () => {
it('honours the checkout-web contract', async () => {
const port = 8123;
await startProvider(port);
try {
await new Verifier({
provider: 'orders-api',
providerBaseUrl: `http://127.0.0.1:${port}`,
pactUrls: [resolve(process.cwd(), 'pacts', 'checkout-web-orders-api.json')],
stateHandlers: {
'order 5001 exists with two line items': async () => {
await seedOrder({
id: 5001,
status: 'confirmed',
items: [
{ sku: 'WIDGET-PRO', quantity: 2, unitPrice: 49.99 },
{ sku: 'GADGET-PLUS', quantity: 1, unitPrice: 30.0 },
],
});
return { description: 'order 5001 seeded' };
},
'no order 9999 exists': async () => {
await clearOrders();
return { description: 'orders cleared' };
},
},
}).verifyProvider();
} finally {
await stopProvider();
}
}, 30_000);
});
A minimal, dependency-free harness — an in-memory Express provider — makes the example runnable end to end:
// contract/provider/provider-harness.ts
import express, { type Express } from 'express';
import type { Server } from 'node:http';
interface Order {
id: number;
status: string;
createdAt: string;
total: number;
items: { sku: string; quantity: number; unitPrice: number }[];
}
const store = new Map<number, Order>();
let server: Server | null = null;
export async function seedOrder(input: {
id: number;
status: string;
items: { sku: string; quantity: number; unitPrice: number }[];
}): Promise<void> {
const total = input.items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
store.set(input.id, {
id: input.id,
status: input.status,
createdAt: '2026-01-15T09:30:00.000Z',
total: Number(total.toFixed(2)),
items: input.items,
});
}
export async function clearOrders(): Promise<void> {
store.clear();
}
function buildApp(): Express {
const app = express();
app.get('/api/v1/orders/:id', (req, res) => {
const order = store.get(Number(req.params.id));
if (!order) return res.status(404).json({ error: 'order not found' });
return res.json(order);
});
return app;
}
export async function startProvider(port: number): Promise<void> {
await new Promise<void>((done) => {
server = buildApp().listen(port, done);
});
}
export async function stopProvider(): Promise<void> {
if (!server) return;
await new Promise<void>((done, fail) =>
server!.close((err) => (err ? fail(err) : done())),
);
server = null;
}
Run the verification:
npx vitest run contract/provider
# Expected: "1 pact, 2 interactions, 0 failures"
4. Share contracts through a Pact Broker (optional)
For a single repo, the committed pact file is enough. Once multiple consumers or independently-deployed services share a provider, a Pact Broker stores versioned pacts and verification results and answers “is it safe to deploy this version?”
# docker-compose.pact.yml
services:
broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: "postgres://pact:pact@broker-db/pact"
PACT_BROKER_BASIC_AUTH_USERNAME: pact
PACT_BROKER_BASIC_AUTH_PASSWORD: pact
depends_on:
- broker-db
broker-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
Publish from the consumer build, verify from the provider build, and gate the deploy:
# Consumer: publish the pact after the consumer test passes
npx pact-broker publish ./pacts \
--consumer-app-version "$GIT_SHA" \
--branch "$GIT_BRANCH" \
--broker-base-url http://localhost:9292 \
--broker-username pact --broker-password pact
# Before deploying the consumer, ask the broker if the provider verified it
npx pact-broker can-i-deploy \
--pacticipant checkout-web --version "$GIT_SHA" \
--to-environment production \
--broker-base-url http://localhost:9292 \
--broker-username pact --broker-password pact
can-i-deploy exits non-zero unless every pact for that version has been verified by the provider version currently in the target environment — the deploy-time gate that the file-only workflow cannot provide. Booting the broker and provider together fits the container patterns in dockerized mock environments.
Verification
Run the consumer and provider tests back to back and confirm the pact is produced and verified:
npx vitest run contract/consumer && \
test -f pacts/checkout-web-orders-api.json && \
npx vitest run contract/provider
echo "exit code: $?"
# Expected: pact file present, provider verification passes, "exit code: 0"
Gotchas and edge cases
-
Provider state text must match verbatim. The
given('order 5001 exists with two line items')string in the consumer test is the lookup key in the provider’sstateHandlers. A trailing space, different casing, or a reworded description silently fails to match and the verifier reports a missing state handler. Keep the strings in a shared constants file if they drift easily. -
Do not over-specify the contract. Using exact-value matchers (
"confirmed"instead ofstring('confirmed')) forces the provider to return that literal value forever, turning the pact into a brittle snapshot. Assert types and structure with matchers; only pin an exact value when the consumer genuinely branches on it. This mirrors the shape-not-value discipline in response shaping techniques. -
Verification needs a clean provider state per interaction. If one interaction’s seed data leaks into the next, verification passes locally and fails in CI where ordering differs. Reset the store in each state handler (as
clearOrdersdoes) so every replayed interaction starts from a known baseline — the same isolation concern that governs mock lifecycle management.
FAQ
Why is it called consumer-driven?
The consumer, not the provider, defines the contract. Each consumer test records only the interactions and fields that consumer actually uses, so the resulting pact describes real dependencies rather than the provider’s full surface. The provider then proves it satisfies every consumer’s recorded expectations, which keeps it free to change anything no consumer relies on.
Do I still need a Pact Broker for a single team?
No. For one repository where the consumer and provider tests run in the same pipeline, committing the pact file to disk and pointing the verifier at it is enough. A broker earns its keep once multiple consumers or separately-deployed services share a provider, because it stores versioned pacts and answers can-i-deploy across independent release cadences.
Related
- Detecting OpenAPI Contract Drift in CI — the spec-level counterpart that catches breaking changes before Pact runs
- Validating Mock Responses Against OpenAPI — assert your stubs against the schema so pacts and mocks agree
- Contract Testing & Drift Detection — parent overview of the spec ⇄ mocks ⇄ provider loop
← Back to Contract Testing & Drift Detection