Contract Testing for Microservices Architectures
A fleet of fifty services has roughly a thousand possible interface pairings, and standing them all up in one environment to catch a single renamed field is both slow and unreliable. Contract testing replaces that combinatorial integration problem with per-edge verification: each consumer records what it needs, each provider proves it still delivers it, and a broker decides whether any given release is safe. This guide extends API Contract Fundamentals & Tool Selection and focuses on the part that only gets hard at scale — running verification across many services, gating deployments with can-i-deploy, and managing version matrices without ever booting a full integration environment.
We cover two verification modes. The consumer-driven contracts with Pact model replays recorded expectations against a live provider. Bi-directional contract testing instead compares a consumer contract against the provider’s own OpenAPI document statically. Both feed the same broker and the same deployment gate, so you can mix them per edge.
When to Use This Approach
Contract testing earns its keep when integration cost grows faster than your team’s patience. Reach for it when:
- You run more than a handful of independently deployed services and a shared integration environment has become a flaky bottleneck.
- Teams deploy on separate cadences and need a programmatic answer to “is it safe to release right now?” rather than a release-train calendar.
- You want to catch interface mismatches in CI, before staging, instead of debugging a 500 in a multi-service environment.
- Your services span synchronous REST or gRPC and asynchronous events, and you need one governance model across both transports.
- You are already detecting interface drift and want to formalize it; pair this with breaking change detection so spec-level diffs and contract verification reinforce each other.
It is overkill for a monolith, a single team owning every service, or two services that always deploy together — there the coupling is cheap to test directly.
Prerequisites
You need a broker, a contract library, and a CLI for the deployment gate. Pin versions so examples stay reproducible.
| Component | Version | Purpose |
|---|---|---|
Pact JS (@pact-foundation/pact) |
13.x | Generate consumer pacts and verify providers |
| Pact Broker | 2.x (Docker pactfoundation/pact-broker) |
Store contracts, results, deployment matrix |
Pact CLI (@pact-foundation/pact-broker) |
12.x | publish, can-i-deploy, record-deployment |
| Node.js | 20 LTS | Runtime for the test harness |
| AsyncAPI CLI | 3.x | Validate event channel schemas |
Install the toolchain in each service repository:
# In every consumer and provider repo
npm install --save-dev @pact-foundation/pact@13 @pact-foundation/pact-broker@12
# Once, on the host that runs the broker
docker pull pactfoundation/pact-broker:latest
docker pull postgres:16-alpine
Step 1: Map Service Dependencies and Choose a Verification Mode
Before writing a single test, draw the graph. Every directed edge — “web-frontend calls payment-api” — is a contract you will own and verify. The map tells you how many contracts exist, who owns each, and which verification mode fits.
Choose per edge:
- Consumer-driven when the provider is yours, runs cleanly in CI, and you want the consumer’s real expectations to drive coverage. It catches semantic gaps a static spec misses.
- Bi-directional when the provider is hard to boot (a legacy service, a third party that publishes OpenAPI, or a team that maintains a spec but not a verifiable test harness). Read bi-directional contract testing explained for the trade-offs.
Express the dependency map as data so CI can reason about it:
# contracts/dependency-map.yaml
# One entry per consumer -> provider edge in the fleet.
edges:
- consumer: web-frontend
provider: payment-api
mode: consumer-driven # provider boots in CI
- consumer: order-service
provider: payment-api
mode: consumer-driven
- consumer: order-service
provider: inventory-svc
mode: consumer-driven
- consumer: mobile-app
provider: inventory-svc
mode: bi-directional # verified against provider OpenAPI, no boot
This file is the source of truth for which jobs run where. It keeps the verification fleet auditable as services come and go.
Step 2: Deploy a Centralized Contract Broker
The broker is the only shared, stateful component. It stores published contracts, verification results, and — critically — the deployment matrix that records which version of each app is live in each environment. That matrix is what makes deployment gating possible without an integration environment.
# docker-compose.yml
services:
pact-broker:
image: pactfoundation/pact-broker:latest
ports: ['9292:9292']
environment:
PACT_BROKER_DATABASE_URL: postgres://pactuser:password@db:5432/pactbroker
# Token auth in production; basic auth shown for local clarity.
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: secure_token
# Reject publishes that omit the branch so version selectors work.
PACT_BROKER_WEBHOOK_RETRY_SCHEDULE: '10 60 120 300 600 1200'
depends_on: [db]
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: pactuser
POSTGRES_PASSWORD: password
POSTGRES_DB: pactbroker
Register a webhook so a new consumer contract automatically triggers the relevant provider’s verification build — this is how contracts propagate across the fleet without humans chasing teams:
curl -X POST "$PACT_BROKER_URL/webhooks" \
-H "Content-Type: application/json" \
-u admin:secure_token \
--data '{
"events": [{"name": "contract_content_changed"}],
"request": {
"method": "POST",
"url": "https://ci.example.com/api/trigger/${pactbroker.providerName}",
"headers": {"Content-Type": "application/json"}
}
}'
The contract_content_changed event fires only when the contract differs from the previous version, which avoids re-verifying providers on every unrelated consumer commit.
Step 3: Generate and Publish Consumer Contracts
For consumer-driven edges, the consumer’s tests define the contract. Use matchers, never literal values, for any field the provider generates — exact values produce brittle pacts that break on unrelated provider changes.
// consumer.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, integer } = MatchersV3;
const provider = new PactV3({
consumer: 'web-frontend',
provider: 'payment-api',
dir: './pacts',
});
it('fetches a payment', () => {
return provider
.given('user has a valid payment method') // provider state name
.uponReceiving('a GET for a payment')
.withRequest({ method: 'GET', path: '/v1/payments/pay_123' })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: { id: like('pay_123'), amount: integer(1000) }, // shape, not value
})
.executeTest(async (mock) => {
const res = await fetch(`${mock.url}/v1/payments/pay_123`);
expect(res.status).toBe(200);
});
});
Publish the generated pact tagged with version and branch. The --branch flag is what later powers mainBranch and deployedOrReleased version selectors:
npx pact-broker publish ./pacts \
--consumer-app-version "$GITHUB_SHA" \
--branch "$GITHUB_REF_NAME" \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
For bi-directional edges, publish the consumer’s recorded contract alongside the provider’s OpenAPI document; the broker compares them rather than booting the provider. The mechanics differ but the published artifact lands in the same broker and gates the same way.
Step 4: Verify Providers Against Published Contracts
A provider pulls every relevant consumer contract and replays it against a running instance. Version selectors decide which contracts are relevant — verifying every contract that ever existed is wasteful and noisy. Use mainBranch plus deployedOrReleased so you cover what is on the main line and what is actually live somewhere.
// provider.pact.test.ts
import { Verifier } from '@pact-foundation/pact';
it('verifies payment-api against consumer contracts', () => {
return new Verifier({
provider: 'payment-api',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_URL!,
pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
providerVersion: process.env.GITHUB_SHA,
providerVersionBranch: process.env.GITHUB_REF_NAME,
publishVerificationResult: true, // write result into the matrix
consumerVersionSelectors: [
{ mainBranch: true }, // latest from each consumer's main
{ deployedOrReleased: true }, // anything currently live
],
}).verifyProvider();
});
Run it in CI against a state-reset provider instance. The provider must implement a state handler for each given() string the consumer declared, or verification reports Interaction not found.
# .github/workflows/verify-contracts.yml
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Start provider with clean state
run: npm run start:test &
- name: Wait for health
run: npx wait-on http://localhost:8080/health
- name: Verify contracts
run: npm run test:pact:provider
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_NAME: ${{ github.ref_name }}
Step 5: Gate Deployments with can-i-deploy
This is the step that replaces a full integration environment. can-i-deploy asks the broker: for the version I am about to ship into a given environment, does a successful verification result exist against every version already deployed there? It returns exit code 0 only when every required contract is green for that exact version pair.
# Run in the deploy job, before promotion to the target environment.
npx pact-broker can-i-deploy \
--pacticipant payment-api \
--version "$GITHUB_SHA" \
--to-environment production \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN" \
--retry-while-unknown 12 \
--retry-interval 10
--retry-while-unknown waits for an in-flight verification to finish instead of failing immediately — useful when a consumer and provider race to deploy. A non-zero exit blocks the release. Because the broker already knows every consumer-provider pair from the recorded contracts and the deployment matrix, this single check substitutes for booting the whole fleet together.
Step 6: Record Deployments to Keep the Matrix Accurate
can-i-deploy is only as correct as the matrix it reads. After every successful deploy, tell the broker what is now live so the next check reflects reality:
# Run immediately after a successful production deploy.
npx pact-broker record-deployment \
--pacticipant payment-api \
--version "$GITHUB_SHA" \
--environment production \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
For environments where multiple versions coexist (canary, blue-green), use record-release instead, which keeps prior versions marked as available until you explicitly mark them deprecated. The matrix then captures the full set of live versions a new release must be compatible with.
Covering Asynchronous and Event-Driven Edges
Services that talk over Kafka or RabbitMQ need the same gate, applied to messages. A message pact validates the payload shape and metadata a consumer expects; the provider verifies it produces a conforming message. Combine this with AsyncAPI for event-driven systems to govern channel naming and envelope structure.
// async-message.pact.test.ts
import { MessageConsumerPact, MatchersV3 } from '@pact-foundation/pact';
const { like } = MatchersV3;
const messagePact = new MessageConsumerPact({
consumer: 'order-service',
provider: 'inventory-svc',
dir: './pacts',
});
it('handles an order_created event', () => {
return messagePact
.expectsToReceive('an order_created event')
.withContent({ orderId: like('ord_99'), status: like('PENDING') })
.withMetadata({ contentType: 'application/json' })
.verify(async (message) => {
const payload = JSON.parse(message.contents as string);
expect(payload.orderId).toBeDefined();
});
});
asyncapi validate ./asyncapi.yaml
asyncapi lint ./asyncapi.yaml
Message pacts publish to the same broker and flow through the same can-i-deploy gate, so event-driven and request-driven edges share one deployment decision.
Spec/Schema Reference
| Option | Type | Default | Effect |
|---|---|---|---|
consumerVersionSelectors.mainBranch |
boolean | false |
Verify the latest contract from each consumer’s main branch |
consumerVersionSelectors.deployedOrReleased |
boolean | false |
Verify contracts for consumer versions currently live in any environment |
consumerVersionSelectors.matchingBranch |
boolean | false |
Verify the consumer contract from a branch matching the provider’s branch |
publishVerificationResult |
boolean | false |
Write the verification outcome into the broker matrix |
--to-environment (can-i-deploy) |
string | none | Target environment whose live versions define the compatibility check |
--retry-while-unknown |
integer | 0 |
Number of polls to wait for an unknown (in-flight) verification |
--branch (publish) |
string | none | Tags the contract version; enables branch-based selectors and the matrix |
record-deployment --environment |
string | none | Marks a version as the sole live one in an environment (replaces prior) |
record-release --environment |
string | none | Marks a version as available without retiring prior versions |
Verification
A healthy pipeline produces three distinct, observable signals.
Publishing succeeds and reports the version and branch:
Pact successfully published for web-frontend version 9f3c1a2 and provider payment-api.
View the published pact at https://broker.example.com/...
Events detected: contract_content_changed (notifying provider payment-api)
Provider verification reports each interaction pass and writes results back:
Verifying a pact between web-frontend and payment-api
a GET for a payment
returns a response which
has status code 200 (OK)
has a matching body (OK)
1 interaction, 0 failures
Verification results published.
The deployment gate gives an explicit verdict:
Computer says yes \o/
All required verification results are published and successful.
payment-api 9f3c1a2 can be deployed to production.
If any of these is missing — no contract_content_changed, no published verification, or a Computer says no — the release is correctly blocked.
Troubleshooting
Interaction not found / no matching provider state
The consumer declared a given('user has a valid payment method') state with no matching handler on the provider. Provider state strings are matched verbatim. Inspect the .pact JSON, then register a handler with the exact string in the provider’s stateHandlers map and reset data inside it so each interaction runs against a known fixture.
can-i-deploy returns “unknown” instead of yes or no
A verification result for the requested version pair has not been published yet — usually a race where the provider build is still running. Add --retry-while-unknown 12 --retry-interval 10 so the gate waits, and confirm the provider job runs publishVerificationResult: true. If it never resolves, the verification job failed silently; check that the provider build actually started via the webhook.
Broker returns 401 during publish or verification
The CI token is missing, expired, or read-only. Rotate the broker token, update the PACT_BROKER_TOKEN secret, and confirm the token has write scope — publish and publishVerificationResult both require write access, not just read.
can-i-deploy passes but production still breaks
The deployment matrix is stale because a deploy step skipped record-deployment. The gate compared against versions the broker believes are live, not what truly is. Make record-deployment (or record-release) a mandatory, non-skippable post-deploy step, and reconcile the matrix against your orchestrator’s live inventory.
Async message verification fails with a deserialization error
Schema drift between producer and consumer — the producer added or renamed a field the consumer’s contract does not expect. Set additionalProperties: false in the AsyncAPI schema to surface drift loudly, then make the change additive on the producer or update the consumer contract before the producer removes anything.
Frequently Asked Questions
What is the difference between consumer-driven and bi-directional contract testing?
Consumer-driven contract testing replays recorded consumer expectations against a running provider, so it needs the provider deployed and stateful. Bi-directional contract testing compares a consumer-recorded contract against the provider’s own OpenAPI spec statically, so no provider deployment is required during verification.
How does can-i-deploy decide whether a release is safe?
can-i-deploy queries the broker for every contract between the application version you want to deploy and the versions already deployed in the target environment. It returns exit code 0 only when every required pact has a successful verification result for that exact pair of versions.
Do I still need a full integration environment if I use contract testing?
No. Contract testing verifies each consumer-provider pair in isolation against recorded expectations, so you avoid the combinatorial cost of standing up every service together. You keep a thin smoke-test environment for end-to-end sanity, not for catching interface mismatches.
How do version selectors and the deployment matrix relate?
Consumer version selectors tell the provider which consumer contracts to verify (for example mainBranch and deployedOrReleased). The deployment matrix is the broker’s record of which version of each app is in each environment, and can-i-deploy reads that matrix to choose the relevant verification results.
Can contract testing cover asynchronous and event-driven services?
Yes. Message pacts validate the payload and metadata a consumer expects from a queue or topic, and the provider verifies it produces a matching message. Pair this with AsyncAPI schema validation to cover channel naming and envelope structure.
What happens when a provider needs to make a breaking change?
Verification fails for the affected consumer versions, and can-i-deploy blocks the provider release. The provider coordinates by deploying an additive change first, waiting for consumers to migrate, then removing the old field once no deployed consumer version still depends on it.