Consumer-Driven API Contract Testing with Pact 2026
Consumer-Driven API Contract Testing with Pact in 2026
TL;DR
Integration testing between microservices has a fundamental problem: you either test against the real service (slow, expensive, flaky), mock the service yourself (the mock drifts from reality), or don't test the integration at all (bugs in production). Consumer-driven contract testing solves this. With Pact.js, each API consumer defines a "contract" — what endpoints they call and what response shape they expect. The API provider runs these contracts as tests. If the provider breaks any consumer's contract, the tests fail before deployment. The result: you catch breaking API changes before they reach production, without requiring both services to be deployed at the same time.
Key Takeaways
- The core problem: When Service A calls Service B's API, how do you test that B's API is still compatible with A's expectations without running both services simultaneously?
- Pact's answer: Consumers generate contracts; providers verify them — no live service-to-service calls needed
- Pact Broker stores and shares contracts between teams; PactFlow is the managed SaaS version
can-i-deployCLI checks if a consumer/provider pair is safe to deploy — the key CI/CD integration point- Pact works best for REST and GraphQL — gRPC support exists but is more complex
- Not a replacement for E2E tests — Pact catches contract violations; E2E tests catch workflow failures; both are needed
The Problem Pact Solves
Without Contract Testing
In a typical microservice setup:
Frontend → Orders API → [Users Service, Products Service, Shipping Service]
The Orders API team mocks out the Users Service in their tests. The mock works as written — but three months ago, the Users Service changed the user.address field from { street, city } to { line1, line2, city, zip }. The Orders API mock still returns the old format. Tests pass. Integration breaks in production.
The alternatives are:
- E2E tests against real services — slow, expensive, require all services to be deployed simultaneously
- Shared test environments — expensive to maintain, often flaky, don't catch contract changes before merge
- API snapshots — like jest snapshots but for API responses; still require a running server and can accept wrong-but-consistent behavior
Pact's Approach
Pact inverts the testing relationship:
- Consumer defines expectations: "I expect
GET /users/:idto return{ id, name, email, address: { line1, city, zip } }" - Pact generates a contract (a JSON file) from these expectations
- Consumer tests run against a Pact mock server — not the real service
- Provider verifies the contract — runs the consumer's expectations as test cases against the real provider
- If the provider's API changes in a way that breaks the consumer's expectations, the provider's verification fails
Setting Up Pact.js: Consumer Side
Installation
npm install --save-dev @pact-foundation/pact
Writing a Consumer Test
// tests/consumers/orders-users-client.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
import path from 'path'
import { UsersClient } from '../../src/clients/users-client'
const { like, string, integer, eachLike } = MatchersV3
const provider = new PactV3({
consumer: 'OrdersService',
provider: 'UsersService',
dir: path.resolve(process.cwd(), 'pacts'), // Where contracts are saved
port: 1234,
})
describe('OrdersService → UsersService contract', () => {
describe('GET /users/:id', () => {
it('returns a user when the user exists', async () => {
await provider
.given('a user with ID abc123 exists')
.uponReceiving('a request for user abc123')
.withRequest({
method: 'GET',
path: '/users/abc123',
headers: { Authorization: like('Bearer token') },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: string('abc123'), // Match any string, but document the example
name: string('Alice Johnson'),
email: string('alice@example.com'),
address: {
line1: string('123 Main St'),
city: string('San Francisco'),
zip: string('94105'),
country: string('US'),
},
plan: string('pro'),
createdAt: string('2026-01-15T10:30:00Z'),
},
})
.executeTest(async (mockServer) => {
// Run the actual client code against the Pact mock server
const client = new UsersClient(mockServer.url)
const user = await client.getUser('abc123', 'Bearer token')
// Assert on what the Orders service actually uses
expect(user.id).toBe('abc123')
expect(user.address.zip).toBeDefined() // Orders service uses zip for shipping
})
})
it('returns 404 when user does not exist', async () => {
await provider
.given('no user with ID xyz999 exists')
.uponReceiving('a request for non-existent user xyz999')
.withRequest({ method: 'GET', path: '/users/xyz999' })
.willRespondWith({
status: 404,
body: { error: string('User not found'), code: string('USER_NOT_FOUND') },
})
.executeTest(async (mockServer) => {
const client = new UsersClient(mockServer.url)
await expect(client.getUser('xyz999', 'Bearer token'))
.rejects.toThrow('User not found')
})
})
})
})
Running this test:
- Validates that the
UsersClientcode correctly handles both success and 404 cases - Generates a
pacts/OrdersService-UsersService.jsoncontract file
The Generated Contract File
The contract file is human-readable JSON that documents the agreement:
{
"consumer": { "name": "OrdersService" },
"provider": { "name": "UsersService" },
"interactions": [
{
"description": "a request for user abc123",
"providerStates": [{ "name": "a user with ID abc123 exists" }],
"request": {
"method": "GET",
"path": "/users/abc123",
"headers": { "Authorization": "Bearer token" }
},
"response": {
"status": 200,
"body": {
"id": "abc123",
"address": { "line1": "123 Main St", "city": "San Francisco", "zip": "94105" }
},
"matchingRules": {
"body.id": { "matchers": [{ "match": "type" }] }
}
}
}
]
}
Setting Up Pact.js: Provider Side
The provider loads the contract and verifies that its actual implementation satisfies every interaction.
// tests/providers/users-service.pact.test.ts
import { Verifier } from '@pact-foundation/pact'
import path from 'path'
import { app } from '../../src/app' // Your Express/Fastify app
import { db } from '../../src/db'
describe('UsersService provider verification', () => {
let server: ReturnType<typeof app.listen>
beforeAll(async () => {
server = app.listen(3001)
})
afterAll(() => server.close())
it('validates all consumer contracts', async () => {
await new Verifier({
providerBaseUrl: 'http://localhost:3001',
// Load contracts from local filesystem (in monorepo)
// or from Pact Broker in CI
pactUrls: [
path.resolve(process.cwd(), '../orders-service/pacts/OrdersService-UsersService.json'),
],
// Or use Pact Broker:
// pactBrokerUrl: 'https://your-org.pactflow.io',
// pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// consumerVersionSelectors: [{ mainBranch: true }, { deployed: true }],
// Provider state handlers — set up test data for each provider state
stateHandlers: {
'a user with ID abc123 exists': async () => {
await db.users.upsert({
id: 'abc123',
name: 'Alice Johnson',
email: 'alice@example.com',
address: { line1: '123 Main St', city: 'San Francisco', zip: '94105', country: 'US' },
plan: 'pro',
})
},
'no user with ID xyz999 exists': async () => {
await db.users.delete({ id: 'xyz999' })
},
},
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_SHA ?? '0.0.0',
}).verifyProvider()
})
})
If the Users Service changes address.zip to address.postalCode, the provider verification fails at this test step — before the change can be deployed.
The Pact Broker: Sharing Contracts Between Teams
In a multi-team environment, contracts need to be shared. The Pact Broker (open source) or PactFlow (managed SaaS) acts as a contract registry:
# Consumer CI: publish the contract after tests pass
npx pact-broker publish ./pacts \
--broker-base-url https://your-org.pactflow.io \
--broker-token $PACT_BROKER_TOKEN \
--consumer-app-version $GIT_SHA \
--branch $BRANCH_NAME
# Provider CI: fetch and verify all consumer contracts
# (uses pactBrokerUrl in the Verifier config above)
can-i-deploy — The Deployment Gate
The critical CI/CD integration is can-i-deploy:
# Before deploying Orders Service to production:
npx pact-broker can-i-deploy \
--pacticipant OrdersService \
--version $GIT_SHA \
--to-environment production \
--broker-base-url https://your-org.pactflow.io \
--broker-token $PACT_BROKER_TOKEN
This checks: "Is there a verified contract between OrdersService at this version and all its providers at their production versions?" If any provider hasn't verified the contract, deployment is blocked.
Async Contract Testing: Message Pacts
Pact isn't limited to HTTP APIs. For event-driven architectures (Kafka, RabbitMQ, SNS/SQS), Pact supports message contracts — ensuring that the events a producer publishes match what consumers expect.
Message Consumer Test
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
const { like, string, integer } = MatchersV3
describe('OrdersService → EventBus contract', () => {
const messagePact = new PactV3({
consumer: 'ShippingService',
provider: 'OrdersService',
dir: path.resolve(process.cwd(), 'pacts'),
})
it('handles an order.created event', async () => {
await messagePact
.expectsToReceive('an order.created event')
.withContent({
eventType: string('order.created'),
orderId: string('order-abc123'),
customerId: string('cust-xyz'),
items: [
{
productId: string('prod-001'),
quantity: integer(2),
price: like(29.99),
},
],
shippingAddress: {
line1: string('123 Main St'),
city: string('San Francisco'),
zip: string('94105'),
},
createdAt: string('2026-03-09T12:00:00Z'),
})
.verify(async (event) => {
// Test that ShippingService correctly processes the event
const result = await processOrderCreatedEvent(event)
expect(result.shipmentQueued).toBe(true)
expect(result.address.zip).toBe('94105')
})
})
})
Message Producer Verification
// On the OrdersService (producer) side
import { MessageProviderPact } from '@pact-foundation/pact'
describe('OrdersService message verification', () => {
it('verifies order.created event format', async () => {
const verifier = new MessageProviderPact({
messageProviders: {
'an order.created event': async () => {
// Return an example event that the producer would actually publish
return {
eventType: 'order.created',
orderId: 'order-abc123',
customerId: 'cust-xyz',
items: [{ productId: 'prod-001', quantity: 2, price: 29.99 }],
shippingAddress: {
line1: '123 Main St',
city: 'San Francisco',
zip: '94105',
},
createdAt: new Date().toISOString(),
}
},
},
pactUrls: [path.resolve(process.cwd(), '../shipping-service/pacts/ShippingService-OrdersService.json')],
providerVersion: process.env.GIT_SHA,
publishVerificationResult: process.env.CI === 'true',
})
await verifier.verify()
})
})
If the Orders team changes shippingAddress.zip to shippingAddress.postalCode in the Kafka event schema, the Shipping service's message contract test fails before the change can be deployed.
Common Pact Pitfalls and Best Practices
Pitfall 1: Over-Specifying the Contract
The most common mistake is specifying too much in consumer contracts:
// ❌ Bad — over-specified contract
.willRespondWith({
status: 200,
body: {
id: 'abc123', // Exact value match — will fail if the test ID changes
name: 'Alice Johnson', // Exact match — irrelevant to the consumer
email: 'alice@example.com',
createdAt: '2026-01-15T10:30:00Z', // Exact timestamp — always wrong
lastLoginAt: '2026-03-09T08:00:00Z', // Field the consumer doesn't use
internalScore: 847, // Field the consumer doesn't use
},
})
// ✅ Good — only specify what the consumer actually uses
.willRespondWith({
status: 200,
body: {
id: string('abc123'), // Type match — any string is fine
name: string('Alice Johnson'), // Consumer displays the name
address: {
zip: string('94105'), // Consumer uses this for shipping calculation
},
// Don't include lastLoginAt, internalScore — consumer doesn't use them
},
})
Only contract the fields your consumer actually reads. Extra fields make contracts brittle and create false failures when the provider adds new fields.
Pitfall 2: Testing Business Logic in Contracts
Pact tests integration, not business logic:
// ❌ Bad — testing business logic in a contract
it('returns upgraded plan after payment', async () => {
await provider
.given('user abc123 just paid for pro plan')
.uponReceiving('GET /users/abc123')
.willRespondWith({ body: { plan: 'pro' } }) // Testing business logic, not integration
.executeTest(async (mockServer) => {
const result = await client.getUser('abc123')
expect(result.plan).toBe('pro') // This is a unit test for the payment flow
})
})
// ✅ Good — testing integration shape
it('returns a user with a plan field', async () => {
await provider
.given('a user with ID abc123 exists')
.uponReceiving('a request for user abc123')
.willRespondWith({
body: {
id: string(),
plan: string('pro'), // Just verifying the field exists and is a string
},
})
.executeTest(async (mockServer) => {
const result = await client.getUser('abc123')
expect(typeof result.plan).toBe('string') // Shape, not value
})
})
Pitfall 3: Skipping Provider States
Provider states are critical for reproducible tests. Without them, the provider test data is unpredictable:
// ❌ Bad — no provider state (what data exists when this test runs?)
.given('')
// ✅ Good — explicit provider state
.given('a user with ID abc123 exists with plan=pro')
Provider states should be:
- Specific enough for the state handler to set up the right data
- Reusable across multiple interactions that share setup
- Idempotent — running them twice shouldn't cause failures
Pact vs. Alternative Approaches
| Approach | Pros | Cons |
|---|---|---|
| Pact contract testing | Catches breaking changes before deployment, no live services needed | Setup overhead, needs organizational adoption |
| API snapshot testing | Simple, no Broker needed | Requires running server, accepts wrong-but-consistent behavior |
| E2E integration tests | Tests real workflows | Slow, expensive, requires all services deployed together |
| Manual API versioning | Low tooling cost | Relies on discipline; breaking changes still slip through |
| OpenAPI validation | Documents the schema | Doesn't test that consumers use the documented API correctly |
Methodology
- npm download data from npmjs.com API, March 2026 weekly averages
- Package versions:
@pact-foundation/pactv12.x - Sources: Pact Foundation documentation (docs.pact.io), PactFlow blog, "Consumer-Driven Contracts" by Martin Fowler, Thoughtworks Technology Radar
Explore API testing tools on APIScout — compare Pact with other API testing and mocking libraries.
Related: API Mocking: MSW vs Mirage vs WireMock 2026 · API Error Handling Patterns for Production 2026 · API Breaking Changes Without Breaking Clients 2026