Contract Testing
Pact, consumer-driven contracts, and microservice testing.
Contract Testing
Contract testing ensures that services can communicate with each other without actually running them together. It's a crucial testing strategy for microservices that provides fast, reliable integration testing without the complexity of end-to-end tests.
The Problem with Traditional Integration Testing
E2E Testing Challenges
- Slow: Requires spinning up entire system
- Brittle: Tests break when unrelated services change
- Expensive: Complex infrastructure and maintenance
- Late Feedback: Failures discovered late in the pipeline
Integration Testing Challenges
- Environment Dependencies: Need all services running
- Data Management: Complex test data setup
- Flaky Tests: Network issues, timing problems
- Parallel Execution: Difficult to run tests concurrently
Contract Testing Solution
Contract testing breaks the problem into two parts:
- Consumer Tests: Verify consumer expectations against a mock provider
- Provider Tests: Verify provider implementation against consumer expectations
The Flow
Pact: The Contract Testing Framework
Pact is the most popular contract testing framework with implementations for multiple languages.
Key Concepts
1. Consumer-Driven Contracts
The consumer defines the contract based on its needs:
// Consumer test (React app consuming API)
import { pactWith } from 'jest-pact';
import { getUser } from './api-service';
pactWith({ consumer: 'web-app', provider: 'user-api' }, provider => {
test('returns user data', async () => {
await provider.addInteraction({
state: 'user exists',
uponReceiving: 'request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: { 'Accept': 'application/json' }
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 123,
name: 'John Doe',
email: 'john@example.com',
active: true
}
}
});
const user = await getUser(123, provider.mockService.baseUrl);
expect(user).toEqual({
id: 123,
name: 'John Doe',
email: 'john@example.com',
active: true
});
});
});2. Provider Verification
The provider verifies it can fulfill all consumer contracts:
// Provider test (Express API)
import { Verifier } from '@pact-foundation/pact';
import express from 'express';
import userRoutes from './routes/users';
describe('User API Provider Verification', () => {
test('validates contracts', async () => {
const app = express();
app.use('/users', userRoutes);
const verifier = new Verifier({
provider: 'user-api',
providerBaseUrl: 'http://localhost:8080',
pactUrls: ['./pacts/web-app-user-api.json'],
stateHandlers: {
'user exists': () => {
// Setup test data for this state
return Promise.resolve();
}
}
});
const output = await verifier.verifyProvider();
console.log(output);
});
});Contract Testing Patterns
1. Request-Response Contracts
Simple GET Contract
{
"consumer": "web-app",
"provider": "user-api",
"interactions": [
{
"description": "request for user by ID",
"request": {
"method": "GET",
"path": "/users/123",
"headers": {
"Accept": "application/json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"active": true
},
"matchingRules": {
"$.body": {
"jsonClass": "org.apache.commons.lang.builder.EqualsBuilder"
},
"$.body.id": {
"match": "integer"
},
"$.body.name": {
"match": "type"
},
"$.body.email": {
"match": "regex",
"regex": "^[\\w\\.\\-+]+@[\\w\\.-]+\\.[a-zA-Z]{2,}$"
}
}
}
}
]
}POST Request Contract
test('creates new user', async () => {
await provider.addInteraction({
uponReceiving: 'create new user',
withRequest: {
method: 'POST',
path: '/users',
headers: {
'Content-Type': 'application/json'
},
body: {
name: 'Jane Doe',
email: 'jane@example.com'
}
},
willRespondWith: {
status: 201,
headers: {
'Content-Type': 'application/json',
'Location': 'http://localhost/users/456'
},
body: {
id: 456,
name: 'Jane Doe',
email: 'jane@example.com',
active: true,
createdAt: '2023-01-01T00:00:00Z'
},
matchingRules: {
"$.body.id": { "match": "integer" },
"$.body.createdAt": { "match": "regex", "regex": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" }
}
}
});
});2. Message/Event Contracts
Async Message Contract
// Consumer expecting to receive user events
pactWith({ consumer: 'notification-service', provider: 'user-api' }, provider => {
test('receives user created event', async () => {
await provider.addInteraction({
state: 'user created',
uponReceiving: 'user created event',
withRequest: {
method: 'POST',
path: '/messages',
body: {
"type": "UserCreated",
"data": {
"userId": 123,
"name": "John Doe",
"email": "john@example.com"
}
}
},
willRespondWith: {
status: 200,
body: {
"success": true
}
}
});
});
});Advanced Pact Features
1. State Management
Provider states help setup the right test data:
// Consumer test with state
await provider.addInteraction({
state: 'user with orders exists',
uponReceiving: 'request for user orders',
withRequest: {
method: 'GET',
path: '/users/123/orders'
},
willRespondWith: {
status: 200,
body: [
{
id: 1,
userId: 123,
total: 99.99,
status: 'completed'
}
]
}
});
// Provider state handler
const stateHandlers = {
'user with orders exists': async () => {
// Create test user with orders
await userService.create({ id: 123, name: 'John Doe' });
await orderService.create({ id: 1, userId: 123, total: 99.99 });
},
'user does not exist': async () => {
// Ensure no user exists
await userService.delete(123);
}
};2. Matching Rules
Instead of exact values, use flexible matching:
// Basic type matching
{
"id": pact.like(123), // Any integer
"name": pact.like('John'), // Any string
"active": pact.like(true), // Any boolean
"metadata": pact.somethingLike({
created: pact.like('2023-01-01T00:00:00Z')
})
}
// Regex matching
{
"email": pact.term({
generate: 'test@example.com',
matcher: '^[\\w\\.\\-+]+@[\\w\\.-]+\\.[a-zA-Z]{2,}$'
}),
"url": pact.term({
generate: '/users/123',
matcher: '/users/\\d+'
})
}
// Array matching
{
"items": pact.eachLike({
id: pact.like(1),
name: pact.like('Item'),
min: 1 // At least one item
})
}3. Provider States with Parameters
// Consumer test
await provider.addInteraction({
state: 'user with specific email exists',
uponReceiving: 'request to update user',
withRequest: {
method: 'PUT',
path: '/users/123',
body: { email: 'newemail@example.com' }
},
willRespondWith: {
status: 200,
body: { success: true }
}
});
// Provider with parameterized state
const stateHandlers = {
'user with specific email exists': async (params) => {
// params might contain user ID, email, etc.
const { userId } = params;
await userService.create({
id: userId,
email: 'oldemail@example.com'
});
}
};Pact Broker Integration
Pact Broker is central to contract testing workflows:
Publishing Contracts
// CI/CD pipeline - Consumer
const publish = async () => {
await new Publisher({
pactBroker: 'https://pact-broker.example.com',
pactFilesOrDirs: ['./pacts/'],
consumerVersion: process.env.GIT_COMMIT,
branch: process.env.GIT_BRANCH,
tags: ['prod', 'staging']
}).publish();
};Verifying Contracts
// CI/CD pipeline - Provider
const verify = async () => {
const verifier = new Verifier({
provider: 'user-api',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: 'https://pact-broker.example.com',
providerVersions: [process.env.GIT_COMMIT],
publishVerificationResult: true,
providerVersionBranch: process.env.GIT_BRANCH
});
const output = await verifier.verifyProvider();
console.log('Pact verification completed:', output);
};Can-I-Deploy Checks
# Check if provider can be deployed
pact-broker can-i-deploy \
--pacticipant user-api \
--version $VERSION \
--to-environment production
# Check if consumer can be deployed
pact-broker can-i-deploy \
--pacticipant web-app \
--version $VERSION \
--to-environment stagingBest Practices
1. Contract Design
Principle: Contracts Should Be Consumer-Driven
Consumers should define what they need, not what providers offer. This prevents over-engineering and unnecessary coupling.
// Good: Consumer defines exactly what it needs
{
"name": pact.like('John'), // Only the fields consumer uses
"email": pact.like('john@example.com')
}
// Bad: Consumer expects everything provider offers
{
"name": pact.like('John'),
"email": pact.like('john@example.com'),
"address": pact.like('123 Main St'), // Consumer doesn't use this
"phone": pact.like('555-1234'), // Consumer doesn't use this
"metadata": pact.somethingLike({}) // Consumer doesn't use this
}2. Versioning Strategy
- Semantic Versioning: Use semantic versions for breaking changes
- Tags: Use environment tags (prod, staging, dev)
- Branches: Track feature branch deployments
3. State Management
- Idempotent: State setup should be repeatable
- Isolated: Each test should have clean state
- Minimal: Only setup necessary data
4. Test Organization
// Organize tests by feature/state
describe('User API Contracts', () => {
describe('when user exists', () => {
test('returns user data');
test('updates user successfully');
test('deletes user');
});
describe('when user does not exist', () => {
test('returns 404');
test('cannot update');
test('cannot delete');
});
});Common Pitfalls & Solutions
1. Over-Specification
Problem: Contracts contain too much detail, making them brittle.
// Bad: Too specific
{
"createdAt": "2023-01-01T12:00:00.123Z" // Exact timestamp
}
// Good: Flexible matching
{
"createdAt": pact.term({
generate: "2023-01-01T12:00:00.123Z",
matcher: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
})
}2. Missing State Setup
Problem: Provider verification fails due to missing test data.
Solution: Always implement proper state handlers.
3. Contract Drift
Problem: Consumer and provider contracts diverge over time.
Solution: Regular CI/CD checks and can-i-deploy verification.
4. Test Environment Issues
Problem: Tests fail due to environment differences.
Solution: Use Docker containers for consistent test environments.
Tools & Ecosystem
Pact Language Support
- JavaScript/TypeScript:
@pact-foundation/pact - Java:
pact-jvm - Python:
pact-python - Ruby:
pact-ruby - Go:
pact-go - .NET:
PactNet
Broker Options
- Pact Cloud: Hosted service by Pact.io
- Pact Broker OSS: Self-hosted Ruby application
- Docker:
pactfoundation/pact-broker
CI/CD Integration
# GitHub Actions example
name: Contract Tests
on: [push, pull_request]
jobs:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- run: npm install
- run: npm run test:pact
- run: npm run publish:pact
provider-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: docker-compose up -d
- run: npm install
- run: npm run verify:pactContract Testing ≠ Full Integration Testing
Contract testing ensures API compatibility but doesn't test business logic across services. Combine with limited E2E tests for critical user flows.