Set Up Contract Tests for Independently Deployed Services
For developers running two services that ship on separate schedules, this shows how to add consumer-driven contract tests that fail fast in CI before a breaking deploy lands. You will leave with a working Pact-based flow: consumer tests publish contracts, provider verification runs against them, and deployment is gated on verification status.
TL;DR — Use consumer-driven contracts and publish them from the consumer CI on every main-branch build. The single most common fix when this setup "doesn't work" is that the provider is verifying the wrong provider version or never publishing verification results back to the broker. Reading time: ~5 min
Goal
When you finish, your consumer service will publish a contract artifact from CI, your provider service will verify that contract in its own CI against the current code, and your deployment pipeline will have a hard pass/fail signal that blocks incompatible independent releases.
Prerequisites
- Two services with separate repos/pipelines: one consumer, one provider
- Node.js >= 20 on both repos if you use the examples below — check with:
node --version
- npm >= 10 — check with:
npm --version
- A Pact Broker or compatible contract broker URL, for example
https://pact-broker.example.com - Broker credentials with permission to publish pacts and verification results
- CI variables configured in both repos:
PACT_BROKER_BASE_URL
PACT_BROKER_TOKEN
GIT_COMMIT
GIT_BRANCH
- The provider base URL available during verification, for example
http://127.0.0.1:8080 - A stable provider state setup mechanism in the provider test environment, typically an HTTP endpoint such as
POST /_pact/provider_states
Steps
Step 1: Add Pact dependencies to the consumer
Run this in the consumer repo:
npm install --save-dev @pact-foundation/pact
Success looks like npm exiting with code 0 and adding @pact-foundation/pact to devDependencies in package.json.
Step 2: Create a consumer contract test that writes a pact file
Create test/contracts/orders-api.pact.test.js in the consumer repo:
const path = require('path');
const { Pact, Matchers } = require('@pact-foundation/pact');
const { like, integer } = Matchers;
const provider = new Pact({
consumer: 'web-frontend',
provider: 'orders-service',
port: 1234,
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn'
});
describe('orders-service contract', () => {
beforeAll(() => provider.setup());
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
test('GET /orders/42 returns an order', async () => {
await provider.addInteraction({
state: 'order 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; charset=utf-8' },
body: {
id: integer(42),
status: like('PAID'),
totalCents: integer(2599)
}
}
});
const res = await fetch('http://127.0.0.1:1234/orders/42', {
headers: { Accept: 'application/json' }
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: 42,
status: 'PAID',
totalCents: 2599
});
});
});
Add a script to package.json:
{
"scripts": {
"test:contract": "node --test test/contracts/*.pact.test.js"
}
}
Success looks like a pact file appearing under pacts/, for example pacts/web-frontend-orders-service.json.
Step 3: Run the consumer contract test locally
In the consumer repo, run:
npm run test:contract
ls -l pacts
Success looks like test output ending in ok or exit code 0, plus a pact JSON file in pacts/.
Example output shape:
> test:contract
> node --test test/contracts/*.pact.test.js
✔ GET /orders/42 returns an order (145.231ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
Step 4: Publish the pact from consumer CI
Add the Pact CLI publisher in the consumer repo:
npm install --save-dev @pact-foundation/pact-node
Add scripts/publish-pacts.js:
const { publishPacts } = require('@pact-foundation/pact-node');
publishPacts({
pactFilesOrDirs: ['pacts'],
pactBroker: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
consumerVersion: process.env.GIT_COMMIT,
branch: process.env.GIT_BRANCH
}).then(() => {
console.log('Pacts published');
}).catch((err) => {
console.error(err);
process.exit(1);
});
Run it:
node scripts/publish-pacts.js
Success looks like exit code 0 and output containing Pacts published.
Step 5: Add provider verification dependencies
Run this in the provider repo:
npm install --save-dev @pact-foundation/pact
Success looks like npm exit code 0 and the package added to devDependencies.
Step 6: Expose provider states in the provider test environment
Add a test-only endpoint in the provider service. Example Express route in test/provider-states.js:
const express = require('express');
const app = express();
app.use(express.json());
app.post('/_pact/provider_states', async (req, res) => {
const state = req.body.state;
if (state === 'order 42 exists') {
await global.testDb.query(`INSERT INTO orders (id, status, total_cents) VALUES (42, 'PAID', 2599) ON CONFLICT (id) DO UPDATE SET status='PAID', total_cents=2599`);
return res.status(200).json({ result: 'ok' });
}
return res.status(400).json({ error: `Unknown state: ${state}` });
});
module.exports = app;
Mount it only in test mode, then start the provider locally on port 8080.
Success looks like this returning 200:
curl -i -X POST http://127.0.0.1:8080/_pact/provider_states -H 'Content-Type: application/json' -d '{"state":"order 42 exists"}'
Expected output shape:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"result":"ok"}
Step 7: Verify the provider against published contracts
Create test/pact.verify.js in the provider repo:
const { Verifier } = require('@pact-foundation/pact');
new Verifier().verifyProvider({
provider: 'orders-service',
providerBaseUrl: 'http://127.0.0.1:8080',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
publishVerificationResult: true,
stateHandlers: {},
stateHandlersIgnoreMissing: true,
providerStatesSetupUrl: 'http://127.0.0.1:8080/_pact/provider_states'
}).then(() => {
console.log('Provider verification complete');
}).catch((err) => {
console.error(err);
process.exit(1);
});
Run it:
node test/pact.verify.js
Success looks like exit code 0 and output ending with Provider verification complete.
If the provider breaks the contract, output usually looks like this:
Verifying a pact between web-frontend and orders-service
a request for order 42
with GET /orders/42
returns a response which
has status code 200 (FAILED - 1)
Failures:
1) Verifying a pact between web-frontend and orders-service Given order 42 exists - a request for order 42
1.1) has a matching body
$.totalCents -> Expected 2599 (Integer) but received "2599" (String)
Step 8: Gate deployment with a can-I-deploy check
Install the Pact Broker client where your deployment job runs:
npm install --save-dev @pact-foundation/pact-node
Run this before deploying the consumer or provider:
npx pact-broker can-i-deploy --pacticipant orders-service --version "$GIT_COMMIT" --broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"
For the consumer, use:
npx pact-broker can-i-deploy --pacticipant web-frontend --version "$GIT_COMMIT" --broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"
Success looks like exit code 0 and output containing Computer says yes.
A blocked deploy looks like this:
Computer says no ¯\_(ツ)_/¯
The following pacticipants are missing verifications for version 6f3d9b2:
orders-service
⚠️ If your provider-state endpoint writes to a shared database, point verification at an isolated test database before running Step 7. Provider verification can create or overwrite records by design.
Verify it works
Run the full flow in order:
# consumer repo
npm run test:contract
node scripts/publish-pacts.js
# provider repo
node test/pact.verify.js
npx pact-broker can-i-deploy --pacticipant orders-service --version "$GIT_COMMIT" --broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"
Expected end-to-end result:
- Consumer contract test exits
0 - Pact file is published to the broker
- Provider verification exits
0and publishes results can-i-deployexits0
If you want one direct broker check, fetch the latest pact URL list:
curl -s -H "Authorization: Bearer $PACT_BROKER_TOKEN" "$PACT_BROKER_BASE_URL/pacts/provider/orders-service/latest" | jq .
Success looks like valid JSON, not 401 Unauthorized or 404 Not Found.
Common pitfalls
Provider verification uses the wrong provider version
Mistake: providerVersion is hard-coded or omitted in Step 7.
Symptom: verification appears green in logs, but can-i-deploy still says the current commit is unverified.
Fix: set providerVersion: process.env.GIT_COMMIT and pass the actual CI commit SHA.
Verification results are never published
Mistake: publishVerificationResult is false or broker credentials are missing in provider CI.
Symptom: local verification passes, but broker still reports missing verifications.
Fix: set publishVerificationResult: true and export PACT_BROKER_BASE_URL plus PACT_BROKER_TOKEN in the provider pipeline.
Provider states endpoint is not reachable from the verifier
Mistake: providerStatesSetupUrl points to the wrong host, port, or path.
Symptom: verification fails before assertions with connection errors such as:
Error: connect ECONNREFUSED 127.0.0.1:8081
Fix: run curl -i -X POST http://127.0.0.1:8080/_pact/provider_states -H 'Content-Type: application/json' -d '{"state":"order 42 exists"}' and use that exact working URL in Step 7.
Consumer test matches example values too strictly
Mistake: pact body uses literal values for fields that legitimately vary, like timestamps or generated IDs.
Symptom: provider verification fails on harmless differences after a valid provider change.
Fix: replace strict literals with Pact matchers such as integer(42) and like('PAID') in Step 2.
Contract tests run against unstable provider data
Mistake: provider verification depends on whatever happens to be in a shared dev database.
Symptom: flaky verification; the same commit alternates between pass and fail.
Fix: seed exact records in the provider-state handler and point tests at an isolated database.
Branch versions are published without branch metadata
Mistake: pacts are published without branch, so the broker cannot relate branch-specific versions cleanly.
Symptom: confusing deploy matrix or unexpected pending/unverified results when multiple branches publish contracts.
Fix: publish with branch: process.env.GIT_BRANCH in Step 4 and providerVersionBranch: process.env.GIT_BRANCH in Step 7.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI