Why Contract Testing?
In microservices, services are developed and deployed independently. Without contract testing, you rely on:
- Integration tests: Require all services running simultaneously — slow and fragile.
- Documentation: Developers read API docs and hope they are accurate.
- Hope: Deploy and pray nothing breaks.
Contract testing fills the gap by verifying that services can communicate correctly without needing all services to be running at the same time.
How Pact Works
Pact is the most popular contract testing framework. It uses a consumer-driven approach:
1. Consumer writes tests defining what it expects from the provider
2. Pact generates a contract file (JSON) from these tests
3. Contract is shared with the provider (via Pact Broker or file)
4. Provider runs the contract against its actual implementation
5. If the provider satisfies all contracts, it is safe to deploy
The Pact Lifecycle
Consumer CI: Provider CI:
┌─────────────────┐ ┌─────────────────┐
│ Run consumer │ │ Fetch contracts │
│ tests against │ │ from Pact Broker │
│ Pact mock │ │ │
│ │ │ │ Run provider │
│ ▼ │ │ against each │
│ Generate .pact │───────────▶│ contract │
│ file │ Publish │ │ │
│ │ │ to Broker │ ▼ │
│ ▼ │ │ Pass: safe to │
│ Publish to │ │ deploy │
│ Pact Broker │ │ Fail: fix before │
│ │ │ deploying │
└─────────────────┘ └─────────────────┘
Writing Consumer Tests
The consumer defines interactions it expects with the provider:
// consumer.test.js — User Service consumer test
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, eachLike, string, integer } = MatchersV3;
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService',
});
describe('UserService contract', () => {
test('get user by ID', async () => {
// Define expected interaction
await provider
.given('user with ID 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/users/123',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(123),
name: string('Alice'),
email: string('alice@example.com'),
},
});
await provider.executeTest(async (mockServer) => {
// Call your actual consumer code against the mock
const userClient = new UserClient(mockServer.url);
const user = await userClient.getUser(123);
expect(user.id).toBe(123);
expect(user.name).toBeDefined();
expect(user.email).toContain('@');
});
});
test('user not found returns 404', async () => {
await provider
.given('user with ID 999 does not exist')
.uponReceiving('a request for non-existent user 999')
.withRequest({
method: 'GET',
path: '/users/999',
})
.willRespondWith({
status: 404,
body: { error: string('User not found') },
});
await provider.executeTest(async (mockServer) => {
const userClient = new UserClient(mockServer.url);
await expect(userClient.getUser(999)).rejects.toThrow('User not found');
});
});
});
This test runs against a Pact mock server (not the real provider). It generates a contract file describing the expected interactions.
Writing Provider Tests
The provider verifies it can fulfill all consumer contracts:
// provider.test.js — User Service provider verification
const { Verifier } = require('@pact-foundation/pact');
describe('UserService provider verification', () => {
test('verifies all consumer contracts', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3001', // Your actual provider
pactBrokerUrl: 'https://your-broker.pact.io',
provider: 'UserService',
providerVersion: process.env.GIT_COMMIT,
publishVerificationResult: true,
// State handlers — set up test data for each "given" state
stateHandlers: {
'user with ID 123 exists': async () => {
await db.users.create({ id: 123, name: 'Alice', email: 'alice@example.com' });
},
'user with ID 999 does not exist': async () => {
await db.users.deleteAll();
},
},
});
await verifier.verifyProvider();
});
});
Pact Matchers
Pact uses matchers to make contracts flexible. Instead of requiring exact values, matchers define the expected shape:
| Matcher | Purpose | Example |
|---|---|---|
like(value) | Matches type, not exact value | like(42) matches any integer |
string(value) | Matches any string | string('Alice') matches any string |
integer(value) | Matches any integer | integer(1) matches any integer |
eachLike(value) | Array where each element matches | eachLike({id: integer()}) |
regex(pattern, example) | Matches regex pattern | regex('^\\d{3}$', '123') |
datetime(format, example) | Matches date format | ISO 8601 date |
Pact Broker
The Pact Broker is a central service for managing contracts:
# Start Pact Broker with Docker
docker run -d --name pact-broker \
-e PACT_BROKER_DATABASE_URL=sqlite:////tmp/pact_broker.sqlite3 \
-p 9292:9292 \
pactfoundation/pact-broker
Can I Deploy? The Broker tracks which versions have been verified. The can-i-deploy tool answers: “Is it safe to deploy this version?”
# Check if OrderService v1.2.3 can be deployed
pact-broker can-i-deploy \
--pacticipant OrderService \
--version 1.2.3 \
--to production
Exercise: Contract Testing Implementation
Setup
Create two services and implement contract testing between them.
Task 1: Consumer Contract Tests
Write consumer tests for an OrderService that consumes a ProductService API:
Interactions to define:
GET /products/:id— Returns product with name, price, and stock.GET /products?category=electronics— Returns array of products.POST /products/:id/reserve— Reserves stock, returns confirmation.GET /products/999— Returns 404 for non-existent product.
Task 2: Provider Verification
Write provider tests for the ProductService:
- Set up state handlers for each “given” state.
- Start the actual ProductService.
- Run verification against consumer contracts.
- Ensure all interactions pass.
Task 3: Pact Broker Integration
- Set up a local Pact Broker with Docker.
- Publish consumer contracts to the Broker.
- Configure provider verification to fetch from the Broker.
- Use
can-i-deployto check deployment safety.
Task 4: Breaking Change Detection
- Make a breaking change in the provider (rename a field, change a status code).
- Run provider verification — it should fail.
- Document the failure message and how it helps identify the issue.
- Fix the breaking change or coordinate a contract update.
Task 5: CI Pipeline Integration
Design a CI pipeline that:
- Consumer CI: Run consumer tests → publish contract to Broker → check can-i-deploy.
- Provider CI: Fetch contracts from Broker → verify provider → publish results → check can-i-deploy.
Deliverables
- Consumer test code with at least 4 interactions.
- Provider verification code with state handlers.
- Pact Broker setup and configuration.
- CI pipeline design document.
- Breaking change detection demonstration.