Pact with Node.js and Express: API Contract Testing Step by Step
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.