How to Build a Contract Testing Suite with Pact for Microservices
How to Build a Contract Testing Suite with Pact for Microservices
Why Contract Testing Matters in Microservices
When you run multiple microservices, integration tests become a nightmare. You need to spin up every service, manage test data across databases, and deal with flaky network calls. Worse, when Service A breaks because Service B changed its API, you often don't know until production.
Contract testing solves this by verifying that services communicate correctly without running the entire system. Instead of testing the whole integration, you test the "contract" between producer and consumer services .
In this tutorial, you'll build a complete contract testing suite using Pact, the most popular contract testing library, with Node.js services.
What You'll Build
- A consumer service (Order Service) that calls an API
- A provider service (Product Service) that exposes the API
- Pact contract tests that verify the API contract
- A mock provider for consumer tests
- Verification that the provider satisfies the contract
Prerequisites
- Node.js 18+ installed
- npm or yarn
- Basic understanding of REST APIs
- Familiarity with Jest for testing
Step 1: Set Up the Project Structure
Create a new directory and initialize two services:
mkdir contract-testing-tutorial
cd contract-testing-tutorial
### Create consumer service
mkdir consumer && cd consumer
npm init -y
npm install express @pact-foundation/pact jest ts-node typescript @types/node @types/express @types/jest
### Create provider service
cd ..
mkdir provider && cd provider
npm init -y
npm install express @pact-foundation/pact jest ts-node typescript @types/node @types/express @types/jest
Create a tsconfig.json in both directories:
{"compilerOptions":{"target":"ES2020","module":"commonjs","lib":["ES2020"],"outDir":"./dist","rootDir":"./src","strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src/**/*"]}
Step 2: Build the Provider Service (Product Service)
Create provider/src/index.ts:
import express, { Request, Response } from 'express';
const app = express();
const PORT = 3001;
// Mock product database
const products = [
{ id: 1, name: 'Laptop', price: 999.99, inStock: true },
{ id: 2, name: 'Mouse', price: 29.99, inStock: true },
{ id: 3, name: 'Keyboard', price: 79.99, inStock: false },
];
// GET /products - returns array of products
app.get('/products', (req: Request, res: Response) => {
res.json(products);
});
// GET /products/:id - returns single product
app.get('/products/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
const product = products.find(p => p.id === id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
});
app.listen(PORT, () => {
console.log(`Provider service running on port ${PORT}`);
});
Add to provider/package.json:
{"scripts":{"start":"ts-node src/index.ts","test":"jest"}}
Step 3: Build the Consumer Service (Order Service)
Create consumer/src/api.ts - this module makes HTTP calls to the provider:
import axios from 'axios';
const PROVIDER_URL = 'http://localhost:3001';
export interface Product {
id: number;
name: string;
price: number;
inStock: boolean;
}
export interface ProductResponse {
id: number;
name: string;
price: number;
inStock: boolean;
}
export async function getProducts(): Promise<Product[]> {
const response = await axios.get<ProductResponse[]>(`${PROVIDER_URL}/products`);
return response.data;
}
export async function getProductById(id: number): Promise<Product> {
const response = await axios.get<ProductResponse>(`${PROVIDER_URL}/products/${id}`);
return response.data;
}
Install axios:
cd consumer
npm install axios @types/axios
Create consumer/src/orderService.ts:
import { getProducts, getProductById, Product } from './api';
export class OrderService {
async getAvailableProducts(): Promise<Product[]> {
const products = await getProducts();
return products.filter(p => p.inStock);
}
async getProductPrice(id: number): Promise<number> {
const product = await getProductById(id);
return product.price;
}
}
Step 4: Write Contract Tests for the Consumer
This is the core of contract testing. Create consumer/src/pact/consumer.test.ts:
import { Pact } from '@pact-foundation/pact';
import axios from 'axios';
describe('Product Service Contract', () => {
const pact = new Pact({
consumer: 'OrderService',
provider: 'ProductService',
port: 1234,
log: 'pact.log',
(dir): string => {
return 'pacts';
},
logLevel: 'info',
});
beforeAll(async () => {
await pact.setup();
});
afterAll(async () => {
await pact.finalize();
});
beforeEach(() => {
pact.removeInteractions();
});
it('expects GET /products to return a list of products', async () => {
const expectedProducts = [
{
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
},
{
id: 2,
name: 'Mouse',
price: 29.99,
inStock: true,
},
];
pact.addInteraction({
state: 'products exist',
uponReceiving: 'a request to get all products',
withRequest: {
method: 'GET',
path: '/products',
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: expectedProducts,
},
});
const response = await axios.get(expectedProducts);
expect(response.data).toEqual(expectedProducts);
});
it('expects GET /products/:id to return a single product', async () => {
const expectedProduct = {
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
};
pact.addInteraction({
state: 'product with id 1 exists',
uponReceiving: 'a request to get product by id',
withRequest: {
method: 'GET',
path: Pact.Matchers.regex({
regex: '/products/1',
example: '/products/1',
}),
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: expectedProduct,
},
});
const response = await axios.get('http://localhost:1234/products/1');
expect(response.data).toEqual(expectedProduct);
});
});
Wait, there's an error in the test. Let me fix it:
Create the corrected consumer/src/pact/consumer.test.ts:
import { Pact } from '@pact-foundation/pact';
import axios from 'axios';
describe('Product Service Contract', () => {
const pact = new Pact({
consumer: 'OrderService',
provider: 'ProductService',
port: 1234,
log: 'pact.log',
dir: 'pacts',
logLevel: 'info',
});
beforeAll(async () => {
await pact.setup();
});
afterAll(async () => {
await pact.finalize();
});
beforeEach(() => {
pact.removeInteractions();
});
it('expects GET /products to return a list of products', async () => {
const expectedProducts = [
{
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
},
{
id: 2,
name: 'Mouse',
price: 29.99,
inStock: true,
},
];
pact.addInteraction({
state: 'products exist',
uponReceiving: 'a request to get all products',
withRequest: {
method: 'GET',
path: '/products',
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: expectedProducts,
},
});
const response = await axios.get('http://localhost:1234/products');
expect(response.data).toEqual(expectedProducts);
});
it('expects GET /products/:id to return a single product', async () => {
const expectedProduct = {
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
};
pact.addInteraction({
state: 'product with id 1 exists',
uponReceiving: 'a request to get product by id',
withRequest: {
method: 'GET',
path: Pact.Matchers.regex({
regex: '/products/\\d+',
example: '/products/1',
}),
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: expectedProduct,
},
});
const response = await axios.get('http://localhost:1234/products/1');
expect(response.data).toEqual(expectedProduct);
});
});
Add test script to consumer/package.json:
{"scripts":{"test":"jest","test:pact":"jest src/pact"}}
Create consumer/jest.config.js:
module.exports = {
preset: 'ts-node/register',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
Step 5: Write Provider Verification Tests
Now verify that the provider satisfies the contract. Create provider/src/pact/provider.test.ts:
import { PactV3, MockServer } from '@pact-foundation/pact';
import { createServer } from 'http';
import app from '../../index';
describe('ProductService Provider Verification', () => {
let mockServer: MockServer;
let appServer: any;
beforeAll(async () => {
// Start the mock Pact server
mockServer = await new PactV3({
consumer: 'OrderService',
provider: 'ProductService',
dir: 'pacts',
log: 'pact.log',
}).createMockServer();
// Start the actual provider app on a different port
appServer = app.listen(3002);
});
afterAll(async () => {
await mockServer.cleanup();
appServer.close();
});
it('verifies the provider satisfies the contract', async () => {
const result = await new PactV3({
consumer: 'OrderService',
provider: 'ProductService',
dir: 'pacts',
log: 'pact.log',
}).verifyProvider({
pactUrl: 'pacts/OrderService-ProductService.json',
providerBaseUrl: 'http://localhost:3002',
});
expect(result).toBe(true);
});
});
Actually, let me simplify this with a more practical approach. Create provider/src/pact/provider-verification.test.ts:
import { Pact } from '@pact-foundation/pact';
import request from 'supertest';
import app from '../index';
describe('ProductService Provider Verification', () => {
const pact = new Pact({
consumer: 'OrderService',
provider: 'ProductService',
port: 1235,
log: 'pact.log',
dir: 'pacts',
logLevel: 'info',
});
beforeAll(async () => {
await pact.setup();
});
afterAll(async () => {
await pact.finalize();
});
it('verifies GET /products returns products', async () => {
pact.addInteraction({
state: 'products exist',
uponReceiving: 'a request to get all products',
withRequest: {
method: 'GET',
path: '/products',
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: [
{
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
},
],
},
});
const response = await request(app).get('/products');
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name');
expect(response.body).toHaveProperty('price');
expect(response.body).toHaveProperty('inStock');
});
it('verifies GET /products/:id returns a product', async () => {
pact.addInteraction({
state: 'product with id 1 exists',
uponReceiving: 'a request to get product by id',
withRequest: {
method: 'GET',
path: Pact.Matchers.regex({
regex: '/products/1',
example: '/products/1',
}),
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: {
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
},
},
});
const response = await request(app).get('/products/1');
expect(response.status).toBe(200);
expect(response.body).toEqual({
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true,
});
});
});
Install supertest:
cd provider
npm install supertest @types/supertest
Step 6: Run the Contract Tests
Run Consumer Contract Tests
cd consumer
npm test src/pact/consumer.test.ts
This will:
- Start a mock Pact server on port 1234
- Run your tests against the mock server
- Generate a Pact contract file (
pacts/OrderService-ProductService.json)
Run Provider Verification
cd provider
npm test src/pact/provider-verification.test.ts
This verifies the provider implementation matches the contract.
Step 7: Understanding the Generated Pact Contract
After running consumer tests, check the generated pact file:
{"consumer":{"name":"OrderService"},"provider":{"name":"ProductService"},"interactions":[{"description":"a request to get all products","request":{"method":"GET","path":"/products"},"response":{"status":200,"headers":{"Content-Type":"application/json; charset=utf-8"},"body":[{"id":1,"name":"Laptop","price":999.99,"inStock":true}]},"providerState":"products exist"}],"metadata":{"pactSpecification":{"version":"2.0.0"}}}
This JSON file is the contract that defines exactly how the consumer and provider must communicate .
Step 8: Add Pact to Your CI/CD Pipeline
Create a GitHub Actions workflow .github/workflows/contract-tests.yml:
name: Contract Testing
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
working-directory: ./consumer
run: npm ci
- name: Run consumer contract tests
working-directory: ./consumer
run: npm test src/pact/consumer.test.ts
- name: Upload Pact contract
uses: actions/upload-artifact@v3
with:
name: pact-contract
path: consumer/pacts/
provider-verification:
runs-on: ubuntu-latest
needs: consumer-tests
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Download Pact contract
uses: actions/download-artifact@v3
with:
name: pact-contract
path: provider/pacts/
- name: Install dependencies
working-directory: ./provider
run: npm ci
- name: Run provider verification
working-directory: ./provider
run: npm test src/pact/provider-verification.test.ts
Step 9: Breaking the Contract (And Catching It)
Let's see contract testing in action when things break. Modify the provider to return a different field:
Provider change provider/src/index.ts:
// WRONG: Changed 'inStock' to 'available'
const products = [
{ id: 1, name: 'Laptop', price: 999.99, available: true },
{ id: 2, name: 'Mouse', price: 29.99, available: true },
];
When you run the provider verification tests, they will fail with a clear error message:
Expected:{"id":1,"name":"Laptop","price":999.99,"inStock":true}Actual:{"id":1,"name":"Laptop","price":999.99,"available":true}Missingfield:inStock
This catches the breaking change before it reaches production, unlike integration tests that might only fail sporadically .
Best Practices for Contract Testing
1. Keep Contracts Simple
Only test the essential parts of the API that consumers need. Don't contract-test every single field.
2. Use Provider States
Provider states describe the data setup needed for each interaction:
pact.addInteraction({
state: 'user with id 123 exists',
uponReceiving: 'a request to get user',
// ...
});
3. Version Your Contracts
Store pact files in version control and treat them as first-class artifacts.
4. Run Contracts in CI
Never deploy without verifying contracts. Make contract testing a gate in your CI pipeline.
5. Don't Replace Integration Tests Completely
Contract testing complements integration testing. Use contract tests for API contracts and integration tests for end-to-end scenarios.
Common Pitfalls to Avoid
| Pitfall | Solution |
|-||
| Testing implementation details | Only test the public API contract |
| Over-contracting | Contract only what consumers actually need |
| Ignoring provider states | Set up proper test data for each interaction |
| Not running in CI | Make contract tests mandatory in CI/CD |
| Contracting internal APIs | Focus on external-facing APIs between services |
When to Use Contract Testing
✅ Use contract testing when:
- You have multiple microservices
- Services are owned by different teams
- You need fast, reliable integration verification
- You want to prevent breaking changes
❌ Don't use contract testing when:
- You have a monolithic application
- Only one consumer exists (simple integration tests suffice)
- You're testing UI behavior (use E2E tests instead)
Conclusion
Contract testing with Pact gives you:
- Fast feedback: Tests run in seconds, not minutes
- Reliability: No flaky network or database issues
- Breaking change detection: Catch API changes before production
- Team autonomy: Teams can deploy independently without coordinating releases
The key insight is that contract testing shifts integration testing left in your development process. Instead of discovering integration issues in staging or production, you catch them when writing code .
Start by contracting your most critical service interactions, then expand gradually. Within weeks, you'll have confidence that your microservices work together without running expensive integration test suites.
Resources
What's your experience with contract testing? Have you tried Pact or another tool? Share your thoughts in the comments below.
https://docs.pact.io/
https://martinfowler.com/articles/consumerDrivenContracts.html
https://pact.io/tutorial
-
Rizwan Saleem | https://rizwansaleem.co