← All articles
TESTING Contract Testing with Pact: Consumer-Driven API Testing 2026-03-04 · 4 min read · pact · contract testing · microservices

Contract Testing with Pact: Consumer-Driven API Testing

Testing 2026-03-04 · 4 min read pact contract testing microservices testing api consumer-driven integration testing ci/cd

Integration testing between microservices is fragile and slow: you need all services running, test environments to stay in sync, and end-to-end tests that break for reasons unrelated to your changes. Contract testing is a targeted alternative: each consumer defines what it expects from a provider, and the provider verifies it can meet those expectations — independently, without the full stack.

The Core Concept

Consumer: A service that calls another service's API (e.g., a frontend calling a user service).

Provider: A service that exposes an API (e.g., the user service).

Contract (Pact): A file documenting exactly what the consumer expects from the provider — specific requests and the responses it needs.

The workflow:

  1. Consumer tests run, generating a Pact contract file
  2. Contract is shared with the provider (via Pact Broker or filesystem)
  3. Provider verification runs: Pact replays the recorded consumer requests against the real provider
  4. If the provider returns matching responses, the contract passes

This catches API breaking changes before deployment, without end-to-end tests.

Install

# Node.js (TypeScript)
npm install --save-dev @pact-foundation/pact

Consumer Test

The consumer test defines expected interactions and generates the Pact file:

// user-service.consumer.test.ts
import { Pact } from "@pact-foundation/pact";
import { like, eachLike } from "@pact-foundation/pact/src/dsl/matchers";
import path from "path";

const provider = new Pact({
  consumer: "frontend",
  provider: "user-service",
  port: 4000,
  dir: path.resolve(__dirname, "../pacts"),
  logLevel: "warn",
});

describe("User Service API contract", () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
  afterEach(() => provider.verify());

  describe("GET /users/:id", () => {
    beforeEach(() => {
      return provider.addInteraction({
        state: "user 123 exists",
        uponReceiving: "a request for user 123",
        withRequest: {
          method: "GET",
          path: "/users/123",
          headers: {
            Accept: "application/json",
          },
        },
        willRespondWith: {
          status: 200,
          headers: {
            "Content-Type": "application/json",
          },
          body: {
            id: like("123"),
            name: like("Alice Smith"),
            email: like("[email protected]"),
          },
        },
      });
    });

    it("fetches a user by ID", async () => {
      // Call your actual consumer code (not a stub)
      const response = await fetch("http://localhost:4000/users/123", {
        headers: { Accept: "application/json" },
      });
      const user = await response.json();

      expect(response.status).toBe(200);
      expect(user.id).toBe("123");
      expect(user.name).toBeDefined();
      expect(user.email).toBeDefined();
    });
  });
});

Running this test:

  1. Pact starts a mock server on port 4000
  2. Your consumer code runs against the mock
  3. Pact records the interaction
  4. A pacts/frontend-user-service.json contract file is generated

Matchers

Pact matchers define what counts as "matching" — exact value vs. type:

import {
  like,        // matches same type, any value
  term,        // regex match
  eachLike,    // array where each element matches the pattern
  integer,     // integer type
  decimal,     // decimal number
} from "@pact-foundation/pact/src/dsl/matchers";

body: {
  id: like("abc123"),           // any string
  count: integer(5),            // any integer
  price: decimal(9.99),         // any decimal
  status: term({
    generate: "active",
    matcher: "^(active|inactive|pending)$",
  }),
  tags: eachLike("javascript"), // array of strings
}

Use matchers generously — you want contracts to describe the shape of data, not hardcode values that will change.

Provider Verification

The provider side verifies it can satisfy the contract:

// user-service.provider.test.ts
import { Verifier } from "@pact-foundation/pact";
import path from "path";

describe("User Service provider verification", () => {
  it("validates the Pact contracts", async () => {
    const opts = {
      provider: "user-service",
      providerBaseUrl: "http://localhost:3001", // your provider running
      pactUrls: [
        path.resolve(__dirname, "../pacts/frontend-user-service.json"),
      ],
      providerStatesHandler: async (state: string) => {
        // Set up test state before each interaction
        switch (state) {
          case "user 123 exists":
            await db.users.upsert({
              id: "123",
              name: "Alice Smith",
              email: "[email protected]",
            });
            break;
        }
      },
    };

    const output = await new Verifier(opts).verifyProvider();
    console.log(output);
  });
});

The verifier:

  1. Reads the Pact file
  2. For each interaction, calls providerStatesHandler to set up data
  3. Replays the recorded request against the running provider
  4. Asserts the response matches the contract

Pact Broker

In a team environment, contracts need to be shared between consumer and provider pipelines. The Pact Broker is a central repository for Pact files:

# docker-compose.yml
services:
  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - 9292:9292
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:pact@pact-db/pact
    depends_on:
      - pact-db

  pact-db:
    image: postgres:15
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: pact
      POSTGRES_DB: pact

Publish contracts from consumer CI:

npx pact-broker publish ./pacts \
  --broker-base-url http://pact-broker:9292 \
  --consumer-app-version $(git rev-parse HEAD) \
  --branch main

Verify from provider CI:

const opts = {
  provider: "user-service",
  providerBaseUrl: "http://localhost:3001",
  pactBrokerUrl: "http://pact-broker:9292",
  publishVerificationResult: true,
  providerVersion: process.env.GIT_SHA,
  enablePending: true, // Don't fail on unverified pacts
};

Can I Deploy?

The killer feature: before deploying, check if the contract is verified:

npx pact-broker can-i-deploy \
  --pacticipant frontend \
  --version $(git rev-parse HEAD) \
  --to-environment production

This answers: "is every provider I depend on verified against my current contract?" If no, the deploy is blocked. This prevents deploying a frontend that calls an API endpoint the provider doesn't yet implement.

CI/CD Integration

# GitHub Actions: consumer pipeline
jobs:
  contract-tests:
    steps:
      - run: npm test -- --testPathPattern=consumer
      - run: |
          npx pact-broker publish ./pacts \
            --broker-base-url $PACT_BROKER_URL \
            --consumer-app-version $GITHUB_SHA \
            --branch $GITHUB_REF_NAME

# Provider pipeline
  verify-contracts:
    steps:
      - run: npm run start:test &  # start provider
      - run: npm test -- --testPathPattern=provider

When Pact Catches Bugs

A provider team renames user.email to user.emailAddress and deploys. Without contract testing, this silently breaks the consumer. With Pact:

  1. Provider verification fails: "consumer expects email field, provider returns emailAddress"
  2. The can-i-deploy check blocks the provider deployment
  3. Provider team is notified: "breaking change for frontend consumer"
  4. Fix: coordinate field name, or add backward-compatible alias

When to Use Contract Testing

Good fit:

Less useful:

Pact vs Integration Tests

Pact Integration Tests
Requires full stack No Yes
Speed Fast (unit test speed) Slow
Catches contract breaks Yes Yes
Catches logic bugs No Yes
Maintenance Moderate High

Pact doesn't replace integration tests — it replaces the subset of integration tests that verify API compatibility. Keep integration tests for business logic that spans services.

The Pact ecosystem (pact.io) supports JavaScript, Java, Python, Go, Ruby, .NET, and more. Once set up, can-i-deploy becomes a reliable deployment gate for service compatibility.