Catch Breaking API Changes Before They Ship with Pact
Wire consumer-driven Pact tests into CI so a backend deploy fails before it breaks your frontend.
What you'll build
A consumer-driven contract between two Node.js services — order-web (frontend) and order-api (backend) — enforced by Pact: the consumer publishes its expectations as a pact file, the provider verifies against it on every commit, and a can-i-deploy gate in CI blocks any backend deploy that would break the frontend. Unlike schema diffing, this only locks down what consumers actually use — the provider stays free to change everything else.
Prerequisites
- Node.js 22+ —
@pact-foundation/pactv17 requiresnode >= 22. - Docker to run the Pact Broker locally (verified with Docker 29.x).
- Versions verified for this tutorial:
@pact-foundation/pact17.0.1,@pact-foundation/pact-cli18.1.1, Jest 30.4, Express 5.2, thepactfoundation/pact-brokerDocker image. macOS and Linux are identical; on Windows use WSL2.
1. Scaffold the consumer
Two directories stand in for two repos:
mkdir -p pact-demo/order-web pact-demo/order-api
cd pact-demo/order-web
npm init -y
npm i -D @pact-foundation/pact@17 jest@30 @pact-foundation/pact-cli
The consumer's API client, src/api.js — plain fetch, nothing Pact-specific:
async function getOrder(baseUrl, id) {
const res = await fetch(`${baseUrl}/orders/${id}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`Unexpected status ${res.status}`);
return res.json();
}
module.exports = { getOrder };
2. Write the consumer contract test
The test runs your real client against a Pact mock server and records the interaction as a contract. Save as test/order-api.pact.test.js:
const path = require('path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { getOrder } = require('../src/api');
const { like, string, decimal } = MatchersV3;
const provider = new PactV3({
consumer: 'order-web',
provider: 'order-api',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('GET /orders/:id', () => {
it('returns the order when it exists', () => {
provider
.given('an order with id 42 exists')
.uponReceiving('a request for order 42')
.withRequest({
method: 'GET',
path: '/orders/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: like({
id: 42,
status: string('shipped'),
total: decimal(129.99),
}),
});
return provider.executeTest(async (mockServer) => {
const order = await getOrder(mockServer.url, 42);
expect(order).toEqual({ id: 42, status: 'shipped', total: 129.99 });
});
});
});
Use matchers, not literals: string('shipped') means "any string here", so the provider isn't forced to return that exact value later. Run npx jest — on pass, Pact writes pacts/order-web-order-api.json.
3. Stand up a Pact Broker and publish the contract
The Pact Broker is the exchange point between the two pipelines. Run it locally with Docker (SQLite is fine for a demo; use Postgres in production — SQLite doesn't handle concurrent requests):
docker run -d --name pact-broker -p 9292:9292 \
-e PACT_BROKER_DATABASE_ADAPTER=sqlite \
-e PACT_BROKER_DATABASE_NAME=/tmp/pact_broker.sqlite3 \
pactfoundation/pact-broker
Publish the pact from order-web/, versioned by git SHA and branch so the broker can track which app version expects what:
npx pact-broker publish ./pacts/*.json \
--broker-base-url http://localhost:9292 \
--consumer-app-version dev1 \
--branch main
Locally any version string works; in CI, pass the git SHA (--consumer-app-version $GITHUB_SHA).
Open http://localhost:9292 — the order-web → order-api contract is listed, unverified.
4. Verify the provider against the contract
Set up the backend:
cd ../order-api
npm init -y
npm i express@5
npm i -D @pact-foundation/pact@17 jest@30 @pact-foundation/pact-cli
src/app.js:
const express = require('express');
function createApp(orders) {
const app = express();
app.get('/orders/:id', (req, res) => {
const order = orders.get(Number(req.params.id));
if (!order) return res.status(404).json({ error: 'not found' });
res.json(order);
});
return app;
}
module.exports = { createApp };
The verification test, test/pact.verify.test.js, starts the real app and replays every interaction from the broker against it. The stateHandlers key must match the consumer's given() string exactly — that's how the provider seeds test data without sharing a database:
const { Verifier } = require('@pact-foundation/pact');
const { createApp } = require('../src/app');
const orders = new Map();
let server;
beforeAll((done) => {
server = createApp(orders).listen(8081, done);
});
afterAll((done) => server.close(done));
it('honours the pacts published by consumers', () => {
return new Verifier({
provider: 'order-api',
providerBaseUrl: 'http://127.0.0.1:8081',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL || 'http://localhost:9292',
consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT || 'dev',
providerVersionBranch: process.env.GIT_BRANCH || 'main',
stateHandlers: {
'an order with id 42 exists': async () => {
orders.set(42, { id: 42, status: 'shipped', total: 129.99 });
},
},
}).verifyProvider();
}, 60000);
Run npx jest. The selectors pull the pacts that matter: each consumer's main branch plus whatever's actually deployed. Note the test never touches the consumer's code — the pact file is the only thing the two pipelines share, which is what lets them live in separate repos and deploy independently.
5. Gate deploys in CI with can-i-deploy
In the provider's GitHub Actions pipeline, verification publishes its result to the broker, then can-i-deploy refuses to proceed unless this exact SHA has passed against every consumer version in production:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npx jest
env:
CI: "true"
PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
GIT_COMMIT: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
- name: Can I deploy?
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant order-api --version ${{ github.sha }} \
--to-environment production \
--broker-base-url ${{ vars.PACT_BROKER_BASE_URL }}
# ... your deploy step, then:
- name: Record deployment
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker record-deployment \
--pacticipant order-api --version ${{ github.sha }} \
--environment production \
--broker-base-url ${{ vars.PACT_BROKER_BASE_URL }}
can-i-deploy exits non-zero if verification is missing or failed, which fails the job before your deploy step runs — that's the gate. The broker pre-creates test and production environments; record-deployment after each release tells it what's live. The consumer pipeline mirrors this: publish the pact, then can-i-deploy --pacticipant order-web before shipping the frontend. For CI you'll want a broker both pipelines can reach — self-host the same image, or use a hosted one.
Verify it works
Consumer side (order-web), npx jest:
Tests: 1 passed, 1 total
Provider side (order-api), npx jest prints the replayed interaction:
Verifying a pact between order-web and order-api
a request for order 42
Given an order with id 42 exists
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json" (OK)
has a matching body (OK)
Now prove the gate catches a breaking change. In src/app.js, make the route rename the field — res.json({ id: order.id, state: order.status, total: order.total }) — and rerun the provider's npx jest:
1) Verifying a pact between order-web and order-api Given an order with id 42 exists - a request for order 42
1.1) has a matching body
$ -> Actual map is missing the following keys: status
There were 1 pact failures
In CI that failed verification lands in the broker, can-i-deploy exits non-zero, and the deploy never happens. The frontend team finds out from your pipeline — not from production.
Troubleshooting
npm warn EBADENGINE Unsupported engine/required: { node: '>=22' }on install — you're on Node 20 or older. pact-js v17 ships a native core built for Node 22+; upgrade Node rather than forcing the install.no state handler found for state: "an order with id 42 exists"during verification, followed by a body mismatch or 404 — the string instateHandlersdoesn't exactly match the consumer'sgiven(). Copy it verbatim; it's an identifier, not prose.❌ Failed to access pact broker path '' - error sending request for url (http://localhost:9292/)when publishing — the broker isn't up yet or the URL is wrong. Checkdocker psand hithttp://localhost:9292/diagnostic/status/heartbeatuntil it responds — first startup can take a few seconds while the database initialises.$ -> Actual map is missing the following keys: ...— this isn't a tooling problem; the provider genuinely dropped a field a consumer relies on. Either restore it, or change the consumer's test first and republish the pact — that's the consumer-driven workflow doing its job.
Next steps
Add a broker webhook so publishing a changed pact triggers provider verification immediately instead of waiting for the provider's next commit. Read up on pending pacts and WIP pacts so new consumer expectations don't break the provider's build before it has a chance to implement them. When you outgrow a self-hosted broker, PactFlow is the managed option. And if your services also talk over queues, Pact's message pacts apply the same contract discipline to async events.
Sources & further reading
- Pact JS (v17) — github.com
- Verifying Pacts - JavaScript — docs.pact.io
- Pact Broker Docker Image — docs.pact.io
- Consumer Version Selectors — docs.pact.io
- Recording Deployments and Releases — docs.pact.io
Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.
Discussion 0
No comments yet
Be the first to weigh in.