Contract Testing with Pact: Complete Tutorial for JavaScript Teams
Pact is a consumer-driven contract testing library that lets you define what a consumer (e.g., a frontend app or microservice) expects from a provider (e.g., an API), generate a contract file from those expectations, and verify the provider actually fulfills them — without spinning up both services at the same time. This tutorial covers the full workflow: consumer test → pact file → provider verification.
Key Takeaways
Pact tests the contract, not the implementation. Pact doesn't test that your API works end-to-end. It tests that the provider's responses match what the consumer expects. The consumer owns the contract.
The consumer writes the contract. The consumer test defines the request shape and expected response. This flips the usual "API owners decide the shape" dynamic — consumers say what they need.
Pact mocks the provider during consumer tests. Consumer tests don't need the real provider running. Pact spins up a mock server based on the defined interactions, and the consumer runs against that.
Provider verification replays consumer interactions against the real provider. The generated pact file is sent to the provider's CI, which replays each interaction against the actual running service and verifies the responses match.
Pact Broker is optional but strongly recommended for teams. The Broker hosts pact files and tracks which provider versions are compatible with which consumer versions. Without it, you're passing pact files around manually.
What Contract Testing Solves
In a microservices architecture, Service A calls Service B's API. Service B changes an API response shape. Service A now breaks. Nobody noticed until E2E tests ran — or worse, until production.
The traditional solutions — extensive integration test environments, contract documents, API versioning — are slow, expensive, and often ignored.
Pact solves this at the unit level: consumer tests that verify Service A's expectations about Service B's API are met, without Service B being deployed. When Service B is about to deploy, it verifies it still meets all consumer expectations. If it doesn't, the deploy fails before integration testing.
Installation
npm install --save-dev @pact-foundation/pactThe Consumer Side: Writing a Pact Test
Imagine you have a frontend app (user-app) that calls a user API (user-service). The app makes a GET /users/1 request and expects a response with id, name, and email.
Consumer Test Setup
// user.pact.spec.js
const { PactV3, MatchersV3 } = require("@pact-foundation/pact");
const { like, regex } = MatchersV3;
const path = require("path");
// Create the Pact instance
const provider = new PactV3({
consumer: "user-app",
provider: "user-service",
dir: path.resolve(process.cwd(), "pacts"),
port: 8080,
});Defining an Interaction
An interaction describes: given some state, when the consumer makes this request, the provider should respond with this.
describe("User Service - Consumer Tests", () => {
describe("GET /users/:id", () => {
it("returns a user by ID", async () => {
// Define the interaction
await provider
.given("a user with ID 1 exists")
.uponReceiving("a request to get user 1")
.withRequest({
method: "GET",
path: "/users/1",
headers: {
Accept: "application/json",
},
})
.willRespondWith({
status: 200,
headers: { "Content-Type": "application/json" },
body: {
id: like(1), // any integer is acceptable
name: like("Alice"), // any string is acceptable
email: regex(
"\\w+@\\w+\\.\\w+",
"alice@example.com"
),
},
});
// Run the test
await provider.executeTest(async (mockServer) => {
// Point your actual client code at the mock server
const client = new UserClient(mockServer.url);
const user = await client.getUser(1);
expect(user.id).toBeDefined();
expect(user.name).toBeDefined();
expect(user.email).toMatch(/\w+@\w+\.\w+/);
});
});
});
});The UserClient
Your actual API client code — the thing being tested:
// userClient.js
const axios = require("axios");
class UserClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async getUser(id) {
const response = await axios.get(`${this.baseUrl}/users/${id}`, {
headers: { Accept: "application/json" },
});
return response.data;
}
}
module.exports = { UserClient };The consumer test uses UserClient against the Pact mock server. When executeTest runs, Pact:
- Starts a mock server on port 8080
- Configures it to respond to
GET /users/1with the defined response - Runs your test function against that mock
- Verifies the real client made the expected request
- Writes a pact file to the
pacts/directory
Running the Consumer Test
npx jest user.pact.spec.jsIf the test passes, a pact file is generated:
pacts/user-app-user-service.jsonThis JSON file contains the interactions — it's the contract.
The Generated Pact File
{
"consumer": { "name": "user-app" },
"provider": { "name": "user-service" },
"interactions": [
{
"description": "a request to get user 1",
"providerState": "a user with ID 1 exists",
"request": {
"method": "GET",
"path": "/users/1",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
},
"matchingRules": {
"body": {
"$.id": { "matchers": [{ "match": "type" }] },
"$.name": { "matchers": [{ "match": "type" }] },
"$.email": { "matchers": [{ "match": "regex", "regex": "\\w+@\\w+\\.\\w+" }] }
}
}
}
}
]
}This file goes to the provider — either via a Pact Broker or copied directly.
The Provider Side: Verifying the Contract
The provider (user-service) runs verification against the pact file as part of its CI build:
// provider.pact.spec.js
const { Verifier } = require("@pact-foundation/pact");
const path = require("path");
describe("Pact Verification - user-service", () => {
it("validates the expectations of user-app", async () => {
const opts = {
provider: "user-service",
providerBaseUrl: "http://localhost:3000", // running provider
pactUrls: [
path.resolve(process.cwd(), "../user-app/pacts/user-app-user-service.json"),
],
// State handlers set up provider data for each "given" state
stateHandlers: {
"a user with ID 1 exists": async () => {
// Seed the test database with user 1
await db.users.upsert({ id: 1, name: "Alice", email: "alice@example.com" });
},
},
};
return new Verifier(opts).verifyProvider();
});
});The verifier:
- Reads the pact file
- For each interaction, runs the
stateHandlersto set up provider data - Replays the consumer request against the real provider
- Verifies the response matches the contract (using matching rules from the pact file)
If GET /users/1 returns { id: 1, name: "Alice", email: "alice@example.com" }, verification passes. If the provider changed the shape — dropped email, renamed name to fullName, changed id to a string — verification fails.
Pact Broker: Sharing Contracts at Scale
With multiple services and multiple pact files, sharing files manually doesn't scale. Pact Broker is a service that:
- Hosts pact files by consumer/provider/version
- Tracks which provider versions are compatible with each consumer version
- Provides a UI for visualizing the contract network
- Supports
can-i-deploychecks: "is it safe to deploy user-service v2.1.0 if user-app v1.3.0 is in production?"
Publishing Pacts to the Broker
npx pact-broker publish ./pacts \
--consumer-app-version="$(git rev-parse HEAD)" \
--broker-base-url="https://your-broker.pactflow.io" \
--broker-token="$PACT_BROKER_TOKEN"Provider Verification Against the Broker
const opts = {
provider: "user-service",
providerBaseUrl: "http://localhost:3000",
// Instead of local pact files, fetch from the broker
pactBrokerUrl: "https://your-broker.pactflow.io",
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
};Can-I-Deploy Check
Before deploying user-service to production:
npx pact-broker can-i-deploy \
--pacticipant="user-service" \
--version="$(git rev-parse HEAD)" \
--to-environment="production" \
--broker-base-url="https://your-broker.pactflow.io" \
--broker-token="$PACT_BROKER_TOKEN"Output:
Computer says yes ✔
CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS?
------------|-----------|---------------|-----------|--------
user-app | abc123 | user-service | def456 | trueIf verification failed for any consumer, can-i-deploy returns a non-zero exit code and blocks the deploy.
Pact in CI/CD
Consumer CI
# .github/workflows/consumer.yml
- name: Run Pact consumer tests
run: npx jest --testPathPattern=pact
- name: Publish pacts
run: |
npx pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}Provider CI
# .github/workflows/provider.yml
- name: Start provider
run: npm start &
- name: Run Pact provider verification
run: npx jest --testPathPattern=provider.pact
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_SHA: ${{ github.sha }}
- name: Can-I-Deploy check
run: |
npx pact-broker can-i-deploy \
--pacticipant=user-service \
--version=${{ github.sha }} \
--to-environment=production \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}Common Mistakes
Matching too strictly. Using exact value matching ({ id: 1 }) instead of type matching (like(1)) makes contracts brittle — any change in test data breaks verification. Use matchers for everything that isn't a business rule.
Not using provider states. If your provider tests don't set up the data described in given(...), verification may pass accidentally (user not found → 404, which doesn't match the 200 contract) or fail for the wrong reason.
Skipping pact broker and passing files manually. Works for two services. Doesn't work for ten. Start with the broker.
Testing too much in the consumer test. The consumer test verifies the contract shape, not the business logic. Don't write a consumer test that covers every possible server response — write one per interaction shape that the consumer actually uses.