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. Read this topic area as the practical bridge between the fast, isolated mocks you already write and the guarantee that those mocks still describe a service that genuinely exists.

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 value is concentrated at exactly one seam: the place where a request leaves one codebase and a response comes back from another, owned by a different team on a different release cadence.

The reason this seam matters more than most is that it is the one your unit tests cannot see. A consumer’s unit suite mocks the provider, so it passes whether or not the mock is faithful; the provider’s own suite exercises its handlers, so it passes whether or not any consumer actually calls them the way its tests assume. Each side is green, each side is wrong about the other, and the mismatch only surfaces when the two are deployed together. Contract testing removes that blind spot by making both suites agree on a single shared document rather than two private assumptions. The consumer writes the document by exercising its real client; the provider reads the same document and proves it can satisfy it.

The boundary works in three layers:

  1. 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). Crucially, the consumer only records interactions it genuinely makes, so the contract describes real usage rather than a wish list — an endpoint the consumer never calls never enters the contract, which keeps the provider free to change it.
  2. 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. The broker is the memory of the system: it knows that consumer version abc123 expects a certain shape and that provider version def456 has proven it can deliver that shape, and it exposes that knowledge as a compatibility matrix that a deployment gate can query.
  3. 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. Because the provider verifies against the actual recorded requests, a breaking change to a response field is caught the moment the provider’s verification runs, long before the two services are wired together in a shared environment.

What contract testing deliberately excludes is as important as what it covers. It does not assert that the provider’s data is correct — only that the response shape and status match the contract, so a provider returning a structurally valid but semantically wrong total still passes. It does not exercise the network, TLS, or auth infrastructure, because the consumer talks to an in-process mock and the provider is verified in isolation. And it does not cover flows that span three or more services, where the emergent behaviour of a chain matters more than any single hop. Those concerns 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. The practical rule of thumb is ownership: if you can run the provider’s verification suite in your own CI, use Pact; if you cannot, simulate it and accept that the fidelity of that simulation is now your responsibility to maintain.

Consumer-driven contract flow through the Pact Broker The consumer test generates a pact file and publishes it to the Pact Broker; the provider fetches that contract, verifies its real handlers, and posts the result back for the deployment gate to read. generates pact file verifies real handlers Consumer Pact mock server Pact Broker versioned contracts Provider pact:verify in CI publish fetch result can-i-deploy gate reads broker status
The consumer publishes a contract; the provider fetches, verifies, and reports; the broker holds the shared truth.

Prerequisites

Step-by-Step Implementation

The five steps below form one continuous loop: the consumer produces a contract, the broker stores it, the provider proves it, and a deployment gate reads the verdict. Each step is runnable on its own, but the payoff only appears when they run in sequence across two pipelines — so as you work through them, keep in mind which side of the boundary each command belongs to and which artifact it produces or consumes.

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. The mock server is a real, locally bound HTTP process rather than an in-JavaScript interceptor, which is what lets it exercise your genuine client — including its serialization, headers, and error handling — instead of a stubbed function. That fidelity is the whole point: the contract records what your actual client sends on the wire, so anything the client does implicitly (a default Accept header, a query-string encoding quirk) becomes part of the recorded expectation and is verified against the provider.

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. Treat this test like any other unit test in your suite — it should be fast, deterministic, and assertive about the shape of what comes back, not just that a call was made.

// 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);
    });
  });
});

The two design choices that make or break this test are the provider state and the matchers. The given('an order with id 42 exists') string is not decoration — it is a named precondition that the provider must be able to reproduce during verification, so keep it descriptive and stable because the provider side keys its fixture setup on that exact string. The MatchersV3 calls declare that the contract cares about type and shape, not literal values: integer(42) says “an integer will be here, and 42 is a valid example,” so a provider returning id: 43 still satisfies the contract. Writing the assertion inside executeTest against the value your client actually parsed proves the client-side deserialization works end to end, closing the loop between the wire format and the typed object your application consumes.

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

The --consumer-app-version should be the exact git SHA that produced the contract, not a human-friendly label, because the broker uses it as the primary key that links a specific build to a specific set of expectations. Tagging with --branch lets the provider express selectors like “verify the contract from every consumer’s main branch plus whatever is deployed to production,” which is how you keep verification focused on the versions that actually matter rather than every experimental branch anyone ever pushed. Publishing is idempotent per version: re-publishing the same SHA with an identical contract is a no-op, so it is safe to run on every CI build.

Step 4: Verify on the provider

The provider pulls the contract from the broker and replays every interaction against its real HTTP handlers. This runs in the provider’s own pipeline, on the provider team’s schedule, against a locally booted instance of the service.

// 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();

For each interaction, the verifier calls the provider’s real endpoint, compares the live response against the recorded matchers, and records a pass or fail. Setting publishVerificationResult: true posts that verdict back to the broker keyed by providerVersion, which is what populates the compatibility matrix the deployment gate later reads. The consumerVersionSelectors field is the single most important knob here: it decides which consumer contracts this provider run is responsible for. { mainBranch: true } verifies the tip of each consumer’s main branch, and you will almost always want to add deployed-environment selectors so the provider also proves compatibility with whatever consumers are live in production right now, not just what is on their main branch.

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. This is the command that converts a wall of green checkmarks into a single, scriptable yes-or-no answer.

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

