
If your microservices pass their own integration tests but still break each other in production, the problem isn’t test coverage — it’s that your tests don’t verify how services communicate. Contract testing with Pact solves this by letting each service define and verify its API expectations independently, without spinning up the entire system. This guide walks through setting up Pact in a Node.js project, writing consumer and provider tests, sharing contracts through a Pact Broker, and integrating everything into your CI pipeline. By the end, you’ll catch breaking API changes during code review instead of after deployment.
What Is Contract Testing?
Contract testing verifies that two services can communicate correctly by testing each side against a shared contract — a formal definition of the requests and responses they exchange. Unlike end-to-end integration tests that require all services running simultaneously, contract tests run each service in isolation against a mock that enforces the contract.
There are two main approaches to contract testing. Provider-driven contracts start with the API specification (like OpenAPI) and verify that consumers follow it. Consumer-driven contracts, which Pact uses, start from the other direction — the consumer defines what it needs from the provider, and the provider verifies it can deliver. This approach is more practical for microservices because it catches the specific breaking changes that actually affect downstream services rather than testing the entire API surface.
How Pact Works: The Consumer-Driven Flow
Pact’s workflow has two distinct phases that run independently in each service’s test suite.
Phase 1 — Consumer Side:
- The consumer test defines expected interactions (specific request/response pairs)
- Pact starts a local mock server that simulates the provider
- The consumer code makes requests to the mock server
- Pact verifies the consumer sent the expected requests
- Pact generates a contract file (called a “pact”) as JSON
Phase 2 — Provider Side:
- The provider starts its real API server
- Pact reads the contract file generated by the consumer
- Pact replays each interaction against the live provider
- The provider must return responses that match the contract
If the provider’s actual responses match the consumer’s expectations, the contract is satisfied. If a developer changes the provider’s response shape, the provider verification fails immediately — before the change reaches any shared environment. This two-phase approach means neither service needs the other running during testing.
Setting Up Pact in a Node.js Project
Install Pact as a dev dependency in both the consumer and provider projects.
# In the consumer project
npm install --save-dev @pact-foundation/pact
# In the provider project
npm install --save-dev @pact-foundation/pact
Create a directory structure for your pact files and tests:
consumer-service/
├── src/
│ └── api/
│ └── userApiClient.ts
├── tests/
│ └── pact/
│ └── userApi.consumer.spec.ts
└── pacts/ (generated contract files)
provider-service/
├── src/
│ └── routes/
│ └── users.ts
└── tests/
└── pact/
└── userApi.provider.spec.ts
Writing Consumer Tests
Consumer tests define what the consumer expects from the provider. Each test sets up an interaction — a specific request that the consumer will make and the response it expects to receive.
// consumer-service/tests/pact/userApi.consumer.spec.ts
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { UserApiClient } from '../../src/api/userApiClient';
const { like, eachLike, integer, string } = MatchersV3;
const provider = new PactV4({
consumer: 'OrderService',
provider: 'UserService',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('User API Contract', () => {
it('should return a user by ID', async () => {
await provider
.addInteraction()
.given('a user with ID 1 exists')
.uponReceiving('a request for user 1')
.withRequest('GET', '/api/users/1', (builder) => {
builder.headers({ Accept: 'application/json' });
})
.willRespondWith(200, (builder) => {
builder
.headers({ 'Content-Type': 'application/json' })
.jsonBody({
id: integer(1),
name: string('Alice Johnson'),
email: string('alice@example.com'),
active: like(true),
});
})
.executeTest(async (mockServer) => {
// Point the client at Pact's mock server
const client = new UserApiClient(mockServer.url);
const user = await client.getUserById(1);
expect(user.id).toBe(1);
expect(user.name).toBeDefined();
expect(user.email).toBeDefined();
});
});
it('should return 404 for a non-existent user', async () => {
await provider
.addInteraction()
.given('no user with ID 999 exists')
.uponReceiving('a request for a non-existent user')
.withRequest('GET', '/api/users/999')
.willRespondWith(404, (builder) => {
builder.jsonBody({
error: string('User not found'),
});
})
.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
await expect(client.getUserById(999)).rejects.toThrow(
'User not found'
);
});
});
});
Notice the matchers like integer(), string(), and like(). These are critical because they define the contract’s flexibility. Using string('Alice Johnson') means the contract requires a string value, but the provider can return any string — not specifically “Alice Johnson”. This prevents brittle contracts that break on every data change. The given() method sets up provider states, which tell the provider what test data to prepare before each interaction.
Here’s the API client that the consumer tests exercise:
// consumer-service/src/api/userApiClient.ts
export class UserApiClient {
constructor(private baseUrl: string) {}
async getUserById(id: number): Promise<User> {
const response = await fetch(`${this.baseUrl}/api/users/${id}`);
if (!response.ok) {
const body = await response.json();
throw new Error(body.error || `HTTP ${response.status}`);
}
return response.json();
}
}
interface User {
id: number;
name: string;
email: string;
active: boolean;
}
When you run the consumer tests, Pact generates a JSON contract file in the pacts/ directory. This file contains every interaction the consumer expects, including the request details and response structure.
Writing Provider Verification Tests
The provider verification test starts your real API server and replays the consumer’s expectations against it. The provider doesn’t define new interactions — it only proves it satisfies what consumers already expect.
// provider-service/tests/pact/userApi.provider.spec.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { startServer } from '../../src/server';
describe('User API Provider Verification', () => {
let server: any;
const PORT = 4000;
beforeAll(async () => {
server = await startServer(PORT);
});
afterAll(async () => {
await server.close();
});
it('should satisfy the OrderService contract', async () => {
const verifier = new Verifier({
providerBaseUrl: `http://localhost:${PORT}`,
pactUrls: [
path.resolve(
__dirname,
'../../../consumer-service/pacts/OrderService-UserService.json'
),
],
// State handlers set up test data for each interaction
stateHandlers: {
'a user with ID 1 exists': async () => {
await seedUser({
id: 1,
name: 'Test User',
email: 'test@example.com',
active: true,
});
},
'no user with ID 999 exists': async () => {
await clearUsers();
},
},
});
await verifier.verifyProvider();
});
});
The stateHandlers map directly to the given() states defined in the consumer tests. Before each interaction replays, Pact calls the matching state handler so the provider’s database contains the right test data. This is where most of the setup work lives on the provider side.
If a developer renames email to emailAddress in the provider’s response, the verification test fails immediately with a clear message showing which field doesn’t match the contract. The developer knows they need to either update the consumer first or maintain backwards compatibility.
Pact Matchers: Writing Flexible Contracts
Pact matchers define what the contract checks — the structure and types — versus what it ignores — the specific values. Getting this balance right is essential for contracts that catch real bugs without being overly brittle.
import { MatchersV3 } from '@pact-foundation/pact';
const {
like, // Matches the type of the example value
eachLike, // Array where each element matches the example
integer, // Must be an integer
string, // Must be a string
boolean, // Must be a boolean
datetime, // Must match a datetime format
regex, // Must match a regex pattern
} = MatchersV3;
// Flexible contract example
const responseBody = {
id: integer(42),
name: string('Alice'),
email: regex('alice@example.com', '^[\\w.]+@[\\w.]+\\.[a-z]{2,}$'),
roles: eachLike('admin'), // Array with at least one string
createdAt: datetime(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
'2026-01-15T10:30:00.000Z'
),
metadata: like({ // Object with matching structure
lastLogin: string('2026-01-20'),
loginCount: integer(5),
}),
};
Use like() for objects and values where you only care about the type. Use regex() when the format matters, such as email addresses or date strings. Use eachLike() for arrays where you need at least one element matching a structure. Avoid exact value matching unless the value is genuinely fixed, like an enum or status code. For more on how API design decisions affect testing patterns, see our guide on REST vs GraphQL vs gRPC.
Using a Pact Broker
In the examples above, the consumer generates a pact file locally and the provider reads it from the filesystem. This works for a monorepo, but in a real microservices setup where each service lives in its own repository, you need a central place to share contracts. The Pact Broker serves this purpose.
# Run Pact Broker locally with Docker for development
docker run -d --name pact-broker \
-e PACT_BROKER_DATABASE_URL=sqlite:///pact_broker.sqlite3 \
-e PACT_BROKER_BASIC_AUTH_USERNAME=pact \
-e PACT_BROKER_BASIC_AUTH_PASSWORD=pact \
-p 9292:9292 \
pactfoundation/pact-broker
Publish consumer pacts to the broker after consumer tests pass:
# Publish pact files to the broker
npx pact-broker publish ./pacts \
--consumer-app-version=$(git rev-parse --short HEAD) \
--broker-base-url=http://localhost:9292 \
--broker-username=pact \
--broker-password=pact \
--tag=$(git branch --show-current)
Update the provider verification to fetch contracts from the broker instead of the filesystem:
// Provider verification using Pact Broker
const verifier = new Verifier({
providerBaseUrl: `http://localhost:${PORT}`,
pactBrokerUrl: 'http://localhost:9292',
pactBrokerUsername: 'pact',
pactBrokerPassword: 'pact',
provider: 'UserService',
providerVersion: process.env.GIT_COMMIT || 'local',
publishVerificationResult: process.env.CI === 'true',
consumerVersionSelectors: [
{ mainBranch: true }, // Contracts from the main branch
{ deployedOrReleased: true }, // Contracts from deployed versions
],
});
The Pact Broker also provides a can-i-deploy tool that checks whether a specific version of a service is safe to deploy based on verification results. This prevents deploying a provider that hasn’t verified its latest consumer contracts.
# Check if UserService version abc123 can be deployed to production
npx pact-broker can-i-deploy \
--pacticipant UserService \
--version abc123 \
--to-environment production \
--broker-base-url=http://localhost:9292
Integrating Pact into CI/CD
Contract testing with Pact delivers the most value when both consumer and provider tests run automatically. Here’s how the CI pipeline works for each side.
Consumer pipeline (runs on every PR):
# consumer-service/.github/workflows/contract-tests.yml
name: Consumer Contract Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
contract-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run consumer contract tests
run: npm run test:pact
- name: Publish pacts to broker
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }} \
--branch=${{ github.ref_name }}
Provider pipeline (runs on every PR and when new pacts are published):
# provider-service/.github/workflows/provider-verify.yml
name: Provider Contract Verification
on:
pull_request:
branches: [main]
repository_dispatch:
types: [pact_changed]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Verify provider contracts
run: npm run test:pact:verify
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_COMMIT: ${{ github.sha }}
CI: true
The repository_dispatch trigger lets the Pact Broker notify the provider repository when a consumer publishes a new contract. This way, the provider verifies new expectations immediately rather than waiting for the next provider PR. For more CI/CD pipeline patterns, see our guide on CI/CD for Node.js projects using GitHub Actions.
Real-World Scenario: Preventing Breaking Changes Across Services
Consider a backend team running five Node.js microservices: an Order Service, a User Service, a Payment Service, an Inventory Service, and a Notification Service. The Order Service consumes APIs from User, Payment, and Inventory. Each service has solid unit tests and its own integration tests against a test database.
Despite this coverage, the team ships a breaking change every few sprints. A common pattern: a developer on the User Service renames a response field from fullName to displayName to match a new frontend convention. The User Service’s own tests pass because they test the new field name. However, the Order Service still expects fullName and breaks silently — the null value propagates until a customer sees a blank name on their receipt.
The team introduces Pact incrementally, starting with the Order Service as the consumer and User Service as the provider — the pair that causes the most integration failures. They write consumer tests for the three endpoints the Order Service actually calls, publish contracts to a hosted Pact Broker, and add provider verification to the User Service pipeline.
Within the first sprint, Pact catches a planned field rename during code review. The provider verification fails, the developer sees exactly which consumer expectation broke, and the team coordinates the change properly — updating the consumer first, publishing a new contract, then updating the provider. Over the following month, they expand to cover Payment and Inventory contracts.
The key trade-off is the learning curve and setup time for the Pact Broker. However, teams that previously spent days debugging cross-service failures in staging environments find the investment pays for itself within the first few caught regressions. For patterns on building resilient microservices communication, see our guide on circuit breakers and resilience patterns.
When to Use Contract Testing with Pact
- Your system has multiple services that communicate over HTTP or messaging and are deployed independently
- Breaking API changes between services are a recurring source of production incidents
- End-to-end integration tests are slow, flaky, or require complex environment setup to run all services together
- Your services are maintained by different teams or developers who don’t always coordinate API changes
- You want to verify compatibility before deployment rather than discovering failures in staging or production
When NOT to Use Pact
- Your application is a monolith — there are no service boundaries to test across, and unit tests cover internal interfaces effectively
- You have only two services with a single integration point — the overhead of a Pact Broker isn’t justified when direct communication between teams is straightforward
- Your services communicate exclusively through a message broker with strict schemas (like Protobuf or Avro) that already enforce compatibility at the serialization layer
- Your team lacks the infrastructure to run a Pact Broker — without a central contract store, sharing pact files between repositories becomes fragile
Common Mistakes with Pact Contract Testing
- Writing contracts that are too strict. Using exact value matching instead of type matchers creates contracts that fail on every data change. Use
like()andstring()matchers to verify structure and types, not specific values that change between test runs. - Testing the provider’s internal behavior in consumer tests. Consumer tests should only define what the consumer needs — specific endpoints, request formats, and response fields. Testing provider-side business logic like “creating a user also sends a welcome email” belongs in the provider’s own test suite.
- Skipping provider states. Without
stateHandlers, the provider verification runs against whatever data happens to exist in the test database. This makes tests flaky and non-reproducible. Always define explicit states that set up the exact data each interaction requires. - Not publishing verification results back to the broker. If the provider verifies contracts locally but doesn’t publish results, the
can-i-deploycheck can’t determine deployment safety. SetpublishVerificationResult: truein CI environments so the broker has a complete picture. - Using Pact as a replacement for integration tests. Contract tests verify API shape, not business logic. They confirm that the response has an
amountfield of type number, but they don’t verify that the amount is calculated correctly. You still need integration tests for business correctness alongside contract tests for compatibility. - Maintaining stale contracts. When a consumer stops using an endpoint, remove the corresponding interaction from the consumer tests. Stale contracts force the provider to maintain backwards compatibility for endpoints nobody calls, which slows development unnecessarily.
Conclusion
Contract testing with Pact catches the specific class of bugs that unit tests and integration tests miss — breaking changes at service boundaries that only surface when services interact. Start by identifying the service pair with the most frequent integration failures, write consumer tests for the endpoints actually called, and verify them on the provider side. Once the workflow is proven, expand to additional service pairs and introduce a Pact Broker for centralized contract management.
The consumer-driven approach ensures you only test what consumers actually need, which keeps contracts focused and maintainable. For your next step, explore how microservices with NestJS structures service communication, or compare approaches for microservices across Spring Cloud, Node.js, and Python.