- Published on
API Contract Testing With Pact — Catching Breaking Changes Before They Hit Production
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Integration tests are slow. End-to-end tests are flaky. What if you could verify that your API actually delivers what each client expects, without firing up the entire stack? Consumer-driven contract testing with Pact does exactly that: clients define what they expect, providers prove they deliver it, and a broker keeps everyone in sync.
- Consumer-Driven Contract Testing Concept
- Pact Consumer Test: Define What You Expect
- Pact Provider Verification: Prove You Deliver It
- Pact Broker: Sharing and Managing Contracts
- CI Integration: Consumer Publishes, Provider Verifies
- Handling Backwards-Compatible Changes
- Schema Evolution Strategies
- Contract Testing vs Integration Testing
- Checklist
- Conclusion
Consumer-Driven Contract Testing Concept
In traditional testing, the provider (API) defines the contract. In Pact, the consumer (client) defines it. Your client code explicitly states: "I need this endpoint to return this shape." The provider must verify: "Yes, we actually return that."
This reversal prevents the silent contract breakage that happens when APIs change but no client was actually using the old format.
// Consumer perspective: What does the Orders API need to provide?
// src/consumer/orders.test.ts
import { PactV3 } from '@pact-foundation/pact'
import fetch from 'node-fetch'
const pact = new PactV3({
consumer: 'WebApp',
provider: 'OrdersAPI',
dir: './pacts'
})
describe('Orders API Consumer', () => {
it('should fetch order details', async () => {
// Define the interaction we expect
await pact
.addInteraction()
.given('an order with id 123 exists')
.uponReceiving('a request for order details')
.withRequest('GET', '/orders/123')
.willRespondWith(200, {
body: {
id: '123',
status: 'confirmed',
total: 99.99,
items: [
{
productId: 'WIDGET-001',
quantity: 2,
price: 49.99
}
],
createdAt: '2026-03-15T10:00:00Z'
}
})
.executeTest(async interaction => {
// Now test our code against this expectation
const response = await fetch(`${interaction.baseUrl}/orders/123`)
const order = await response.json()
expect(response.status).toBe(200)
expect(order.id).toBe('123')
expect(order.status).toBe('confirmed')
expect(order.items).toHaveLength(1)
})
})
it('should list recent orders', async () => {
await pact
.addInteraction()
.given('user has orders')
.uponReceiving('a request for order list')
.withRequest('GET', '/orders', {
headers: { Authorization: 'Bearer token123' }
})
.willRespondWith(200, {
body: {
data: [
{
id: '123',
status: 'confirmed',
total: 99.99
}
],
pagination: {
hasMore: false
}
}
})
.executeTest(async interaction => {
const response = await fetch(`${interaction.baseUrl}/orders`, {
headers: { Authorization: 'Bearer token123' }
})
const result = await response.json()
expect(result.data).toBeDefined()
expect(Array.isArray(result.data)).toBe(true)
expect(result.pagination).toBeDefined()
})
})
it('should return 404 for non-existent order', async () => {
await pact
.addInteraction()
.given('order with id 999 does not exist')
.uponReceiving('a request for non-existent order')
.withRequest('GET', '/orders/999')
.willRespondWith(404, {
body: {
error: 'Order not found'
}
})
.executeTest(async interaction => {
const response = await fetch(`${interaction.baseUrl}/orders/999`)
expect(response.status).toBe(404)
})
})
})
Pact Consumer Test: Define What You Expect
The consumer test is your contract definition. It runs against a mock provider that validates every request matches expectations.
// src/consumer/payment-service.test.ts
import { PactV3 } from '@pact-foundation/pact'
const pact = new PactV3({
consumer: 'CheckoutUI',
provider: 'PaymentService',
dir: './pacts'
})
describe('Payment Service Consumer', () => {
it('should create payment intent', async () => {
await pact
.addInteraction()
.uponReceiving('a request to create payment intent')
.withRequest('POST', '/payment-intents', {
body: {
amount: 9999,
currency: 'USD',
description: 'Order #123'
}
})
.willRespondWith(201, {
body: {
id: 'pi_123abc',
clientSecret: 'pi_123abc_secret_xyz',
status: 'requires_payment_method',
amount: 9999,
currency: 'USD'
}
})
.executeTest(async interaction => {
const response = await fetch(
`${interaction.baseUrl}/payment-intents`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 9999,
currency: 'USD',
description: 'Order #123'
})
}
)
expect(response.status).toBe(201)
const intent = await response.json()
expect(intent.id).toBeDefined()
expect(intent.clientSecret).toBeDefined()
expect(intent.status).toBe('requires_payment_method')
})
})
it('should handle validation errors', async () => {
await pact
.addInteraction()
.uponReceiving('a request with invalid amount')
.withRequest('POST', '/payment-intents', {
body: {
amount: -100,
currency: 'USD'
}
})
.willRespondWith(400, {
body: {
error: 'Amount must be positive',
code: 'INVALID_AMOUNT'
}
})
.executeTest(async interaction => {
const response = await fetch(
`${interaction.baseUrl}/payment-intents`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: -100,
currency: 'USD'
})
}
)
expect(response.status).toBe(400)
})
})
})
Pact Provider Verification: Prove You Deliver It
The provider runs Pact verification. It reads all consumer contracts and proves that the actual API implementation satisfies them.
// provider/orders-api.test.ts
import { Verifier } from '@pact-foundation/pact'
import app from './app'
describe('Orders API Provider', () => {
it('should satisfy all Orders API consumer contracts', async () => {
const verifier = new Verifier({
provider: 'OrdersAPI',
consumerVersionSelectors: [
{ deployed: true },
{ mainBranch: true }
],
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.mycompany.com',
publishVerificationResult: true,
providerVersion: process.env.VERSION || 'latest'
})
// Set up test database state
beforeEach(async () => {
await db.seed()
})
return verifier.verifyProvider()
})
})
// Alternative: Local pact files (before Pact Broker is set up)
describe('Orders API Provider - Local', () => {
it('should satisfy consumer pacts', async () => {
const verifier = new Verifier({
provider: 'OrdersAPI',
providerBaseUrl: 'http://localhost:3000',
pactFiles: ['./pacts/webapp-ordersapi.json'],
stateHandlers: {
'an order with id 123 exists': async () => {
await db.orders.create({
id: '123',
status: 'confirmed',
total: 99.99,
items: [{ productId: 'WIDGET-001', quantity: 2, price: 49.99 }],
createdAt: new Date('2026-03-15')
})
},
'user has orders': async () => {
await db.orders.create({
id: '123',
status: 'confirmed',
total: 99.99
})
},
'order with id 999 does not exist': async () => {
// Ensure it doesn't exist
await db.orders.delete({ where: { id: '999' } })
}
}
})
return verifier.verifyProvider()
})
})
On the provider side, implement the actual endpoints:
// provider/handlers/orders.ts
import express from 'express'
import { db } from '../db'
export const ordersRouter = express.Router()
ordersRouter.get('/orders/:id', async (req, res) => {
try {
const order = await db.orders.findUnique({
where: { id: req.params.id },
include: { items: true }
})
if (!order) {
return res.status(404).json({ error: 'Order not found' })
}
res.json({
id: order.id,
status: order.status,
total: order.total,
items: order.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.price
})),
createdAt: order.createdAt.toISOString()
})
} catch (error) {
res.status(500).json({ error: 'Internal server error' })
}
})
ordersRouter.get('/orders', async (req, res) => {
try {
const orders = await db.orders.findMany({
where: { userId: req.user.id },
take: 50
})
res.json({
data: orders.map(o => ({
id: o.id,
status: o.status,
total: o.total
})),
pagination: {
hasMore: false
}
})
} catch (error) {
res.status(500).json({ error: 'Internal server error' })
}
})
Pact Broker: Sharing and Managing Contracts
A Pact Broker is a central repository. Consumers publish their pacts, providers fetch and verify them. It tracks who depends on whom and prevents integration failures before they happen.
// CI/CD: Consumer publishes pact
// .github/workflows/publish-pact.yml
name: Publish Pact
on:
push:
branches: [main, develop]
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm test -- --testPathPattern=consumer
- name: Publish pact
run: |
npx pact-broker publish pacts/ \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.ref_name }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-username=${{ secrets.PACT_BROKER_USER }} \
--broker-password=${{ secrets.PACT_BROKER_PASS }}
// CI/CD: Provider verifies pacts
// .github/workflows/verify-pacts.yml
name: Verify Pacts
on:
push:
branches: [main]
workflow_dispatch:
inputs:
consumerVersion:
description: 'Consumer version to verify against'
required: false
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run build
- name: Verify pacts
run: |
npx pact-broker can-i-deploy \
--pacticipant=OrdersAPI \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-username=${{ secrets.PACT_BROKER_USER }} \
--broker-password=${{ secrets.PACT_BROKER_PASS }}
- name: Run provider verification
run: npm test -- --testPathPattern=provider
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_USER: ${{ secrets.PACT_BROKER_USER }}
PACT_BROKER_PASS: ${{ secrets.PACT_BROKER_PASS }}
CI Integration: Consumer Publishes, Provider Verifies
The contract lifecycle:
- Consumer writes test expecting API behavior
- Consumer test runs against mock, generates pact file
- CI publishes pact to Broker
- Provider CI fetches latest pacts from Broker
- Provider runs verification against real code
- Result published back to Broker
- Dashboard shows compatibility matrix
// Use Pact in contract-first workflow
// Generate consumer SDK from pact (optional but powerful)
// src/api-client/generated/orders-client.ts
// This could be auto-generated from the pact contract
export class OrdersAPIClient {
constructor(private baseUrl: string) {}
async getOrder(orderId: string): Promise<Order> {
const response = await fetch(`${this.baseUrl}/orders/${orderId}`)
if (!response.ok) throw new Error('Order not found')
return response.json()
}
async listOrders(): Promise<{ data: Order[]; pagination: Pagination }> {
const response = await fetch(`${this.baseUrl}/orders`)
return response.json()
}
async createPaymentIntent(amount: number): Promise<PaymentIntent> {
const response = await fetch(`${this.baseUrl}/payment-intents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, currency: 'USD' })
})
if (!response.ok) throw new Error('Failed to create payment intent')
return response.json()
}
}
Handling Backwards-Compatible Changes
Not all changes are breaking. Pact helps identify which are:
// SAFE: Adding optional field
consumer expects: { id, status, total }
provider returns: { id, status, total, createdAt }
// OK - consumer ignores unknown fields
// UNSAFE: Removing required field
consumer expects: { id, status, total }
provider returns: { id, status }
// BROKEN - consumer crashes on missing field
// SAFE: Renaming and keeping old field
// Gradually migrate consumers
// Provider adds new field but keeps old one
const response = {
id: order.id,
status: order.status,
// Old field for backwards compatibility
orderStatus: order.status,
total: order.total
}
// Provider can remove old field after all consumers upgraded
Schema Evolution Strategies
// Strategy 1: Strangler pattern in API evolution
// Old endpoint still works, new endpoint available in parallel
app.get('/orders/:id', oldOrderHandler) // Deprecated but works
app.get('/v2/orders/:id', newOrderHandler) // New contract
// Strategy 2: Required → Optional field transition
// Phase 1: Keep field, mark as deprecated
{
orderId: '123',
total: 99.99,
deprecated_status: 'confirmed' // Will be removed
}
// Phase 2: Introduce new field
{
orderId: '123',
total: 99.99,
deprecated_status: 'confirmed',
status: 'confirmed'
}
// Phase 3: Wait for all consumers to migrate
// Phase 4: Remove deprecated field
{
orderId: '123',
total: 99.99,
status: 'confirmed'
}
// Strategy 3: Use content negotiation
app.get('/orders/:id', (req, res) => {
const apiVersion = req.headers['accept-version'] || '1'
if (apiVersion === '2') {
return res.json({ orderId: '123', total: 99.99, status: 'confirmed' })
}
res.json({
id: '123',
total: 99.99,
status: 'confirmed'
})
})
Contract Testing vs Integration Testing
| Aspect | Contract Testing | Integration Testing |
|---|---|---|
| Speed | Very fast | Slow |
| Scope | API boundary only | Full system |
| Mocking | Provider mocked | Nothing mocked |
| Coverage | Catches breaking changes | Catches runtime issues |
| Best for | Preventing silent contract breakage | Verifying complete workflows |
Use both: contract tests catch incompatibilities early, integration tests verify the whole system works.
// Typical test pyramid
describe('Order Processing', () => {
// Unit tests: 70% - fastest
describe('Order entity', () => {
it('calculates total correctly', () => {
const order = new Order([{ price: 10, qty: 2 }])
expect(order.total).toBe(20)
})
})
// Contract tests: 20% - medium speed
describe('API contracts', () => {
it('satisfies consumer expectations', async () => {
// Pact test
})
})
// Integration tests: 10% - slowest
describe('End-to-end', () => {
it('can create order from UI to database', async () => {
// Full stack test
})
})
})
Checklist
- Consumer tests define expected API behavior
- Pact files generated from consumer tests
- Provider verification tests all consumer contracts
- Pact Broker set up for contract sharing
- CI publishes consumer pacts after tests pass
- Provider CI verifies against latest pacts
- Backwards-compatibility rules documented
- Schema evolution follows strangler pattern
- Contract changes reviewed before deployment
- Can-I-Deploy check prevents incompatible releases
Conclusion
Contract testing with Pact gives you the safety of integration tests with the speed of unit tests. You catch breaking changes in CI, not in production. Each team owns their contract and proves compliance independently, without coordinating full-stack deployments. It's the foundation of confident service independence.