Pact Consumer-Driven Contract Testing in Node.js: Complete Guide

Pact Consumer-Driven Contract Testing in Node.js: Complete Guide

Consumer-driven contract testing solves one of the trickiest problems in distributed systems: how do you know that changes to a provider API won't silently break every consumer that depends on it? Schema validation tells you the shape of a response. Contract testing tells you whether the response still satisfies what each consumer actually needs.

This guide walks through the complete Pact workflow for Node.js — from writing your first consumer test to running provider verification in CI.

What Consumer-Driven Contracts Are (and Aren't)

A contract is a document that describes the interactions a consumer expects from a provider. It captures the exact HTTP requests the consumer will make and the minimum response shape it needs to function correctly.

The key word is consumer-driven. The consumer team writes tests that describe what they need. Those tests generate a contract file. The provider team then verifies their implementation satisfies every contract from every consumer.

This is fundamentally different from schema validation (like OpenAPI/JSON Schema):

  • Schema validation checks that a response matches a documented format. It's provider-centric — someone writes the spec, everyone conforms to it.
  • Contract testing checks that a provider satisfies what consumers actually use. It's consumer-centric — consumers declare their needs, providers prove they can meet them.

Schema validation catches format drift. Contract testing catches breaking changes — removing a field that one consumer uses even though the schema still "passes".

Installing @pact-foundation/pact

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

You'll also want a test runner. Pact works with Jest, Mocha, and most others. This guide uses Jest.

npm install --save-dev jest

For a realistic example, imagine two services: an order-service (consumer) that calls a product-service (provider) to fetch product details.

Writing Consumer Tests

Consumer tests define what interactions the consumer expects. They run a local mock server that records those interactions into a pact file.

Create src/__tests__/product-consumer.test.js:

const { Pact } = require('@pact-foundation/pact');
const { like, term } = require('@pact-foundation/pact').Matchers;
const path = require('path');
const { fetchProduct } = require('../product-client');

const provider = new Pact({
  consumer: 'order-service',
  provider: 'product-service',
  port: 4000,
  log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
});

describe('Product Service Client', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
  afterEach(() => provider.verify());

  describe('GET /products/:id', () => {
    beforeEach(() => {
      return provider.addInteraction({
        state: 'a product with ID 42 exists',
        uponReceiving: 'a request for product 42',
        withRequest: {
          method: 'GET',
          path: '/products/42',
          headers: {
            Accept: 'application/json',
          },
        },
        willRespondWith: {
          status: 200,
          headers: {
            'Content-Type': term({
              generate: 'application/json; charset=utf-8',
              matcher: 'application/json.*',
            }),
          },
          body: {
            id: like(42),
            name: like('Widget Pro'),
            price: like(29.99),
            inStock: like(true),
          },
        },
      });
    });

    it('returns product details', async () => {
      const product = await fetchProduct(42);
      expect(product.id).toBe(42);
      expect(product.name).toBe('Widget Pro');
      expect(product.price).toBe(29.99);
    });
  });

  describe('GET /products/:id — not found', () => {
    beforeEach(() => {
      return provider.addInteraction({
        state: 'no product with ID 999 exists',
        uponReceiving: 'a request for a non-existent product',
        withRequest: {
          method: 'GET',
          path: '/products/999',
          headers: {
            Accept: 'application/json',
          },
        },
        willRespondWith: {
          status: 404,
          body: {
            error: like('Product not found'),
          },
        },
      });
    });

    it('throws a not-found error', async () => {
      await expect(fetchProduct(999)).rejects.toThrow('Product not found');
    });
  });
});

The like() matcher means "the value must exist and be the same type, but the exact value doesn't matter during provider verification". This prevents brittle tests that break just because test data changes.

The term() matcher uses a regex — useful for content-type headers where the charset suffix varies.

The product-client Module

// src/product-client.js
const axios = require('axios');

const BASE_URL = process.env.PRODUCT_SERVICE_URL || 'http://localhost:3001';

async function fetchProduct(id) {
  const response = await axios.get(`${BASE_URL}/products/${id}`, {
    headers: { Accept: 'application/json' },
  });
  return response.data;
}

module.exports = { fetchProduct };

During consumer tests, PRODUCT_SERVICE_URL points at the Pact mock server (port 4000). In production, it points at the real service.

Running the Consumer Tests

Add to package.json:

{
  "scripts": {
    "test:pact": "jest --testPathPattern='__tests__/.*consumer.*'",
    "pact:publish": "node scripts/publish-pacts.js"
  }
}

Run with:

npm run test:pact

After the tests pass, Pact writes a file to pacts/order-service-product-service.json. This is the contract. It looks like:

{
  "consumer": { "name": "order-service" },
  "provider": { "name": "product-service" },
  "interactions": [
    {
      "description": "a request for product 42",
      "providerState": "a product with ID 42 exists",
      "request": {
        "method": "GET",
        "path": "/products/42",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "body": {
          "id": 42,
          "name": "Widget Pro",
          "price": 29.99,
          "inStock": true
        }
      }
    }
  ],
  "metadata": {
    "pactSpecification": { "version": "2.0.0" }
  }
}

