Pact JS Consumer-Driven Contract Flow

When a frontend or service consumes an API it does not own the deployment of, hand-written stubs silently rot: the provider ships a field rename, your stub keeps returning the old shape, and the bug only appears in production. This guide walks frontend and full-stack engineers through the consumer half of contract testing using @pact-foundation/pact v12 with Vitest as the primary runner (Jest notes inline). You will drive your real HTTP client against the Pact mock server, generate a pact file, and publish that versioned contract to the Pact Broker so a provider can later verify it.

Root Cause Analysis

The failure consumer-driven contracts prevent is expectation drift. Two independent fakes — the consumer’s stub and the provider’s own tests — can both pass while disagreeing about the actual wire format. The consumer assumes total is a number; the provider starts returning it as a string. Neither test suite catches it because neither one shares an artifact with the other.

Pact removes the second fake. Instead of asserting against a stub you wrote, the consumer test asserts against the Pact mock server, and Pact records exactly what your client sent and what shape it expected back. That recording — the pact file — becomes the single shared expectation. The provider later replays it against real handlers, so the same document gates both sides. The symptom you are eliminating is “it worked in CI but broke on integration”; the cause is that CI never tested the two sides against one agreed contract.

The reason this is “consumer-driven” rather than schema-first is deliberate. A full provider schema describes everything the API can return, but a given consumer only depends on a slice of it. Pact records exactly that slice — the operations this client calls and the fields it reads — so the contract captures real usage instead of the whole surface. When the provider later verifies, it only has to honor what consumers actually need, which means it can freely add fields, change endpoints no one calls, and evolve internals without breaking anyone. The contract is the intersection of what is offered and what is used, and it is generated as a byproduct of an ordinary test rather than written by hand.

The diagram below shows where the pact file comes from in the consumer half of the flow.

Pact consumer flowThe consumer test drives the real client against the Pact mock server, which records a pact file that is then published to the broker.Consumer testreal HTTP clientPact mock serverrecords interactionspact file (JSON)matchingRulesPact Brokerversioned + taggedpublish runs only after the consumer test passes

Reproducible Setup

Install Pact as a dev dependency on the consumer. No global binary is needed for authoring or running consumer tests.

npm install --save-dev @pact-foundation/pact vitest

Give your client a configurable base URL so the test can point it at the Pact mock server’s ephemeral port:

// src/clients/order-client.ts
export interface Order {
  id: number;
  total: number;
  status: string;
}

export async function getOrder(baseUrl: string, id: number): Promise<Order> {
  const res = await fetch(`${baseUrl}/orders/${id}`);
  if (!res.ok) throw new Error(`order fetch failed: ${res.status}`);
  return res.json() as Promise<Order>;
}

Implementation

Step 1: Construct the Pact provider mock. Name both participants and choose where pact files land.

// 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 pact = new PactV3({
  consumer: 'web-storefront',
  provider: 'order-service',
  dir: path.resolve(process.cwd(), 'pacts'),
});

Step 2: Declare the interaction with matchers, not literals. Matchers assert type and shape so the contract survives legitimate data changes.

describe('order-client contract', () => {
  it('fetches a confirmed order by id', async () => {
    pact
      .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 pact.executeTest(async (mockServer) => {
      const order = await getOrder(mockServer.url, 42);
      expect(order.id).toBe(42);
      expect(order.status).toBe('CONFIRMED');
    });
  });
});

Step 2a: Use the right matcher for each field. The matcher you choose decides how strict the contract is, and getting this wrong is the most common cause of a brittle or a too-loose pact. Pin types and structure, never literal values, except where the value is genuinely part of the contract (an enum, a fixed header):

import { MatchersV3 } from '@pact-foundation/pact';
const { integer, decimal, string, boolean, eachLike, iso8601DateTime, regex } = MatchersV3;

const orderBody = {
  id: integer(42),
  total: decimal(99.5),
  status: regex('CONFIRMED|PENDING|CANCELLED', 'CONFIRMED'), // enum: value matters
  createdAt: iso8601DateTime('2026-06-21T10:00:00.000Z'),
  paid: boolean(true),
  lines: eachLike({ sku: string('SKU-1'), qty: integer(2) }, { min: 1 }), // at least one line
};

eachLike is the one to reach for with arrays: it asserts “an array whose elements all look like this,” with a min so the provider cannot satisfy the contract by returning an empty list when the consumer needs at least one element. A bare literal array would instead demand that exact array, which no realistic provider state can reliably reproduce.

Step 2b: Declare multiple interactions, including failure paths. A contract that only covers the happy path leaves the consumer’s error handling unverified against the real provider. Add a 404 interaction so the not-found branch is part of the contract:

it('handles a missing order', async () => {
  pact
    .given('no order with id 999 exists')
    .uponReceiving('a request for a missing order')
    .withRequest({ method: 'GET', path: '/orders/999' })
    .willRespondWith({
      status: 404,
      headers: { 'Content-Type': 'application/json' },
      body: { error: MatchersV3.string('order not found') },
    });

  await pact.executeTest(async (mockServer) => {
    await expect(getOrder(mockServer.url, 999)).rejects.toThrow('order fetch failed: 404');
  });
});

Each given(...) state becomes a fixture the provider must be able to set up during verification, so keep state strings descriptive and stable — the provider matches them as literal text.

Step 3: Run the test. On success, Pact writes pacts/web-storefront-order-service.json. Each interaction the client actually performed inside executeTest is recorded; interactions you declare but never call cause the test to fail, which keeps the contract honest.

npx vitest run src/clients/order-client.pact.test.ts

