Contract Testing with Pact: Complete Tutorial for JavaScript Teams

Contract Testing with Pact: Complete Tutorial for JavaScript Teams

Pact is a consumer-driven contract testing library that lets you define what a consumer (e.g., a frontend app or microservice) expects from a provider (e.g., an API), generate a contract file from those expectations, and verify the provider actually fulfills them — without spinning up both services at the same time. This tutorial covers the full workflow: consumer test → pact file → provider verification.

Key Takeaways

Pact tests the contract, not the implementation. Pact doesn't test that your API works end-to-end. It tests that the provider's responses match what the consumer expects. The consumer owns the contract.

The consumer writes the contract. The consumer test defines the request shape and expected response. This flips the usual "API owners decide the shape" dynamic — consumers say what they need.

Pact mocks the provider during consumer tests. Consumer tests don't need the real provider running. Pact spins up a mock server based on the defined interactions, and the consumer runs against that.

Provider verification replays consumer interactions against the real provider. The generated pact file is sent to the provider's CI, which replays each interaction against the actual running service and verifies the responses match.

Pact Broker is optional but strongly recommended for teams. The Broker hosts pact files and tracks which provider versions are compatible with which consumer versions. Without it, you're passing pact files around manually.

What Contract Testing Solves

In a microservices architecture, Service A calls Service B's API. Service B changes an API response shape. Service A now breaks. Nobody noticed until E2E tests ran — or worse, until production.

The traditional solutions — extensive integration test environments, contract documents, API versioning — are slow, expensive, and often ignored.

Pact solves this at the unit level: consumer tests that verify Service A's expectations about Service B's API are met, without Service B being deployed. When Service B is about to deploy, it verifies it still meets all consumer expectations. If it doesn't, the deploy fails before integration testing.

Installation

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

The Consumer Side: Writing a Pact Test

Imagine you have a frontend app (user-app) that calls a user API (user-service). The app makes a GET /users/1 request and expects a response with id, name, and email.

Consumer Test Setup

// user.pact.spec.js
const { PactV3, MatchersV3 } = require("@pact-foundation/pact");
const { like, regex } = MatchersV3;
const path = require("path");

// Create the Pact instance
const provider = new PactV3({
  consumer: "user-app",
  provider: "user-service",
  dir: path.resolve(process.cwd(), "pacts"),
  port: 8080,
});

Defining an Interaction

An interaction describes: given some state, when the consumer makes this request, the provider should respond with this.

describe("User Service - Consumer Tests", () => {
  describe("GET /users/:id", () => {
    it("returns a user by ID", async () => {
      // Define the interaction
      await provider
        .given("a user with ID 1 exists")
        .uponReceiving("a request to get user 1")
        .withRequest({
          method: "GET",
          path: "/users/1",
          headers: {
            Accept: "application/json",
          },
        })
        .willRespondWith({
          status: 200,
          headers: { "Content-Type": "application/json" },
          body: {
            id: like(1),              // any integer is acceptable
            name: like("Alice"),      // any string is acceptable
            email: regex(
              "\\w+@\\w+\\.\\w+",
              "alice@example.com"
            ),
          },
        });

      // Run the test
      await provider.executeTest(async (mockServer) => {
        // Point your actual client code at the mock server
        const client = new UserClient(mockServer.url);
        const user = await client.getUser(1);

        expect(user.id).toBeDefined();
        expect(user.name).toBeDefined();
        expect(user.email).toMatch(/\w+@\w+\.\w+/);
      });
    });
  });
});

The UserClient

Your actual API client code — the thing being tested:

// userClient.js
const axios = require("axios");

class UserClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async getUser(id) {
    const response = await axios.get(`${this.baseUrl}/users/${id}`, {
      headers: { Accept: "application/json" },
    });
    return response.data;
  }
}

module.exports = { UserClient };

The consumer test uses UserClient against the Pact mock server. When executeTest runs, Pact:

  1. Starts a mock server on port 8080
  2. Configures it to respond to GET /users/1 with the defined response
  3. Runs your test function against that mock
  4. Verifies the real client made the expected request
  5. Writes a pact file to the pacts/ directory

Running the Consumer Test

npx jest user.pact.spec.js

If the test passes, a pact file is generated:

pacts/user-app-user-service.json

This JSON file contains the interactions — it's the contract.

The Generated Pact File

{
  "consumer": { "name": "user-app" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request to get user 1",
      "providerState": "a user with ID 1 exists",
      "request": {
        "method": "GET",
        "path": "/users/1",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": {
          "id": 1,
          "name": "Alice",
          "email": "alice@example.com"
        },
        "matchingRules": {
          "body": {
            "$.id": { "matchers": [{ "match": "type" }] },
            "$.name": { "matchers": [{ "match": "type" }] },
            "$.email": { "matchers": [{ "match": "regex", "regex": "\\w+@\\w+\\.\\w+" }] }
          }
        }
      }
    }
  ]
}

