Contract Testing
Consumer-driven contract testing with Pact for API compatibility
Contract Testing
Contract testing ensures that services can communicate with each other by verifying that the API provider meets the expectations of its consumers. This is especially valuable in microservices architecture.
What is Contract Testing?
Traditional API Testing:
- Consumer tests against actual provider
- Provider can break consumer without knowing
- Integration tests are slow and brittle
Contract Testing:
- Consumer defines expectations (contract)
- Provider verifies it meets contract
- Fast, independent testing
- Early detection of breaking changes
How Pact Works
1. Consumer creates contract (Pact file)
↓
2. Contract shared with Provider
↓
3. Provider verifies it satisfies contract
↓
4. Both sides deploy safelyPact Setup
Installation
npm install --save-dev @pact-foundation/pactConsumer Side (Frontend)
Writing Consumer Tests
// tests/contract/user-service.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { getUserProfile, updateUserProfile } from '@/api/user-service';
const { like, eachLike, integer, string } = MatchersV3;
const provider = new PactV3({
consumer: 'FrontendApp',
provider: 'UserService',
dir: './pacts',
});
describe('User Service Contract', () => {
describe('GET /api/users/:id', () => {
it('returns user profile', async () => {
await provider
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/api/users/123',
headers: {
'Authorization': like('Bearer token123'),
},
})
.willRespondWith({
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
id: like(123),
name: like('John Doe'),
email: like('john@example.com'),
role: like('admin'),
createdAt: like('2024-01-01T00:00:00Z'),
},
})
.executeTest(async (mockServer) => {
// Call your actual API client
const user = await getUserProfile(mockServer.url, 123);
expect(user.id).toBe(123);
expect(user.name).toBeDefined();
expect(user.email).toBeDefined();
});
});
it('handles user not found', async () => {
await provider
.given('user 999 does not exist')
.uponReceiving('a request for non-existent user')
.withRequest({
method: 'GET',
path: '/api/users/999',
})
.willRespondWith({
status: 404,
body: {
error: like('User not found'),
},
})
.executeTest(async (mockServer) => {
await expect(
getUserProfile(mockServer.url, 999)
).rejects.toThrow('User not found');
});
});
});
describe('POST /api/users/:id', () => {
it('updates user profile', async () => {
await provider
.given('user 123 exists')
.uponReceiving('a request to update user 123')
.withRequest({
method: 'POST',
path: '/api/users/123',
headers: {
'Content-Type': 'application/json',
},
body: {
name: like('Jane Doe'),
email: like('jane@example.com'),
},
})
.willRespondWith({
status: 200,
body: {
id: like(123),
name: like('Jane Doe'),
email: like('jane@example.com'),
updatedAt: like('2024-01-09T00:00:00Z'),
},
})
.executeTest(async (mockServer) => {
const updated = await updateUserProfile(mockServer.url, 123, {
name: 'Jane Doe',
email: 'jane@example.com',
});
expect(updated.name).toBe('Jane Doe');
expect(updated.email).toBe('jane@example.com');
});
});
});
describe('GET /api/users', () => {
it('returns list of users', async () => {
await provider
.given('multiple users exist')
.uponReceiving('a request for all users')
.withRequest({
method: 'GET',
path: '/api/users',
query: {
page: like('1'),
limit: like('10'),
},
})
.willRespondWith({
status: 200,
body: {
users: eachLike({
id: integer(1),
name: string('John Doe'),
email: string('john@example.com'),
}),
total: like(100),
page: like(1),
},
})
.executeTest(async (mockServer) => {
const result = await getUsers(mockServer.url, { page: 1, limit: 10 });
expect(result.users).toBeInstanceOf(Array);
expect(result.users[0]).toHaveProperty('id');
expect(result.users[0]).toHaveProperty('name');
expect(result.total).toBeDefined();
});
});
});
});Matchers
import { MatchersV3 } from '@pact-foundation/pact';
const {
like, // Type matching
eachLike, // Array with type matching
integer, // Integer type
decimal, // Decimal type
string, // String type
boolean, // Boolean type
regex, // Regex matching
iso8601DateTime, // ISO datetime
uuid, // UUID format
} = MatchersV3;
// Examples
body: {
id: integer(123),
email: regex('john@example.com', /^[\w.]+@[\w.]+$/),
createdAt: iso8601DateTime('2024-01-09T10:00:00Z'),
uuid: uuid('550e8400-e29b-41d4-a716-446655440000'),
tags: eachLike('tag1'),
metadata: like({ key: 'value' }),
}Provider Side (Backend)
Verifying Contracts
// tests/contract/verify-pacts.test.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
describe('Pact Verification', () => {
it('validates contracts from consumers', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3001',
// Load pact files
pactUrls: [
path.resolve(__dirname, '../../pacts/FrontendApp-UserService.json'),
],
// Provider states setup
stateHandlers: {
'user 123 exists': async () => {
// Setup: Create user 123 in test database
await db.users.create({
id: 123,
name: 'John Doe',
email: 'john@example.com',
});
},
'user 999 does not exist': async () => {
// Setup: Ensure user 999 doesn't exist
await db.users.delete({ id: 999 });
},
'multiple users exist': async () => {
// Setup: Create multiple users
await db.users.createMany([
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
// ... more users
]);
},
},
// Tear down after each verification
afterEach: async () => {
await db.users.deleteAll();
},
});
await verifier.verifyProvider();
});
});Pact Broker (Sharing Contracts)
Setup Pact Broker
# docker-compose.yml
version: '3'
services:
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- '9292:9292'
environment:
PACT_BROKER_DATABASE_URL: postgres://postgres:password@postgres/pact_broker
depends_on:
- postgres
postgres:
image: postgres:14
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: pact_brokerPublish Contracts (Consumer)
// package.json
{
"scripts": {
"pact:publish": "pact-broker publish ./pacts --consumer-app-version=$GIT_COMMIT --broker-base-url=http://localhost:9292"
}
}# Publish pacts to broker
npm run pact:publishVerify from Broker (Provider)
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3001',
// Fetch from Pact Broker
pactBrokerUrl: 'http://localhost:9292',
provider: 'UserService',
// Only verify pacts from main branch
consumerVersionSelectors: [
{ mainBranch: true },
{ deployedOrReleased: true },
],
// Publish verification results
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
});CI/CD Integration
Consumer Pipeline
# .github/workflows/contract-tests.yml
name: Contract Tests (Consumer)
on: [push, pull_request]
jobs:
consumer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run consumer tests
run: npm run test:contract
- name: Publish pacts
if: github.ref == 'refs/heads/main'
run: |
npm run pact:publish
env:
GIT_COMMIT: ${{ github.sha }}
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}Provider Pipeline
# .github/workflows/verify-contracts.yml
name: Verify Contracts (Provider)
on: [push, pull_request]
jobs:
provider:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Start provider
run: npm start &
- name: Wait for provider
run: npx wait-on http://localhost:3001
- name: Verify contracts
run: npm run pact:verify
env:
GIT_COMMIT: ${{ github.sha }}
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}Best Practices
- Test Behavior, Not Data: Use matchers for flexible contracts
- Version Contracts: Track which consumers use which versions
- Provider States: Setup data needed for each interaction
- Use Pact Broker: Central place for contracts
- Block Breaking Changes: Fail CI if contract broken
- Tag Versions: Tag production/staging contracts
- Document Contracts: They serve as living API documentation
Common Pitfalls
❌ Over-specifying contracts: Too rigid, breaks often
✅ Use type matchers, not exact values
❌ Testing implementation: Checking internal details
✅ Test API contract only
❌ No provider states: Tests fail randomly
✅ Setup proper test data
❌ Duplicate logic: Repeating provider code
✅ Reuse actual API client code
When to Use Contract Testing
✅ Use contract testing when:
- Microservices architecture
- Multiple teams/services
- API versioning is complex
- Want fast, independent tests
- Need early breaking change detection
❌ Skip when:
- Monolithic application
- Single team owning everything
- Simple API with few changes
- API is public/third-party
Contract vs E2E Testing
| Aspect | Contract Tests | E2E Tests |
|---|---|---|
| Speed | Fast | Slow |
| Reliability | High | Can be flaky |
| Scope | API contract | Full system |
| When | On every commit | Pre-deployment |
| Isolation | High | Low |
Best Practice: Use both! Contract tests for API compatibility, E2E for critical flows.
Contract testing catches breaking changes early—before they reach production and before they break other teams.