Contract Testing with Pact JS
Contract testing closes the gap that pure mocking leaves open: a hand-written stub can drift from the real provider without anyone noticing until production. By capturing the exact request/response expectations a consumer relies on and replaying them against the provider, contract testing turns integration assumptions into executable, version-controlled artifacts. This discipline sits inside Advanced Mocking & Service Isolation Patterns but solves a problem ordinary stubbing cannot — it verifies both sides of a boundary against the same shared expectation rather than trusting two independent fakes to stay aligned. The focus here is Pact JS, consumer-driven contracts, and the Pact Broker as the source of truth that ties consumer expectations to provider verification across your CI pipelines.
Architectural Scope & Boundaries
Contract testing occupies a precise position in the test stack, and applying it outside that position wastes effort or creates false confidence. It validates the integration boundary between two independently deployable units — typically an HTTP or message consumer and the provider it depends on. It does not test business logic inside either service, and it is not a replacement for end-to-end testing of a full user journey.
The boundary works in three layers:
- Consumer side. A unit-tier test runs the consumer’s real HTTP client against a Pact mock server. Pact records every interaction the consumer actually performs and writes them to a pact file (a JSON document of request/response expectations).
- Broker. The pact file is published to a Pact Broker, which versions it, tags it by branch and environment, and tracks which application versions have verified against which.
- Provider side. The provider replays each recorded interaction against its real handlers, with no knowledge of the consumer’s internals, and reports pass/fail back to the broker.
What contract testing deliberately excludes: it does not assert that the provider’s data is correct (only that the response shape and status match the contract), it does not exercise the network or auth infrastructure, and it does not cover flows that span three or more services. Those belong to higher tiers. Pair contract tests with external service simulation for the third-party APIs you do not own and cannot run a provider verification against — Pact owns the boundaries you control on both sides; simulation owns the rest.
Prerequisites
- Vitest 1+/2+ as the primary runner (Jest also supported)
@pact-foundation/pactv12+ installed as a dev dependency on the consumerPACT_BROKER_BASE_URLandPACT_BROKER_TOKENavailable as CI secrets
Step-by-Step Implementation
Step 1: Install Pact on the consumer
npm install --save-dev @pact-foundation/pact
This package ships the consumer DSL, the native mock server, and the verifier used later on the provider. No global binary is required for the consumer side.
Step 2: Write a consumer contract test
Drive your real client against the Pact mock server. Pact intercepts the call, matches it, and records the interaction.
// src/clients/order-client.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { getOrder } from './order-client';
const provider = new PactV3({
consumer: 'web-storefront',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('order-client contract', () => {
it('fetches an order by id', async () => {
provider
.given('an order with id 42 exists')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42' })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: MatchersV3.integer(42),
total: MatchersV3.decimal(99.5),
status: MatchersV3.string('CONFIRMED'),
},
});
await provider.executeTest(async (mockServer) => {
const order = await getOrder(mockServer.url, 42);
expect(order.id).toBe(42);
});
});
});
Step 3: Generate and publish the pact file
executeTest writes a pact JSON file into the pacts/ directory on success. Publish it to the broker with the CLI, tagging it with the version and branch so the provider can find the right contract.
npx pact-broker publish ./pacts \
--consumer-app-version=$GIT_COMMIT \
--branch=$GIT_BRANCH \
--broker-base-url=$PACT_BROKER_BASE_URL \
--broker-token=$PACT_BROKER_TOKEN
Step 4: Verify on the provider
The provider pulls the contract from the broker and replays every interaction against its real HTTP handlers.
// provider/verify.pact.test.ts
import { Verifier } from '@pact-foundation/pact';
await new Verifier({
provider: 'order-service',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
consumerVersionSelectors: [{ mainBranch: true }],
}).verifyProvider();
Step 5: Gate deployment with can-i-deploy
Before promoting either side, ask the broker whether the version you are about to ship is compatible with everything already in the target environment.
npx pact-broker can-i-deploy \
--pacticipant=web-storefront \
--version=$GIT_COMMIT \
--to-environment=production \
--broker-base-url=$PACT_BROKER_BASE_URL \
--broker-token=$PACT_BROKER_TOKEN
Configuration Reference Table
| Option | Where | Type | Default | Effect |
|---|---|---|---|---|
consumer |
new PactV3() |
string | — | Names the consuming application in the contract |
provider |
new PactV3() |
string | — | Names the provider the contract targets |
dir |
new PactV3() |
string | ./pacts |
Output directory for generated pact files |
given(state) |
interaction | string | none | Declares a provider state set up before verification |
consumerVersionSelectors |
Verifier |
object[] | latest | Selects which consumer contracts the provider verifies |
publishVerificationResult |
Verifier |
boolean | false |
Posts pass/fail back to the broker |
providerStatesSetupUrl |
Verifier |
string | none | Endpoint the verifier calls to seed provider state |
--to-environment |
can-i-deploy |
string | — | Environment whose deployed versions are checked for compatibility |
enablePending |
Verifier |
boolean | false |
Lets new contracts fail without breaking the provider build |
Verification & Assertions
A passing consumer test prints the generated pact path and exits clean; the meaningful artifact is the JSON under pacts/. Inspect it to confirm the interaction was recorded with the matchers you expect rather than literal example values — a contract pinned to the literal 99.5 rather than MatchersV3.decimal() is brittle and will reject valid provider responses.
On the provider, a successful verification logs each interaction with a green check and posts the result to the broker. The decisive gate is can-i-deploy, which returns a non-zero exit code and a compatibility matrix when any required verification is missing or failing. Treat that exit code as the merge/deploy gate rather than relying on a human reading logs.
Edge Cases & Failure Modes
Provider state not seeded. The consumer declares given('an order with id 42 exists'), but the provider has no matching state handler, so the real endpoint returns 404. Fix by wiring providerStatesSetupUrl (or the in-process state handlers) to insert the fixture row before each interaction replays.
Over-specified contracts. Pinning exact strings, timestamps, or array lengths makes the contract fail on legitimate provider changes. Use MatchersV3 (integer, decimal, iso8601DateTime, eachLike) so the contract asserts shape and type, not exact bytes.
Contract not found by the provider. A version-selector mismatch means the provider verifies the wrong (or no) contract and silently passes. Pin consumerVersionSelectors to mainBranch plus deployed environments, and enable enablePending so newly published contracts surface without breaking the build.
Matching the wrong runner globals. Pact’s mock server is a real local HTTP server, so it can collide with a global fetch/axios interceptor. Run contract tests in a file or project that does not start MSW, or scope the interceptor to skip localhost ports.
Performance & CI Impact
Consumer contract tests are fast — they run in the unit tier against an in-process mock server with no real network — so they belong in the same quick CI job as your other Vitest suites. Provider verification is heavier: it boots the provider and replays every interaction, which is closer to an integration-tier cost. Run it as a separate job that can be cached against the broker, and trigger provider re-verification automatically with broker webhooks so a new consumer contract does not wait for the next provider commit. Because the broker decouples the two pipelines, neither side blocks the other at author time; the can-i-deploy gate is the only place the two timelines must agree, which keeps overall pipeline latency low while preserving cross-service safety. For teams weighing where this fits against broader layer budgets, align it with your test strategy and pyramid design so contract verification supplements rather than duplicates E2E coverage.
In-Depth Guides
- Pact JS consumer-driven contract flow — write a consumer test with Vitest or Jest, generate the pact file, and publish it to the broker.
- Verifying provider contracts in CI with Pact — run
pact:verify, gate releases withcan-i-deploy, and trigger verification from broker webhooks. - Cypress vs Playwright for contract testing — decide where browser E2E ends and contract testing begins, with a side-by-side comparison.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- External Service Simulation — simulate third-party APIs you cannot run a provider verification against.
- Using MSW to mock GraphQL endpoints locally — pair shape-checked mocking with consumer-driven contracts.
- Test Strategy & Pyramid Design — place contract testing correctly in the overall layer budget.
Cypress vs Playwright for Contract Testing
Decide where browser E2E ends and contract testing begins, and compare how Cypress and Playwright sit alongside Pact JS in a layered JavaScript test strategy.
Pact JS Consumer-Driven Contract Flow
Write a Pact JS consumer test with Vitest or Jest, generate a pact file from your real HTTP client, and publish the versioned contract to the Pact Broker.
Verifying Provider Contracts in CI with Pact
Run Pact provider verification in CI: replay consumer contracts against real handlers, gate releases with can-i-deploy, and trigger verification via webhooks.