Pact with Node.js and Express: API Contract Testing Step by Step
Contract testing with Pact shines brightest in Node.js microservice environments.
Contract testing with Pact shines brightest in Node.js microservice environments. Most Node.js APIs are Express or Fastify apps — they're relatively thin, the test setup is familiar, and pact-js integrates cleanly with Jest. This guide walks through a complete, realistic example from consumer test to provider verification to CI pipeline.
We'll build contract tests for a User Service that a Frontend consumer depends on.
Project Structure
Suppose you have two services:
frontend/ # React/Next.js app — the consumer
src/
clients/
UserClient.js
pacts/ # generated pact files go here
user-service/ # Express API — the provider
src/
app.js
routes/users.js
tests/
pact/
provider.pact.test.jsThe Consumer: Frontend App
The frontend makes these calls to the User Service:
// frontend/src/clients/UserClient.js
import axios from 'axios';
export class UserClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.http = axios.create({ baseURL: baseUrl });
}
async getUser(userId) {
const { data } = await this.http.get(`/users/${userId}`);
return data;
}
async createUser(userData) {
const { data } = await this.http.post('/users', userData);
return data;
}
async updateUser(userId, updates) {
const { data } = await this.http.patch(`/users/${userId}`, updates);
return data;
}
async deleteUser(userId) {
await this.http.delete(`/users/${userId}`);
}
}Consumer Tests
// frontend/src/clients/UserClient.pact.test.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { UserClient } from './UserClient';
const { like, string, integer, boolean, eachLike, regex } = MatchersV3;
// Define the mock provider
const provider = new PactV3({
consumer: 'frontend',
provider: 'user-service',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'error',
});
describe('UserClient Pact Tests', () => {
// GET /users/:id
describe('getUser', () => {
it('returns a user when user exists', () => {
provider
.given('user with id 1 exists')
.uponReceiving('GET /users/1')
.withRequest({ method: 'GET', path: '/users/1' })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(1),
email: regex('.+@.+\\..+', 'alice@example.com'),
name: string('Alice'),
role: string('user'),
active: boolean(true),
createdAt: string('2024-01-15T10:00:00.000Z'),
},
});
return provider.executeTest(async (mockProvider) => {
const client = new UserClient(mockProvider.url);
const user = await client.getUser(1);
// Consumer assertions — what the frontend actually uses
expect(user.id).toBe(1);
expect(user.email).toMatch(/.+@.+\..+/);
expect(typeof user.name).toBe('string');
expect(typeof user.active).toBe('boolean');
});
});
it('returns 404 when user does not exist', () => {
provider
.given('user with id 999 does not exist')
.uponReceiving('GET /users/999')
.withRequest({ method: 'GET', path: '/users/999' })
.willRespondWith({
status: 404,
body: {
error: string('User not found'),
code: string('USER_NOT_FOUND'),
},
});
return provider.executeTest(async (mockProvider) => {
const client = new UserClient(mockProvider.url);
await expect(client.getUser(999)).rejects.toMatchObject({
response: { status: 404 },
});
});
});
});
// POST /users
describe('createUser', () => {
it('creates a new user and returns it with an id', () => {
provider
.given('no existing user with email bob@example.com')
.uponReceiving('POST /users with valid data')
.withRequest({
method: 'POST',
path: '/users',
headers: { 'Content-Type': 'application/json' },
body: {
email: 'bob@example.com',
name: 'Bob',
role: 'user',
},
})
.willRespondWith({
status: 201,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(2),
email: string('bob@example.com'),
name: string('Bob'),
role: string('user'),
active: boolean(true),
},
});
return provider.executeTest(async (mockProvider) => {
const client = new UserClient(mockProvider.url);
const user = await client.createUser({
email: 'bob@example.com',
name: 'Bob',
role: 'user',
});
expect(user.id).toBeGreaterThan(0);
expect(user.email).toBe('bob@example.com');
});
});
it('returns 409 when email already exists', () => {
provider
.given('user with email alice@example.com exists')
.uponReceiving('POST /users with duplicate email')
.withRequest({
method: 'POST',
path: '/users',
body: { email: 'alice@example.com', name: 'Alice 2', role: 'user' },
})
.willRespondWith({
status: 409,
body: { error: like('Email already in use') },
});
return provider.executeTest(async (mockProvider) => {
const client = new UserClient(mockProvider.url);
await expect(
client.createUser({ email: 'alice@example.com', name: 'Alice 2', role: 'user' })
).rejects.toMatchObject({ response: { status: 409 } });
});
});
});
// PATCH /users/:id
describe('updateUser', () => {
it('updates user fields and returns the updated user', () => {
provider
.given('user with id 1 exists')
.uponReceiving('PATCH /users/1 with name update')
.withRequest({
method: 'PATCH',
path: '/users/1',
body: { name: 'Alice Updated' },
})
.willRespondWith({
status: 200,
body: {
id: integer(1),
name: string('Alice Updated'),
email: string('alice@example.com'),
role: string('user'),
active: boolean(true),
},
});
return provider.executeTest(async (mockProvider) => {
const client = new UserClient(mockProvider.url);
const updated = await client.updateUser(1, { name: 'Alice Updated' });
expect(updated.name).toBe('Alice Updated');
expect(updated.id).toBe(1);
});
});
});
// DELETE /users/:id
describe('deleteUser', () => {
it('returns 204 on successful delete', () => {
provider
.given('user with id 1 exists')
.uponReceiving('DELETE /users/1')
.withRequest({ method: 'DELETE', path: '/users/1' })
.willRespondWith({ status: 204 });
return provider.executeTest(async (mockProvider) => {
const client = new UserClient(mockProvider.url);
await expect(client.deleteUser(1)).resolves.toBeUndefined();
});
});
});
});Run consumer tests:
npx jest UserClient.pact.test.jsOutput: pacts/frontend-user-service.json is generated.
The Provider: Express App
// user-service/src/app.js
import express from 'express';
import { db } from './db';
const app = express();
app.use(express.json());
// GET /users/:id
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found', code: 'USER_NOT_FOUND' });
}
res.json(user);
});
// POST /users
app.post('/users', async (req, res) => {
const { email, name, role } = req.body;
const existing = await db.users.findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already in use' });
}
const user = await db.users.create({ email, name, role, active: true });
res.status(201).json(user);
});
// PATCH /users/:id
app.patch('/users/:id', async (req, res) => {
const user = await db.users.update(parseInt(req.params.id), req.body);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// DELETE /users/:id
app.delete('/users/:id', async (req, res) => {
await db.users.delete(parseInt(req.params.id));
res.status(204).send();
});
export { app };Provider Verification Test
// user-service/tests/pact/provider.pact.test.js
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { app } from '../../src/app';
import { db } from '../../src/db';
describe('Pact Provider Verification — user-service', () => {
let server;
beforeAll(async () => {
await db.connect();
server = app.listen(4001);
});
afterAll(async () => {
server.close();
await db.disconnect();
});
it('validates pact with frontend consumer', () => {
const verifier = new Verifier({
provider: 'user-service',
providerBaseUrl: 'http://localhost:4001',
// Read pact from file (for local dev; use broker URL in CI)
pactUrls: [
path.resolve(process.cwd(), '../frontend/pacts/frontend-user-service.json'),
],
// State handlers — set up the database before each interaction
stateHandlers: {
'user with id 1 exists': async () => {
await db.users.deleteAll();
await db.users.insert({
id: 1,
email: 'alice@example.com',
name: 'Alice',
role: 'user',
active: true,
createdAt: new Date('2024-01-15T10:00:00.000Z'),
});
},
'user with id 999 does not exist': async () => {
await db.users.deleteById(999);
},
'no existing user with email bob@example.com': async () => {
await db.users.deleteByEmail('bob@example.com');
},
'user with email alice@example.com exists': async () => {
await db.users.deleteAll();
await db.users.insert({
id: 1,
email: 'alice@example.com',
name: 'Alice',
role: 'user',
active: true,
});
},
},
logLevel: 'warn',
});
return verifier.verifyProvider();
});
});Run provider verification:
npx jest provider.pact.test.jsCI Integration with GitHub Actions
# .github/workflows/contract-tests.yml
name: Contract Tests
on:
push:
branches: ['*']
jobs:
consumer-tests:
name: Frontend Consumer Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- name: Install dependencies
run: cd frontend && npm ci
- name: Run consumer pact tests
run: cd frontend && npx jest --testPathPattern=pact
- name: Publish pact to broker
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
cd frontend
npx pact-broker publish ./pacts \
--consumer-app-version ${{ github.sha }} \
--tag ${{ github.ref_name }}
provider-verification:
name: User Service Provider Verification
needs: consumer-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- name: Install dependencies
run: cd user-service && npm ci
- name: Run provider verification
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
PROVIDER_VERSION: ${{ github.sha }}
run: cd user-service && npx jest --testPathPattern=provider.pactState Handler Best Practices
State handlers are the most error-prone part of provider verification. Key rules:
1. Always reset state completely:
'user with id 1 exists': async () => {
// Don't assume previous state — reset completely
await db.users.deleteAll();
await db.users.insert({ id: 1, ... });
},2. State handlers run synchronously before each interaction: If your handler is async (most database operations are), return the promise:
'user exists': async () => {
await db.users.insert({ id: 1 }); // must be awaited
},3. Don't share state between handlers: Each provider state is independent. Don't design your pacts so that one interaction depends on state set by a previous one.
4. Mirror your test database setup: State handlers are essentially test fixtures. Use the same factories or builders you use in unit tests.
Common Issues
"Body does not match": Usually a matcher issue. Check whether you're using like() vs an exact value. Provider returns 42 but consumer expects integer(42) — these are compatible. Provider returns "42" but consumer expects integer(42) — type mismatch.
"Unexpected request": The provider received a request not in the pact. Often means the consumer is sending requests the pact doesn't define. Add the missing interaction to the consumer test.
State handler not found: The provider state string in the consumer test must exactly match the state handler key on the provider. Case-sensitive, character-for-character match.
JSON Content-Type header: Include 'Content-Type': 'application/json' in request headers for POST/PATCH interactions. Without it, some Express setups won't parse the body.