can-i-deploy asks the broker a specific question: “for this exact version of web-storefront, has every provider it depends on verified this contract, and is each of those providers a version that is currently in production?” If the answer is yes it exits 0 and your deploy proceeds; if any required verification is missing or failing it exits non-zero and the deploy stops. This is what makes independent deployment safe — neither team has to coordinate a release window, because the gate mechanically refuses to ship a version that would break a partner already running in the target environment.

Sequence of messages across consumer, broker, and provider pipelines Over time the consumer publishes a pact to the broker, the provider pulls and verifies it and publishes the result, and finally the consumer asks the broker whether it can deploy. Consumer CI Pact Broker Provider CI 1. publish pact (SHA + branch) 2. fetch contract 3. verify handlers 4. publish result 5. can-i-deploy?
The two pipelines never call each other directly — every message flows through the broker, decoupling their release cadences.

Configuration Reference

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. Reading the generated JSON is a habit worth forming: it is the actual document the provider will be held to, and a five-second scan of the matchingRules block tells you whether you have described a shape or accidentally frozen a snapshot. If you see literal values where you intended matchers, the contract will pass on the consumer today and fail the provider tomorrow for no reason other than a different-but-valid example value.

On the provider, a successful verification logs each interaction with a green check and posts the result to the broker. Read the verifier output carefully when it fails, because Pact’s diff is precise about which part of the response diverged from the contract — a missing field, a type mismatch, or an unexpected status. A failure here is almost always a genuine signal: either the provider changed a response in a way a consumer depends on, or the consumer over-specified something the provider was never obliged to guarantee. Both are worth a conversation, and the diff tells you which team owns the fix.

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. The matrix it prints is also a useful diagnostic in its own right: each row pairs a consumer version with a provider version and shows whether that pairing has been verified, so a row marked as unverified points straight at the missing piece — usually a provider that has not yet run verification against a freshly published contract. Wiring the exit code into your pipeline as a hard step means the deploy simply cannot proceed on an unverified pairing, which is a far stronger guarantee than a checklist item asking someone to confirm compatibility by hand.

The can-i-deploy decision gate A can-i-deploy check asks whether every required provider has verified this version's contracts; a yes exits zero and the deploy proceeds, a no exits non-zero and the deploy is blocked. can-i-deploy --to-environment production All required verifications present and passing? yes no exit 0 — deploy compatible with the environment exit 1 — blocked missing or failing verification
can-i-deploy collapses the whole verification matrix into one exit code your pipeline can gate on.

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. The subtle trap here is coupling: the state string is a shared vocabulary between two teams, so renaming it on the consumer without updating the provider’s handler silently breaks verification. Keep the set of provider states small and well-named, and treat a new state as an interface change that both sides must acknowledge, not a private consumer detail.

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. The failure this prevents is insidious because it does not look like over-specification at author time — a literal "CONFIRMED" works perfectly until the provider adds a legitimate "PARTIALLY_SHIPPED" state, at which point the contract rejects a correct response. eachLike deserves particular attention for arrays: it verifies the shape of each element while allowing any length including zero, which is almost always what you want, whereas a hand-written two-element array quietly demands the provider always return exactly two.

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. A provider that verifies zero contracts reports success, which is the most dangerous kind of green — it looks like safety but proves nothing. Guard against it by asserting in CI that the verifier actually loaded at least one interaction, and by reviewing the broker’s matrix periodically to confirm every live consumer has a verified row rather than trusting the exit code alone.

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. Because the mock server binds a real port, any test-wide setup that intercepts outbound requests will swallow the traffic Pact is trying to observe, producing empty or malformed contracts. The cleanest fix is isolation: give contract tests their own Vitest project or config so no shared setup file installs a network interceptor, keeping the two mocking strategies from stepping on each other.

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. The per-test overhead of spinning up the native mock server is small and amortized across a file, so a suite of dozens of interactions still finishes in the same order of magnitude as ordinary unit tests. Because there is no shared environment and no cross-service coordination, these tests are also deterministic in a way integration tests rarely are: the same input always produces the same recorded contract, which keeps them out of the flaky category that plagues higher tiers.

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. The webhook pattern is what keeps the two timelines from stalling each other — the moment a consumer publishes a contract, the broker pings the provider’s pipeline to re-verify, so compatibility is established continuously rather than in a batch whenever the provider team next happens to build. Cache the provider’s boot and dependency installation aggressively, because the marginal cost of verifying one more interaction is trivial compared with the fixed cost of standing the service up.

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. This is the architectural payoff worth internalizing: contract testing trades a small, constant tax on every build — publish a contract, verify a contract, ask the gate — for the elimination of the slow, brittle, shared integration environment that would otherwise be the only place these mismatches surface. 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 practice, a healthy suite pushes the bulk of boundary confidence down into fast contract tests and reserves the expensive end-to-end tier for the genuinely emergent behaviours that only appear when the whole system runs together.

In This Topic Area