For Jest, the test body is identical — only the test runner import (@jest/globals or globals) and config differ. PactV3 is runner-agnostic because it spins up its own mock HTTP server rather than patching globals.

Step 4: Publish the contract. Tag it with the commit SHA and branch so the provider’s version selectors can resolve it.

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

In a typical pipeline this runs only after the consumer test job passes, so you never publish a contract that the consumer itself could not satisfy. Tagging by branch matters because the provider’s verification selectors filter on it: a contract published from a feature branch can be verified separately from the one on main, which lets a consumer propose a breaking change and see whether the provider can already honor it before either side merges.

Step 5: Wire it into CI as a gated job. Keep authoring and publishing as distinct steps so a failed test never produces a published artifact:

# .github/workflows/consumer-contract.yml
name: Consumer Contract
on: { push: { branches: ['**'] } }
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run src/clients/order-client.pact.test.ts
      - name: Publish pact
        if: success()
        run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version=${{ github.sha }} \
            --branch=${{ github.ref_name }} \
            --broker-base-url=$PACT_BROKER_BASE_URL \
            --broker-token=$PACT_BROKER_TOKEN
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

This isolates contract authoring from your unit suite while still letting both run in the same Vitest project, mirroring how a deliberate test pyramid strategy separates concerns by intent rather than by tool.

Verification

A passing run prints the generated pact path. Confirm the contract is well-formed by reading the JSON — the matchingRules block should reference type-based rules for id, total, and status rather than baking in 99.5:

{
  "interactions": [
    {
      "description": "a request for order 42",
      "request": { "method": "GET", "path": "/orders/42" },
      "response": {
        "status": 200,
        "matchingRules": {
          "body": { "$.total": { "matchers": [{ "match": "decimal" }] } }
        }
      }
    }
  ]
}

After publishing, the broker UI lists web-storefront against order-service with your branch tag and an “unverified” status until a provider runs. That unverified state is expected — it is the provider’s job to flip it, as covered in verifying provider contracts in CI with Pact.

Three properties are worth asserting before you trust a pact. First, the matching rules are type-based, not value-based — grep the JSON for "match": "type", "decimal", "integer" and confirm no field bakes in a literal you did not intend to fix. Second, every interaction was exercised — the interaction count in the file should equal the number of executeTest calls that fired a request; a missing interaction means a declared expectation never ran. Third, the request path and method are correct — a typo in path produces a contract the provider can satisfy trivially while your real client calls a different route, so the pact passes verification yet protects nothing. A quick sanity check is to diff the recorded request against the URL your client logs at debug level.

Troubleshooting

The test passes but no pact file appears. You declared the interaction but never invoked the client inside executeTest, or the assertion block threw before the request fired. Pact only writes interactions that were actually exercised. Confirm getOrder is called and awaited inside the executeTest callback.

ECONNREFUSED against the mock server. Your client is hitting a hard-coded base URL instead of mockServer.url. Pact assigns a random free port per run, so the URL must be injected at call time, not read from an env constant.

A global interceptor swallows the request. If the project also starts an MSW server or an axios/fetch interceptor in setupFiles, it may intercept the call before it reaches Pact’s mock server. Isolate contract tests in a separate Vitest project or skip interception for 127.0.0.1 ports.

The contract is too strict and breaks on every provider data change. You used literal example values where a matcher belonged. Swap id: 42 for MatchersV3.integer(42) and any timestamp for iso8601DateTime so the provider can return real data without tripping an exact-value comparison. Reserve literals for genuine constants like enum members or a fixed Content-Type.

eachLike matched an empty array in verification. You omitted the min option, so an empty list satisfied the contract even though the consumer iterates over elements. Pass { min: 1 } (or higher) to require the provider state to seed at least that many items, matching what your consumer actually depends on.

Parallel test files overwrite the same pact file. Two PactV3 instances with the same consumer/provider pair writing to the same dir can race. Either keep all interactions for one provider pair in a single test file, or rely on Pact’s merge behavior by giving each its own describe while writing to the same directory — but never spread one provider’s interactions across files that run in parallel without coordination.

The published version collides with an earlier run. Reusing a consumer-app-version overwrites the previous contract for that version in the broker. Always derive the version from the commit SHA (optionally suffixed with the branch) so each build produces a uniquely addressable contract that the provider’s selectors can resolve.

FAQ

Does this work with Jest as well as Vitest?

Yes — the consumer DSL is runner-agnostic because PactV3 boots its own local HTTP mock server rather than monkey-patching the runtime. The test body, matchers, and executeTest flow are identical; only your runner config and the test-globals import differ. Most teams run Pact consumer tests in the same Vitest project as their other unit tests.

Why use MatchersV3 instead of plain example values?

Plain values create an exact-match contract, so any legitimate change to the provider’s data — a different id, a new timestamp — fails verification even though the shape is correct. MatchersV3 (integer, decimal, string, eachLike, iso8601DateTime) pins the type and structure while leaving the actual value free, which is what makes a contract resilient rather than brittle.

Where does the pact file go, and should I commit it?

It is written to the dir you pass to PactV3 (conventionally ./pacts). Do not commit it — treat it as a build artifact and publish it to the Pact Broker instead, which versions it by app version and branch. Committing pacts leads to stale contracts that diverge from what your current consumer actually requests.

How is this different from mocking with MSW?

MSW gives the consumer a fast, deterministic fake, but nothing verifies that fake matches the real provider. Pact records the consumer’s expectation into a shared artifact that the provider must satisfy, closing the drift gap. Many teams use both: MSW for broad component tests and Pact for the specific boundaries they need a provider to honor.