Contract Testing with Pact: Consumer-Driven API Testing
Integration testing between microservices is fragile and slow: you need all services running, test environments to stay in sync, and end-to-end tests that break for reasons unrelated to your changes. Contract testing is a targeted alternative: each consumer defines what it expects from a provider, and the provider verifies it can meet those expectations — independently, without the full stack.
The Core Concept
Consumer: A service that calls another service's API (e.g., a frontend calling a user service).
Provider: A service that exposes an API (e.g., the user service).
Contract (Pact): A file documenting exactly what the consumer expects from the provider — specific requests and the responses it needs.
The workflow:
- Consumer tests run, generating a Pact contract file
- Contract is shared with the provider (via Pact Broker or filesystem)
- Provider verification runs: Pact replays the recorded consumer requests against the real provider
- If the provider returns matching responses, the contract passes
This catches API breaking changes before deployment, without end-to-end tests.
Install
# Node.js (TypeScript)
npm install --save-dev @pact-foundation/pact
Consumer Test
The consumer test defines expected interactions and generates the Pact file:
// user-service.consumer.test.ts
import { Pact } from "@pact-foundation/pact";
import { like, eachLike } from "@pact-foundation/pact/src/dsl/matchers";
import path from "path";
const provider = new Pact({
consumer: "frontend",
provider: "user-service",
port: 4000,
dir: path.resolve(__dirname, "../pacts"),
logLevel: "warn",
});
describe("User Service API contract", () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe("GET /users/:id", () => {
beforeEach(() => {
return provider.addInteraction({
state: "user 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: like("123"),
name: like("Alice Smith"),
email: like("[email protected]"),
},
},
});
});
it("fetches a user by ID", async () => {
// Call your actual consumer code (not a stub)
const response = await fetch("http://localhost:4000/users/123", {
headers: { Accept: "application/json" },
});
const user = await response.json();
expect(response.status).toBe(200);
expect(user.id).toBe("123");
expect(user.name).toBeDefined();
expect(user.email).toBeDefined();
});
});
});
Running this test:
- Pact starts a mock server on port 4000
- Your consumer code runs against the mock
- Pact records the interaction
- A
pacts/frontend-user-service.jsoncontract file is generated
Matchers
Pact matchers define what counts as "matching" — exact value vs. type:
import {
like, // matches same type, any value
term, // regex match
eachLike, // array where each element matches the pattern
integer, // integer type
decimal, // decimal number
} from "@pact-foundation/pact/src/dsl/matchers";
body: {
id: like("abc123"), // any string
count: integer(5), // any integer
price: decimal(9.99), // any decimal
status: term({
generate: "active",
matcher: "^(active|inactive|pending)$",
}),
tags: eachLike("javascript"), // array of strings
}
Use matchers generously — you want contracts to describe the shape of data, not hardcode values that will change.
Provider Verification
The provider side verifies it can satisfy the contract:
// user-service.provider.test.ts
import { Verifier } from "@pact-foundation/pact";
import path from "path";
describe("User Service provider verification", () => {
it("validates the Pact contracts", async () => {
const opts = {
provider: "user-service",
providerBaseUrl: "http://localhost:3001", // your provider running
pactUrls: [
path.resolve(__dirname, "../pacts/frontend-user-service.json"),
],
providerStatesHandler: async (state: string) => {
// Set up test state before each interaction
switch (state) {
case "user 123 exists":
await db.users.upsert({
id: "123",
name: "Alice Smith",
email: "[email protected]",
});
break;
}
},
};
const output = await new Verifier(opts).verifyProvider();
console.log(output);
});
});
The verifier:
- Reads the Pact file
- For each interaction, calls
providerStatesHandlerto set up data - Replays the recorded request against the running provider
- Asserts the response matches the contract
Pact Broker
In a team environment, contracts need to be shared between consumer and provider pipelines. The Pact Broker is a central repository for Pact files:
# docker-compose.yml
services:
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- 9292:9292
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:pact@pact-db/pact
depends_on:
- pact-db
pact-db:
image: postgres:15
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
Publish contracts from consumer CI:
npx pact-broker publish ./pacts \
--broker-base-url http://pact-broker:9292 \
--consumer-app-version $(git rev-parse HEAD) \
--branch main
Verify from provider CI:
const opts = {
provider: "user-service",
providerBaseUrl: "http://localhost:3001",
pactBrokerUrl: "http://pact-broker:9292",
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
enablePending: true, // Don't fail on unverified pacts
};
Can I Deploy?
The killer feature: before deploying, check if the contract is verified:
npx pact-broker can-i-deploy \
--pacticipant frontend \
--version $(git rev-parse HEAD) \
--to-environment production
This answers: "is every provider I depend on verified against my current contract?" If no, the deploy is blocked. This prevents deploying a frontend that calls an API endpoint the provider doesn't yet implement.
CI/CD Integration
# GitHub Actions: consumer pipeline
jobs:
contract-tests:
steps:
- run: npm test -- --testPathPattern=consumer
- run: |
npx pact-broker publish ./pacts \
--broker-base-url $PACT_BROKER_URL \
--consumer-app-version $GITHUB_SHA \
--branch $GITHUB_REF_NAME
# Provider pipeline
verify-contracts:
steps:
- run: npm run start:test & # start provider
- run: npm test -- --testPathPattern=provider
When Pact Catches Bugs
A provider team renames user.email to user.emailAddress and deploys. Without contract testing, this silently breaks the consumer. With Pact:
- Provider verification fails: "consumer expects
emailfield, provider returnsemailAddress" - The
can-i-deploycheck blocks the provider deployment - Provider team is notified: "breaking change for frontend consumer"
- Fix: coordinate field name, or add backward-compatible alias
When to Use Contract Testing
Good fit:
- Multiple teams owning separate services
- Frequent API changes
- Integration test environments are slow or unreliable
- Microservices with many consumers
Less useful:
- Monolith with shared types (TypeScript imports keep things in sync automatically)
- Single-team service with stable APIs
- Public APIs (Pact works, but consumer-driven contracts assume you can reach consumers)
Pact vs Integration Tests
| Pact | Integration Tests | |
|---|---|---|
| Requires full stack | No | Yes |
| Speed | Fast (unit test speed) | Slow |
| Catches contract breaks | Yes | Yes |
| Catches logic bugs | No | Yes |
| Maintenance | Moderate | High |
Pact doesn't replace integration tests — it replaces the subset of integration tests that verify API compatibility. Keep integration tests for business logic that spans services.
The Pact ecosystem (pact.io) supports JavaScript, Java, Python, Go, Ruby, .NET, and more. Once set up, can-i-deploy becomes a reliable deployment gate for service compatibility.