Publishing to PactFlow or Pact Broker

The pact file needs to be published so the provider can retrieve it. Create scripts/publish-pacts.js:

const { Publisher } = require('@pact-foundation/pact');
const path = require('path');

const publisher = new Publisher({
  pactBroker: process.env.PACT_BROKER_BASE_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  pactFilesOrDirs: [path.resolve(__dirname, '..', 'pacts')],
  consumerVersion: process.env.GITHUB_SHA || 'local',
  branch: process.env.GITHUB_REF_NAME || 'main',
  tags: [process.env.GITHUB_REF_NAME || 'main'],
});

publisher.publishPacts()
  .then(() => console.log('Pacts published successfully'))
  .catch(err => {
    console.error('Failed to publish pacts:', err);
    process.exit(1);
  });

Set these environment variables in your CI:

  • PACT_BROKER_BASE_URL — your broker URL (e.g., https://your-org.pactflow.io)
  • PACT_BROKER_TOKEN — read/write token from PactFlow settings

Provider Verification

On the provider side (product-service), create src/__tests__/product-provider.test.js:

const { Verifier } = require('@pact-foundation/pact');
const path = require('path');
const app = require('../app'); // your Express/Fastify app

describe('Product Service Provider Verification', () => {
  let server;

  beforeAll((done) => {
    server = app.listen(3001, done);
  });

  afterAll((done) => server.close(done));

  it('satisfies all consumer contracts', () => {
    return new Verifier({
      provider: 'product-service',
      providerBaseUrl: 'http://localhost:3001',
      pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      publishVerificationResult: true,
      providerVersion: process.env.GITHUB_SHA || 'local',
      providerVersionBranch: process.env.GITHUB_REF_NAME || 'main',
      stateHandlers: {
        'a product with ID 42 exists': async () => {
          // Seed test data — use in-memory store or test DB
          await seedProduct({ id: 42, name: 'Widget Pro', price: 29.99, inStock: true });
        },
        'no product with ID 999 exists': async () => {
          await clearProduct(999);
        },
      },
    }).verifyProvider();
  });
});

Provider states map the strings defined in consumer tests to setup/teardown functions. They run before each interaction is replayed against the real provider.

Can-I-Deploy Checks

Before deploying to production, check that all consumer/provider pairs are mutually verified:

npx pact-broker can-i-deploy \
  --pacticipant product-service \
  --version $GITHUB_SHA \
  --to-environment production \
  --broker-base-url $PACT_BROKER_BASE_URL \
  --broker-token $PACT_BROKER_TOKEN

This command queries the broker and exits non-zero if any consumer that is currently in production has an unverified or failing pact against this provider version.

Complete CI Workflow

# .github/workflows/pact.yml
name: Pact Contract Tests

on:
  push:
    branches: [main, 'feature/**']
  pull_request:

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: order-service
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run consumer pact tests
        run: npm run test:pact
      - name: Publish pacts
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GITHUB_SHA: ${{ github.sha }}
          GITHUB_REF_NAME: ${{ github.ref_name }}
        run: npm run pact:publish

  provider-verification:
    runs-on: ubuntu-latest
    needs: consumer-tests
    defaults:
      run:
        working-directory: product-service
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Verify provider against published pacts
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GITHUB_SHA: ${{ github.sha }}
          GITHUB_REF_NAME: ${{ github.ref_name }}
        run: npm run test:pact:provider
      - name: Can-I-Deploy check
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant product-service \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url $PACT_BROKER_BASE_URL \
            --broker-token $PACT_BROKER_TOKEN

Matchers Reference

Pact provides several matchers beyond like() and term():

const { Matchers } = require('@pact-foundation/pact');
const { like, term, eachLike, integer, decimal, boolean, string, timestamp } = Matchers;

// eachLike — array with at least one element matching the shape
body: {
  products: eachLike({
    id: integer(1),
    name: string('Widget'),
    price: decimal(9.99),
  }),
}

// timestamp — validates ISO 8601 format
body: {
  createdAt: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", '2024-01-15T10:30:00.000Z'),
}

Common Pitfalls

Don't test provider business logic in consumer tests. The consumer test only cares about the shape and status of the response. If the provider adds a discount field, that doesn't break the consumer — so don't assert on fields you don't use.

Use provider states for every interaction. An interaction without a state is fragile — it relies on whatever happens to be in the database. Always declare the state you need and implement a handler.

Version everything. Pacts are matched by consumer version + provider version. Tag your versions with branch names so the broker can track which combinations have been verified across environments.

Run provider verification against the latest pacts from all branches. The consumerVersionSelectors option in Verifier lets you pull pacts from multiple branches:

consumerVersionSelectors: [
  { mainBranch: true },
  { deployedOrReleased: true },
  { branch: 'feature/new-checkout' },
],

What to Test Next

With consumer and provider tests running in CI, you have a safety net for HTTP API contracts. The natural next step is extending this to async messaging — Kafka topics and SQS queues follow different patterns but use the same Pact broker infrastructure.

Contract testing doesn't replace integration tests or E2E tests. It catches a specific class of failure — API contract drift between teams — faster and cheaper than any end-to-end approach can.

Read more

Start now free