This file goes to the provider — either via a Pact Broker or copied directly.

The Provider Side: Verifying the Contract

The provider (user-service) runs verification against the pact file as part of its CI build:

// provider.pact.spec.js
const { Verifier } = require("@pact-foundation/pact");
const path = require("path");

describe("Pact Verification - user-service", () => {
  it("validates the expectations of user-app", async () => {
    const opts = {
      provider: "user-service",
      providerBaseUrl: "http://localhost:3000", // running provider
      pactUrls: [
        path.resolve(process.cwd(), "../user-app/pacts/user-app-user-service.json"),
      ],
      // State handlers set up provider data for each "given" state
      stateHandlers: {
        "a user with ID 1 exists": async () => {
          // Seed the test database with user 1
          await db.users.upsert({ id: 1, name: "Alice", email: "alice@example.com" });
        },
      },
    };

    return new Verifier(opts).verifyProvider();
  });
});

The verifier:

  1. Reads the pact file
  2. For each interaction, runs the stateHandlers to set up provider data
  3. Replays the consumer request against the real provider
  4. Verifies the response matches the contract (using matching rules from the pact file)

If GET /users/1 returns { id: 1, name: "Alice", email: "alice@example.com" }, verification passes. If the provider changed the shape — dropped email, renamed name to fullName, changed id to a string — verification fails.

Pact Broker: Sharing Contracts at Scale

With multiple services and multiple pact files, sharing files manually doesn't scale. Pact Broker is a service that:

  • Hosts pact files by consumer/provider/version
  • Tracks which provider versions are compatible with each consumer version
  • Provides a UI for visualizing the contract network
  • Supports can-i-deploy checks: "is it safe to deploy user-service v2.1.0 if user-app v1.3.0 is in production?"

Publishing Pacts to the Broker

npx pact-broker publish ./pacts \
  --consumer-app-version="$(git rev-parse HEAD)" \
  --broker-base-url="https://your-broker.pactflow.io" \
  --broker-token="$PACT_BROKER_TOKEN"

Provider Verification Against the Broker

const opts = {
  provider: "user-service",
  providerBaseUrl: "http://localhost:3000",
  // Instead of local pact files, fetch from the broker
  pactBrokerUrl: "https://your-broker.pactflow.io",
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  publishVerificationResult: true,
  providerVersion: process.env.GIT_SHA,
};

Can-I-Deploy Check

Before deploying user-service to production:

npx pact-broker can-i-deploy \
  --pacticipant="user-service" \
  --version="$(git rev-parse HEAD)" \
  --to-environment="production" \
  --broker-base-url="https://your-broker.pactflow.io" \
  --broker-token="$PACT_BROKER_TOKEN"

Output:

Computer says yes ✔

CONSUMER    | C.VERSION | PROVIDER      | P.VERSION | SUCCESS?
------------|-----------|---------------|-----------|--------
user-app    | abc123    | user-service  | def456    | true

If verification failed for any consumer, can-i-deploy returns a non-zero exit code and blocks the deploy.

Pact in CI/CD

Consumer CI

# .github/workflows/consumer.yml
- name: Run Pact consumer tests
  run: npx jest --testPathPattern=pact

- name: Publish pacts
  run: |
    npx pact-broker publish ./pacts \
      --consumer-app-version=${{ github.sha }} \
      --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
      --broker-token=${{ secrets.PACT_BROKER_TOKEN }}

Provider CI

# .github/workflows/provider.yml
- name: Start provider
  run: npm start &

- name: Run Pact provider verification
  run: npx jest --testPathPattern=provider.pact
  env:
    PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
    PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
    GIT_SHA: ${{ github.sha }}

- name: Can-I-Deploy check
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant=user-service \
      --version=${{ github.sha }} \
      --to-environment=production \
      --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
      --broker-token=${{ secrets.PACT_BROKER_TOKEN }}

Common Mistakes

Matching too strictly. Using exact value matching ({ id: 1 }) instead of type matching (like(1)) makes contracts brittle — any change in test data breaks verification. Use matchers for everything that isn't a business rule.

Not using provider states. If your provider tests don't set up the data described in given(...), verification may pass accidentally (user not found → 404, which doesn't match the 200 contract) or fail for the wrong reason.

Skipping pact broker and passing files manually. Works for two services. Doesn't work for ten. Start with the broker.

Testing too much in the consumer test. The consumer test verifies the contract shape, not the business logic. Don't write a consumer test that covers every possible server response — write one per interaction shape that the consumer actually uses.

Read more

Start now free