Implement consumer-driven contract testing for API boundaries
Consumer-driven contract tests (Pact) define and verify the API contracts between the frontend consumer and backend provider, catching integration mismatches before they reach production.
- The consumer writes tests that describe what it expects from the API (the "contract")
- The contract is published to a Pact Broker and verified against the real provider
- Provider changes that break the contract fail CI before they are deployed
- This replaces fragile end-to-end tests that require both services to be running simultaneously
Rule Details
Consumer-driven contract testing (CDCT) is a technique where the API consumer (frontend) writes tests that document exactly what it needs from the provider (backend). These tests produce a contract file that the provider verifies independently.
Code Example
Consumer (frontend)
│
│ 1. Writes Pact tests describing expected interactions
│ 2. Tests run against a Pact mock server
│ 3. Pact file (.json) is produced
│
▼
Pact Broker
│
│ 4. Consumer publishes the pact file with version tag
│
▼
Provider (backend)
│
│ 5. Downloads the pact file
│ 6. Runs provider verification — replays each interaction against the real API
│ 7. Reports results back to the broker
│
▼
can-i-deploy check
│
│ 8. Before deploying, query the broker: is this version safe to deploy?Why It Matters
Integration tests that require both services running at the same time are slow, brittle, and hard to maintain. Consumer-driven contract testing decouples the consumer and provider test suites — each team can run their tests independently, yet the broker guarantees that the published contract is always verified against the live provider. This catches API mismatches days earlier than end-to-end tests, with far less infrastructure overhead.
Consumer Side: Writing Pact Tests
Install Pact
pnpm add -D @pact-foundation/pactDefine an interaction (Pact consumer test)
// src/api/__tests__/users.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
import path from 'path'
import { fetchUser } from '../users'
const { like, eachLike, string, integer, iso8601DateTime } = MatchersV3
const provider = new PactV3({
consumer: 'frontend-app',
provider: 'user-service',
dir: path.resolve(__dirname, '../../../pacts'),
logLevel: 'error',
})
describe('User API — Pact consumer tests', () => {
describe('GET /users/:id', () => {
it('returns a user when the user exists', async () => {
await provider
.addInteraction({
states: [{ description: 'a user with id 42 exists' }],
uponReceiving: 'a request for user 42',
withRequest: {
method: 'GET',
path: '/users/42',
headers: {
Accept: 'application/json',
Authorization: like('Bearer token123'),
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(42),
name: string('Jane Doe'),
email: string('jane@example.com'),
role: string('admin'),
createdAt: iso8601DateTime(),
},
},
})
.executeTest(async (mockServer) => {
const user = await fetchUser(42, {
baseUrl: mockServer.url,
token: 'Bearer token123',
})
expect(user.id).toBe(42)
expect(user.name).toBe('Jane Doe')
expect(user.role).toBe('admin')
})
})
it('returns 404 when the user does not exist', async () => {
await provider
.addInteraction({
states: [{ description: 'a user with id 999 does not exist' }],
uponReceiving: 'a request for a non-existent user',
withRequest: {
method: 'GET',
path: '/users/999',
headers: { Accept: 'application/json' },
},
willRespondWith: {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: {
error: string('User not found'),
code: string('USER_NOT_FOUND'),
},
},
})
.executeTest(async (mockServer) => {
await expect(
fetchUser(999, { baseUrl: mockServer.url })
).rejects.toThrow('User not found')
})
})
})
describe('GET /users', () => {
it('returns a paginated list of users', async () => {
await provider
.addInteraction({
states: [{ description: 'at least one user exists' }],
uponReceiving: 'a request for the users list',
withRequest: {
method: 'GET',
path: '/users',
query: { page: '1', limit: '20' },
},
willRespondWith: {
status: 200,
body: {
data: eachLike({
id: integer(1),
name: string('User Name'),
email: string('user@example.com'),
}),
meta: {
total: integer(50),
page: integer(1),
limit: integer(20),
},
},
},
})
.executeTest(async (mockServer) => {
const result = await fetchUsers({ page: 1, limit: 20 }, { baseUrl: mockServer.url })
expect(result.data).toHaveLength(1) // eachLike generates a single item
expect(result.meta.page).toBe(1)
})
})
})
})The API Client Under Test
// src/api/users.ts
interface User {
id: number
name: string
email: string
role: string
createdAt: string
}
interface FetchOptions {
baseUrl?: string
token?: string
}
export async function fetchUser(id: number, options: FetchOptions = {}): Promise<User> {
const { baseUrl = process.env.NEXT_PUBLIC_API_URL, token } = options
const res = await fetch(`${baseUrl}/users/${id}`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: token } : {}),
},
})
if (res.status === 404) {
const body = await res.json()
throw new Error(body.error ?? 'User not found')
}
if (!res.ok) {
throw new Error(`Failed to fetch user: ${res.status}`)
}
return res.json()
}Publishing the Contract
After the consumer tests pass and the pact file is generated:
// scripts/publish-pact.ts
import { Publisher } from '@pact-foundation/pact'
import path from 'path'
const publisher = new Publisher({
pactBroker: process.env.PACT_BROKER_URL!,
pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
pactFilesOrDirs: [path.resolve(__dirname, '../pacts')],
consumerVersion: process.env.GIT_SHA ?? '0.0.0',
tags: [process.env.GIT_BRANCH ?? 'main'],
})
publisher.publishPacts().then(() => {
console.info('Pacts published successfully')
})# .github/workflows/consumer-tests.yml
- name: Run Pact consumer tests
run: pnpm test:pact
- name: Publish pact files
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_SHA: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
run: pnpm tsx scripts/publish-pact.tsProvider Side: Verifying the Contract
On the backend (Node.js example):
// provider/src/__tests__/pact-provider.spec.ts
import { Verifier } from '@pact-foundation/pact'
import path from 'path'
describe('Pact provider verification', () => {
it('validates the expectations of the frontend consumer', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:4000',
pactBrokerUrl: process.env.PACT_BROKER_URL!,
pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
provider: 'user-service',
providerVersion: process.env.GIT_SHA ?? '0.0.0',
publishVerificationResult: process.env.CI === 'true',
// Provider state handlers — set up database fixtures for each state
stateHandlers: {
'a user with id 42 exists': async () => {
await db.seed({ users: [{ id: 42, name: 'Jane Doe', email: 'jane@example.com', role: 'admin' }] })
},
'a user with id 999 does not exist': async () => {
await db.truncate('users')
},
'at least one user exists': async () => {
await db.seed({ users: [{ id: 1, name: 'Test User', email: 'test@example.com' }] })
},
},
})
await verifier.verifyProvider()
})
})can-i-deploy Gate in CI
The can-i-deploy command queries the broker and fails if the consumer/provider versions have not been mutually verified:
# .github/workflows/deploy.yml
- name: Check can-i-deploy
run: |
npx pact-broker can-i-deploy \
--pacticipant frontend-app \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}Pact Matcher Reference
| Matcher | What it does |
|---|---|
like(value) | Match the type, not the exact value |
string('example') | Match any string |
integer(42) | Match any integer |
decimal(3.14) | Match any decimal number |
boolean(true) | Match any boolean |
eachLike(template) | Match an array where each element has the template's shape |
regex('\\d+', '123') | Match a string against a regex pattern |
iso8601DateTime() | Match an ISO 8601 datetime string |
nullValue() | Match a null value |
Pact tests verify that the API response has the right shape and types — they do not test whether the backend calculates the correct value. Combine Pact with unit tests on both sides for complete coverage. A Pact test that asserts integer() will pass whether the API returns 42 or 0 — use specific matchers only where the exact value matters to the consumer.
Standards
- Use these references as the standard for how the test or monitoring strategy should behave in the shipped workflow.
- Check the implementation against Pact documentation before treating the rule as satisfied.
- Check the implementation against Martin Fowler: Consumer-Driven Contracts before treating the rule as satisfied.
Verification
- Run
pnpm test:pact— the consumer tests should pass and a.jsonpact file should be written to thepacts/directory. - Inspect the generated pact file and confirm it contains all the interactions you defined.
- Run the provider verification step and confirm all interactions pass against the real backend running locally.
- Confirm the CI pipeline fails when you intentionally break a provider response (e.g., rename a field the consumer uses) and the error is traced to the specific interaction.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Check whether the frontend has consumer-driven contract tests that define and verify the API contract with the backend.
Fix
Auto-fix issues
Add Pact consumer tests for the frontend API client, publish the contracts to a broker, and add provider verification to the backend CI pipeline.
Explain
Learn more
Explain how consumer-driven contract testing works, what the Pact workflow looks like end-to-end, and how it compares to mocking and end-to-end testing.
Review
Code review
Review the Pact consumer tests. Flag interactions that are too permissive (any-type matchers on fields the consumer actually uses), missing status code assertions, and interactions for endpoints the consumer no longer calls.