What a consumer-driven contract checks, and what it does not
Pact contract testing checks one thing: that a provider still returns what its consumers depend on. In a consumer-driven contract, the consumer's test suite defines the contract. Each consumer test records the requests that client actually sends (method, path, query, headers, body) and the response fields it actually reads. The provider then replays those requests against its real code and confirms the responses still match.
Coverage follows real usage. If a consumer reads id and status from an order, only those two fields are in the contract. The provider can rename internal_notes without asking anyone, and it learns before deploy if it removes status.
What it does not check
A contract does not test the provider's business logic. It does not check whether a discount is calculated correctly, only whether the response has the expected shape and values. It says nothing about latency, throughput, or behaviour under load. It also does not exercise end-to-end flows across several services. You still need unit, integration, and a small number of end-to-end tests for those.
Compared with the alternatives
Schema validation, such as checking against an OpenAPI document, tells you a response conforms to what the provider declares. It does not tell you which parts any consumer relies on, so a breaking change can pass if the schema was updated too. A shared staging environment catches real breaks, but only after deployment, and it is often slow, flaky, and contended.
Pact suits backend and platform engineers whose teams own both sides of internal HTTP or message-based APIs.
How the pipeline fits together: consumers, providers, and the Pact Broker
The loop has five steps, and each one writes to or reads from the Pact Broker.
- The consumer test produces a pact. Running the consumer's unit tests against the Pact mock server writes a JSON file listing every request the consumer makes and the response shape it relies on.
- The consumer publishes the pact. CI uploads it to the broker with the git SHA as the application version and the current branch attached. Use the SHA, not a semantic version. Two builds of the same SHA are the same code, while a reused version number can silently overwrite a different contract.
- A broker webhook triggers provider verification. When a pact with changed content is published, the broker calls the provider's CI and passes the pact URL. The provider replays the requests against its real code without a full rebuild of the consumer side.
- Verification results go back to the broker. Each result is tied to the provider's own git SHA and branch, so the broker records "provider
a1b2c3onmainsatisfies consumerd4e5f6." - can-i-deploy runs before each deploy. It asks whether this version has verified pacts with the versions currently in the target environment. After the deploy succeeds, CI records the deployment (or the release, for artifacts with several live versions) so the broker knows what is running where.
This is why the broker is the source of truth. Neither pipeline alone can see both sides. Only the broker holds the full matrix of which consumer and provider versions were verified together and which are deployed, and it is the only place that can answer a compatibility question safely.
+----------------------+ +----------------------+
| CONSUMER CI | | PROVIDER CI |
| commit d4e5f6 | | commit a1b2c3 |
| 1. tests vs Pact mock| | 3. replay pact vs |
| -> pact JSON file | | real provider code|
+----------+-----------+ +---+--------------+---+
| 2. publish pact | 4. results ^ 3. webhook
| version = git SHA | provider | pact URL
| + branch | SHA+branch|
v v |
+----------------------------------------------------------------+---------+
| PACT BROKER |
| stores: pacts, verification results, deployed/released versions |
| => single matrix: which version pairs verified, which are deployed |
| source of truth: neither pipeline can see both sides alone |
+----------+------------------+-------------------+------------------------+
^ 5. can-i-deploy | yes / no ^ 6. record-deployment
| vs target env v | or record-release
+----------+------------------+-------------------+----------+
| DEPLOY STEP (consumer or provider pipeline) |
| can-i-deploy? --> deploy to env --> record what now runs |
| if no -> pipeline stops, nothing is deployed |
+------------------------------------------------------------+
Every step of the contract-testing loop reads from or writes to the Pact Broker, which is the only component that holds the full matrix of verified consumer/provider version pairs and what is deployed where.
Writing the consumer test: mock server, matchers, and provider states
The consumer test starts a Pact mock server, registers the interactions the consumer expects, and then calls the mock through your production API client, not a hand-written fetch. This is important because the pact should reflect the paths, headers and query parameters your code actually sends. If the client adds an Accept header or encodes IDs differently, the contract captures that. The mock fails the test if an expected request never arrives or an unexpected one does, and it writes the pact file only when the interactions match.
Each interaction declares a provider state, such as given('user 42 exists'). The state is a name, not a fixture. It tells the provider which data to set up before replaying the request, so the consumer never has to know how the provider seeds its database.
For response bodies, use matchers instead of literals:
- A type matcher on
idandemailsays "an integer" and "a string." - A regex matcher on
createdAtpins the ISO-8601 format. - An array matcher says "at least one element shaped like this."
Matchers can fail in two ways. If you over-specify literals, such as asserting name: "Ada", the contract breaks whenever the provider's test data changes. If you match too loosely, such as accepting any object or omitting a field the client reads, a renamed or removed field passes verification and breaks production. Include exactly the fields your client reads, each with a matcher.
In CI, publish the pact after the tests pass:
pact-broker publish ./pacts --consumer-app-version $GIT_SHA --branch $GIT_BRANCH --broker-base-url $PACT_BROKER_URL --broker-token $PACT_BROKER_TOKEN
Use the commit SHA as the version so each pact maps to exactly one build.
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserClient } from '../src/userClient'; // the production client, not a raw fetch
const { integer, string, regex, eachLike } = MatchersV3;
const provider = new PactV3({ consumer: 'web-app', provider: 'user-service', dir: './pacts' });
describe('UserClient', () => {
it('fetches a user by id', () => {
provider
.given('user 42 exists') // state name only; the provider decides how to seed it
.uponReceiving('a request for user 42')
.withRequest({ method: 'GET', path: '/users/42', headers: { Accept: 'application/json' } })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
// Only the fields the client reads, each with a matcher.
// Too strict: name: 'Ada' breaks when provider test data changes.
// Too loose: like({}) or dropping a field lets a rename reach production.
body: {
id: integer(42),
email: string('ada@example.com'),
createdAt: regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, '2024-01-15T09:30:00Z'),
roles: eachLike(string('admin')), // at least one string element
},
});
// The mock fails the test on missing or unexpected requests;
// the pact file is written only if all interactions matched.
return provider.executeTest(async (mockServer) => {
const client = new UserClient(mockServer.url);
const user = await client.getUser(42);
expect(user.id).toBe(42);
expect(user.roles.length).toBeGreaterThan(0);
});
});
});
A Pact JS consumer test that drives the real UserClient against the Pact mock server, declares the 'user 42 exists' provider state, and uses integer, string, regex and eachLike matchers instead of literal values.
Verifying on the provider pipeline: state handlers, consumer version selectors, and pending pacts
The provider verifies in its own CI job against the build under test. Start the service in a test profile with downstream calls stubbed, so a failure points to this provider's contract and not to a flaky neighbour.
State handlers
Each provider state a consumer declares, such as "order 42 exists", maps to a handler that seeds exactly that data. That can be an in-memory repository or a stub injected at the service boundary. Avoid a shared database: parallel jobs collide and leftover rows hide missing setup. The risk is that the handler layer grows into a second, hand-maintained data model. It can accept states that production data never produces and miss ones it does, so review handlers alongside schema changes.
Choosing which pacts to verify
| Selector | What it verifies | Why |
|---|---|---|
mainBranch: true |
Each consumer's main-branch pact | Catches breaks before consumers merge |
deployedOrReleased: true |
Pacts for versions currently in an environment | Protects what is actually running |
| "latest" (avoid) | Newest pact from any branch | One consumer's feature branch can fail your build |
Pending and WIP pacts
With enablePending, a pact the provider has never verified successfully is reported but does not fail the build. includeWipPactsSince also pulls in fresh feature-branch pacts under the same non-blocking rule.
Publish results with --provider-app-version $GIT_SHA and the provider branch. Then add a Broker webhook on contract_requiring_verification_published so that a changed pact triggers verification immediately.
import { Verifier } from '@pact-foundation/pact';
import { startApp } from '../src/app'; // your service's bootstrap
import { InMemoryOrderRepo, StubPaymentsClient } from './fakes';
// In-memory data per job: no shared DB, so parallel CI jobs cannot collide.
const orders = new InMemoryOrderRepo();
async function verify() {
// Boot the provider in test mode with downstream calls stubbed.
const server = await startApp({ port: 8081, orderRepo: orders, payments: new StubPaymentsClient() });
try {
await new Verifier({
provider: 'order-service',
providerBaseUrl: '',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Verify main-branch pacts and pacts for deployed or released versions, not "latest".
consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
enablePending: true, // never-verified pacts are reported, not blocking
includeWipPactsSince: '2026-01-01', // also pull in fresh feature-branch pacts
// Each state seeds exactly the data it names; review these with schema changes.
stateHandlers: {
'order 42 exists': async () => {
orders.clear();
orders.save({ id: 42, status: 'PAID', totalCents: 1999 });
},
'no orders exist': async () => { orders.clear(); },
},
// Publish results tagged with this build's commit and branch.
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_SHA,
providerVersionBranch: process.env.GIT_BRANCH,
}).verifyProvider();
} finally {
server.close();
}
}
verify().catch(() => process.exit(1));
A pact-js provider verification run in CI against a provider booted in test mode. It uses in-memory state handlers, mainBranch and deployedOrReleased selectors, and pending and WIP pacts, and it publishes results with the provider commit SHA and branch.
Gating releases with can-i-deploy and handling breaking changes
Every deploy job, for consumers and providers alike, starts with pact-broker can-i-deploy --pacticipant <app> --version $GIT_SHA --to-environment production. The Broker checks whether this version has a successful verification against every integration partner version currently recorded in that environment. If the check passes and the deploy succeeds, the job runs record-deployment with the same version and environment. Use record-release instead for artifacts like mobile apps, where several versions stay live at once.
These records are the matrix that can-i-deploy queries. If one team never records deployments, the Broker can't see what is actually running, so the question it answers changes. Instead of "is this compatible with production?" it answers "is this compatible with whatever was last recorded?" Treat recording as mandatory. Also, because the check is a hard gate, an unreachable Broker blocks every deploy. Run the Broker with the same availability you expect from your CI system.
Walking through a breaking change
Suppose a provider renames customer_name to customerName. The provider's verification fails on its own branch build, because the production consumer's pact still expects the old field. Even if someone merges anyway, can-i-deploy refuses the provider's deploy because no passing verification exists against the deployed consumer. The failure stays in CI and never reaches production.
There are two safe orders of operations:
- Consumer adapts first. The consumer accepts both fields, publishes a new pact, and deploys. The provider can then ship the rename.
- Provider expands, then contracts. The provider returns both fields and deploys. Consumers migrate on their own schedule. The provider removes the old field once no deployed consumer's pact references it, which
can-i-deployconfirms.
#!/usr/bin/env bash
# deploy.sh - same gate for consumers and providers.
# Usage: ./deploy.sh <pacticipant> <environment>
set -euo pipefail
APP="${1:?pacticipant name required}" # e.g. order-service
ENV="${2:?environment required}" # e.g. production
VERSION="${GIT_SHA:?GIT_SHA must be set}"
# The CLI reads the Broker location and credentials from these variables.
export PACT_BROKER_BASE_URL=""
: "${PACT_BROKER_TOKEN:?inject PACT_BROKER_TOKEN from CI secrets}"
# 1. Gate: does this version have a passing verification against every
# partner version currently recorded in $ENV? A non-zero exit stops the
# job here. That includes an unreachable Broker, which fails closed.
# Retries wait briefly in case the provider's verification is still running.
pact-broker can-i-deploy \
--pacticipant "$APP" \
--version "$VERSION" \
--to-environment "$ENV" \
--retry-while-unknown 6 \
--retry-interval 10
# 2. The actual deploy (placeholder for your tooling).
./scripts/rollout.sh "$APP" "$VERSION" "$ENV"
# 3. Record what is now running, but only after the deploy succeeded.
# Skipping this step leaves the Broker's view of $ENV stale, and every
# later can-i-deploy is checked against the wrong versions.
# For mobile apps or other multi-version artifacts, use record-release.
pact-broker record-deployment \
--pacticipant "$APP" \
--version "$VERSION" \
--environment "$ENV"
A deploy script for consumers and providers. It runs can-i-deploy as a fail-closed gate, deploys, and then records the deployment so the Broker's view of the environment matches what is actually running.
When to use Pact, when to avoid it, and the alternatives
Pact fits best when you can name every consumer of an API and those consumers sit in repositories you can run tests in. That usually means internal microservice API testing over request/response HTTP or asynchronous messages. The contract carries information a schema cannot: which fields each consumer reads, and which provider states it depends on.
Avoid it for public APIs. Unknown consumers cannot publish pacts, so the provider would be verifying against a small sample of its real users. Also avoid it if your teams cannot tag versions consistently and record every deployment in the broker. can-i-deploy answers from that data, so missing or wrong records produce confident wrong answers.
Pact also leaves gaps by design. A consumer test covers only the interactions that consumer wrote. It does not cover:
- endpoints that no consumer exercised
- error paths, unless a consumer explicitly modelled them
- message ordering
- idempotency on retries
- side effects such as database writes or downstream calls
Because of these gaps, keep a thin layer of integration tests for real infrastructure behaviour. Add a few end-to-end tests for critical user journeys.
| Approach | Catches | Misses | Cost |
|---|---|---|---|
| Pact | Field- and state-level drift per consumer | Unexercised paths, ordering, side effects | Broker plus discipline in every repo |
| OpenAPI / provider-driven schema validation | Provider deviating from its published spec | Whether any consumer's actual usage still works | Low; one spec |
| Shared integration environment | Wiring and configuration issues | Isolation; failures are hard to attribute | Contention and environment drift |
| End-to-end suite | Full user journeys | Speed; precise fault location | Slow and flaky at scale |
Impact on development cost and engineering time
Pact is not free, and most of the cost falls on provider teams.
Where the time goes
- The broker is infrastructure. Whether self-hosted or hosted, someone has to own its uptime, authentication, backups, and network reachability from every pipeline. If the broker is unreachable,
can-i-deploycannot answer and releases stall. - Verification adds build time. Every provider build replays each selected pact against a running instance.
- Provider test mode can be hard to build. A service with many downstream dependencies needs a way to boot with those dependencies stubbed. In some codebases this is the largest single task.
- State handlers need upkeep. They encode test-data setup, so they must change whenever the schema or domain rules change.
- Rollout is slow. The broker shows gaps until both sides of each relationship participate, so early
can-i-deployresults protect little.
Where it pays back
A break shows up in the provider's own CI, tied to a named consumer and interaction, instead of surfacing in shared staging or production. Teams rely less on a shared staging environment. Nobody spends an afternoon working out which version runs where, because the broker records it.
Illustrative estimate (assumed numbers, not a benchmark): net hours per month = (breaks caught × hours to diagnose each in staging) − (state-handler hours + broker operations hours) With 4 breaks × 6 h = 24 h, minus (6 h + 4 h), the net saving is 14 h per month.
Rollout order
- Choose one consumer-provider pair with a history of integration breaks.
- Publish pacts and run provider verification as a non-blocking step.
- Record deployments, then make
can-i-deployblocking for that pair. - Add further pairs one at a time.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to pact contract testing, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.