In microservices architectures, where dozens or hundreds of services communicate over network boundaries, ensuring compatibility between consumers and providers is critical. Contract testing provides a solution by capturing the expectations between services and verifying them independently, catching breaking changes before they reach production. For broader context on API testing strategies, see API Testing Architecture in Microservices. This comprehensive guide explores consumer-driven contracts, the Pact framework, schema validation, and strategies for maintaining backward compatibility.
Understanding Contract Testing
The Problem with Traditional Testing
Traditional integration testing approaches have significant drawbacks in microservices environments:
End-to-End Testing Issues:
- Slow and expensive to run
- Brittle and prone to flakiness
- Require all services to be running
- Often catch problems too late
- Difficult to debug failures
- Hard to maintain comprehensive coverage
Mocking Limitations:
- Mocks can drift from actual implementations
- Consumer mocks don’t verify provider behavior
- Provider tests don’t verify consumer expectations
- Changes in provider may not break mocked tests
Contract Testing Solution
Contract testing sits between unit tests and E2E tests, providing fast feedback while ensuring real compatibility:
┌─────────────┐ ┌─────────────┐
│ Consumer │──── uses ───▶│ Provider │
│ Service │ │ Service │
└─────────────┘ └─────────────┘
│ │
│ publishes │ verifies
▼ ▼
┌──────────────────────────────────────────┐
│ Contract Broker │
│ (Stores contracts & results) │
└──────────────────────────────────────────┘
Benefits:
- Fast execution (no network calls needed)
- Independent testing (services don’t need to be running together)
- Consumer-driven (captures real usage patterns)
- Early detection of breaking changes
- Clear ownership and documentation of APIs
Consumer-Driven Contracts
Principles of Consumer-Driven Contracts
Consumer-driven contract testing (CDCT) flips traditional API design:
- Consumers define the contract based on their actual needs
- Providers must honor all consumer contracts they’ve committed to
- Contracts are executable specifications verified in CI/CD 4 (as discussed in Detox: Grey-Box Testing for React Native Applications). Both sides test independently without coordination
The Contract Lifecycle
1. Consumer writes contract
↓
2. Consumer tests against contract (mock provider)
↓
3. Consumer publishes contract to broker
↓
4. Provider retrieves contract
↓
5. Provider verifies it can fulfill contract
↓
6. Provider publishes verification results
↓
7. Can consumer deploy? Check provider verification
↓
8. Can provider deploy? Check all consumer contracts
Pact Framework: Implementation Guide
Setting Up Pact
Pact is the most popular consumer-driven contract testing framework, supporting multiple languages and protocols.
Installation (JavaScript/TypeScript):
npm install --save-dev @pact-foundation/pact
Installation (Python):
pip install pact-python
Installation (Java):
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.6.0</version>
<scope>test</scope>
</dependency>
Consumer Side: Writing Pact Tests
Example: User Service Consumer
// user-service-consumer.pact.test.js
import { pact } from '@pact-foundation/pact';
import { like, eachLike } from '@pact-foundation/pact/dsl/matchers';
import { UserClient } from '../src/clients/user-client';
const provider = pact({
consumer: 'OrderService',
provider: 'UserService',
port: 1234,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn'
});
describe('User Service Contract', () => {
before(() => provider.setup());
afterEach(() => provider.verify());
after(() => provider.finalize());
describe('Get user by ID', () => {
const expectedUser = {
id: '123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
status: 'active'
};
before(() => {
return provider.addInteraction({
state: 'user 123 exists',
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/api/users/123',
headers: {
'Authorization': like('Bearer token-123')
}
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json'
},
body: like(expectedUser)
}
});
});
it('returns the user', async () => {
const client = new UserClient('http://localhost:1234');
const user = await client.getUser('123', 'Bearer token-123');
expect(user).toMatchObject({
id: '123',
email: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
status: expect.any(String)
});
});
});
describe('Get user orders', () => {
before(() => {
return provider.addInteraction({
state: 'user 123 has orders',
uponReceiving: 'a request for user orders',
withRequest: {
method: 'GET',
path: '/api/users/123/orders',
query: 'status=active&limit=10',
headers: {
'Authorization': like('Bearer token-123')
}
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
orders: eachLike({
id: like('order-456'),
total: like(99.99),
status: like('confirmed'),
createdAt: like('2025-10-01T10:00:00Z')
}),
pagination: {
total: like(25),
page: like(1),
limit: like(10)
}
}
}
});
});
it('returns user orders with pagination', async () => {
const client = new UserClient('http://localhost:1234');
const response = await client.getUserOrders('123', {
status: 'active',
limit: 10
}, 'Bearer token-123');
expect(response.orders).toHaveLength(1);
expect(response.orders[0]).toHaveProperty('id');
expect(response.orders[0]).toHaveProperty('total');
expect(response.pagination).toHaveProperty('total');
expect(response.pagination).toHaveProperty('page');
});
});
describe('Error scenarios', () => {
before(() => {
return provider.addInteraction({
state: 'user 999 does not exist',
uponReceiving: 'a request for non-existent user',
withRequest: {
method: 'GET',
path: '/api/users/999',
headers: {
'Authorization': like('Bearer token-123')
}
},
willRespondWith: {
status: 404,
headers: {
'Content-Type': 'application/json'
},
body: {
error: 'User not found',
code: 'USER_NOT_FOUND'
}
}
});
});
it('handles 404 errors correctly', async () => {
const client = new UserClient('http://localhost:1234');
await expect(
client.getUser('999', 'Bearer token-123')
).rejects.toThrow('User not found');
});
});
});
Provider Side: Verifying Pact Contracts
Example: User Service Provider Verification
// user-service-provider.pact.test.js
import { Verifier } from '@pact-foundation/pact';
import { startServer, stopServer } from '../src/server';
describe('Pact Verification', () => {
let server;
before(async () => {
server = await startServer(3000);
});
after(async () => {
await stopServer(server);
});
it('validates the expectations of OrderService', () => {
const opts = {
provider: 'UserService',
providerBaseUrl: 'http://localhost:3000',
// Fetch pacts from broker
pactBrokerUrl: 'https://pact-broker.example.com',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Or use local pacts
// pactUrls: [path.resolve(__dirname, '../../pacts/orderservice-userservice.json')],
// Provider version and branch for can-i-deploy
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
// Publish verification results
publishVerificationResult: process.env.CI === 'true',
// State handlers
stateHandlers: {
'user 123 exists': async () => {
// Set up database state
await db.users.create({
id: '123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
status: 'active'
});
},
'user 123 has orders': async () => {
await db.users.create({
id: '123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
status: 'active'
});
await db.orders.createMany([
{
id: 'order-456',
userId: '123',
total: 99.99,
status: 'confirmed',
createdAt: new Date('2025-10-01T10:00:00Z')
},
// ... more orders
]);
},
'user 999 does not exist': async () => {
// Ensure user doesn't exist
await db.users.deleteMany({ id: '999' });
}
},
// Request filters (e.g., for authentication)
requestFilter: (req, res, next) => {
// Mock authentication for testing
req.headers['x-authenticated-user'] = 'test-user';
next();
}
};
return new Verifier(opts).verifyProvider();
});
});
Advanced Pact Features
1. Matching Rules for Flexible Contracts
import { like, eachLike, regex, term, iso8601DateTime } from '@pact-foundation/pact/dsl/matchers';
provider.addInteraction({
state: 'products exist',
uponReceiving: 'a request for products',
withRequest: {
method: 'GET',
path: '/api/products'
},
willRespondWith: {
status: 200,
body: {
products: eachLike({
id: regex(/^prod-[0-9]+$/, 'prod-123'),
name: like('Product Name'),
price: like(99.99),
currency: term({
generate: 'USD',
matcher: '^(USD|EUR|GBP)$'
}),
createdAt: iso8601DateTime('2025-10-01T10:00:00Z'),
tags: eachLike('electronics')
}, { min: 1 })
}
}
});
2. Message Pact for Async Communication
import { MessageConsumerPact } from '@pact-foundation/pact';
describe('Order Created Event', () => {
const messagePact = new MessageConsumerPact({
consumer: 'InventoryService',
provider: 'OrderService',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn'
});
it('handles order created event', () => {
return messagePact
.expectsToReceive('an order created event')
.withContent({
orderId: like('order-789'),
customerId: like('cust-456'),
items: eachLike({
productId: like('prod-123'),
quantity: like(2),
price: like(49.99)
}),
total: like(99.98),
createdAt: iso8601DateTime()
})
.withMetadata({
'content-type': 'application/json',
'event-type': 'order.created'
})
.verify((message) => {
// Test your message handler
return orderEventHandler.handle(message);
});
});
});
Schema Validation Strategies
JSON Schema Validation
JSON Schema provides a standard way to define and validate API contracts:
// user-schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "email", "profile"],
"properties": {
"id": {
"type": "string",
"pattern": "^[0-9]+$"
},
"email": {
"type": "string",
"format": "email"
},
"profile": {
"type": "object",
"required": ["firstName", "lastName"],
"properties": {
"firstName": { "type": "string", "minLength": 1 },
"lastName": { "type": "string", "minLength": 1 },
"phone": { "type": "string", "pattern": "^\\+?[1-9]\\d{1,14}$" }
}
},
"status": {
"type": "string",
"enum": ["active", "inactive", "suspended"]
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false
}
Validating Against Schema:
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import userSchema from './schemas/user-schema.json';
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
describe('User API Schema Validation', () => {
const validate = ajv.compile(userSchema);
it('validates correct user object', () => {
const user = {
id: '123',
email: 'user@example.com',
profile: {
firstName: 'John',
lastName: 'Doe',
phone: '+12345678901'
},
status: 'active',
createdAt: '2025-10-01T10:00:00Z'
};
const valid = validate(user);
expect(valid).toBe(true);
});
it('rejects user with invalid email', () => {
const user = {
id: '123',
email: 'invalid-email',
profile: {
firstName: 'John',
lastName: 'Doe'
},
status: 'active',
createdAt: '2025-10-01T10:00:00Z'
};
const valid = validate(user);
expect(valid).toBe(false);
expect(validate.errors).toContainEqual(
expect.objectContaining({
instancePath: '/email',
keyword: 'format'
})
);
});
it('rejects user with extra properties', () => {
const user = {
id: '123',
email: 'user@example.com',
profile: { firstName: 'John', lastName: 'Doe' },
status: 'active',
createdAt: '2025-10-01T10:00:00Z',
extraField: 'not allowed'
};
const valid = validate(user);
expect(valid).toBe(false);
expect(validate.errors[0].keyword).toBe('additionalProperties');
});
});
OpenAPI Specification Validation
OpenAPI (formerly Swagger) provides comprehensive API documentation and validation:
# openapi.yaml
openapi: 3.0.0
info:
title: User Service API
version: 1.0.0
paths:
/api/users/{userId}:
get:
summary: Get user by ID
parameters:
- name: userId
in: path
required: true
schema:
type: string
pattern: '^[0-9]+$'
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
required:
- id
- email
- profile
properties:
id:
type: string
pattern: '^[0-9]+$'
email:
type: string
format: email
profile:
$ref: '#/components/schemas/Profile'
status:
type: string
enum: [active, inactive, suspended]
createdAt:
type: string
format: date-time
Profile:
type: object
required:
- firstName
- lastName
properties:
firstName:
type: string
minLength: 1
lastName:
type: string
minLength: 1
phone:
type: string
pattern: '^\+?[1-9]\d{1,14}$'
Error:
type: object
required:
- error
- code
properties:
error:
type: string
code:
type: string
Validating Against OpenAPI Spec:
import SwaggerParser from '@apidevtools/swagger-parser';
import { OpenApiValidator } from 'express-openapi-validator';
describe('OpenAPI Contract Validation', () => {
let api;
beforeAll(async () => {
api = await SwaggerParser.validate('./openapi.yaml');
});
it('validates API specification is valid', () => {
expect(api.openapi).toBe('3.0.0');
expect(api.paths).toHaveProperty('/api/users/{userId}');
});
it('validates response against spec', async () => {
const validator = new OpenApiValidator({
apiSpec: './openapi.yaml',
validateResponses: true
});
const response = {
status: 200,
body: {
id: '123',
email: 'user@example.com',
profile: {
firstName: 'John',
lastName: 'Doe'
},
status: 'active',
createdAt: '2025-10-01T10:00:00Z'
}
};
// Validate response matches schema
await expect(
validator.validateResponse(response, {
path: '/api/users/{userId}',
method: 'get'
})
).resolves.not.toThrow();
});
});
Backward Compatibility Patterns
Versioning Strategies
1. Additive Changes (Safe)
// V1: Original response
{
"id": "123",
"name": "John Doe",
"email": "john@example.com"
}
// V2: Add new optional fields (backward compatible)
{
"id": "123",
"name": "John Doe",
"email": "john@example.com",
"phone": "+12345678901", // New field
"preferences": { // New nested object
"newsletter": true
}
}
2. Deprecation Pattern
// Gradually deprecate old fields
{
"id": "123",
"name": "John Doe", // Deprecated (still present)
"profile": { // New structure
"firstName": "John",
"lastName": "Doe"
},
"email": "john@example.com"
}
// Provider response includes deprecation headers
response.headers['X-Deprecated-Fields'] = 'name';
response.headers['X-Deprecation-Date'] = '2026-01-01';
3. Expansion and Contraction
Phase 1 (Expand): Add new field alongside old field
Provider returns both
Consumers gradually migrate to new field
Phase 2 (Contract): Remove old field
Only after all consumers updated
Can verify with Pact broker
Testing Backward Compatibility
describe('Backward Compatibility Tests', () => {
it('new provider version satisfies old consumer contracts', async () => {
// Load old contract
const oldContract = require('./pacts/v1/orderservice-userservice.json');
// Verify current provider against old contract
const verifier = new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:3000',
pactUrls: [oldContract],
providerVersion: '2.0.0'
});
await expect(verifier.verifyProvider()).resolves.not.toThrow();
});
it('detects breaking changes in API', async () => {
// Schema validation catches removed fields
const oldSchema = require('./schemas/user-v1.json');
const newResponse = {
id: '123',
// name field removed (breaking change!)
profile: {
firstName: 'John',
lastName: 'Doe'
},
email: 'john@example.com'
};
const ajv = new Ajv();
const validate = ajv.compile(oldSchema);
expect(validate(newResponse)).toBe(false);
expect(validate.errors).toContainEqual(
expect.objectContaining({
keyword: 'required',
params: { missingProperty: 'name' }
})
);
});
});
Can-I-Deploy: Safe Deployments
Pact Broker’s can-i-deploy tool prevents breaking deployments:
# Check if OrderService can be deployed
pact-broker can-i-deploy \
--pacticipant OrderService \
--version $GIT_COMMIT \
--to-environment production
# Check if UserService can be deployed
# (verifies all consumer contracts are satisfied)
pact-broker can-i-deploy \
--pacticipant UserService \
--version $GIT_COMMIT \
--to-environment production
CI/CD Integration:
# .github/workflows/deploy.yml
name: Deploy
jobs:
can-i-deploy:
runs-on: ubuntu-latest
steps:
- name: Check if can deploy
run: |
pact-broker can-i-deploy \
--pacticipant UserService \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url https://pact-broker.example.com \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
- name: Deploy if checks pass
if: success()
run: ./deploy.sh production
- name: Record deployment
run: |
pact-broker record-deployment \
--pacticipant UserService \
--version ${{ github.sha }} \
--environment production
Conclusion
Contract testing is essential for maintaining reliable microservices architectures. By implementing consumer-driven contracts with Pact, leveraging schema validation with JSON Schema and OpenAPI, and following backward compatibility patterns, teams can deploy with confidence while maintaining system stability.
Key Takeaways:
- Consumer-driven contracts ensure APIs meet real usage requirements
- Pact framework provides comprehensive tooling for contract testing
- Schema validation enforces structure and catches breaking changes early
- Backward compatibility requires careful planning and gradual migration
- Can-I-deploy checks prevent breaking deployments in production
Contract testing enables teams to move fast without breaking things, providing the confidence needed for true continuous deployment in microservices environments.