Pact Testing Tutorial: Consumer-Driven Contract Testing in 30 Minutes

Pact Testing Tutorial: Consumer-Driven Contract Testing in 30 Minutes

Microservices break in integration. A provider team updates an API response format, the consumer team doesn't know for two weeks, and then production falls over. The standard answer — integration tests against a shared staging environment — is slow, flaky, and doesn't scale.

Pact takes a different approach: each consumer defines exactly what it needs from each provider, that definition becomes a contract, and the provider verifies the contract against its own test suite. No shared environment needed. Teams stay independent.

This tutorial gets you from zero to a working Pact setup in about 30 minutes.

What Is Consumer-Driven Contract Testing?

The "consumer-driven" part is key. In traditional API testing, the provider defines what the API does and consumers adapt. With Pact:

  1. The consumer writes a test describing what it expects from the provider
  2. Running that test generates a pact file (a JSON contract)
  3. The provider runs that pact file against its actual implementation to verify it can fulfill the consumer's expectations

If the provider changes in a way that breaks an existing pact, the verification step fails. The provider team knows immediately, before deployment.

Core Concepts

  • Pact: A JSON file describing interactions — requests the consumer sends and responses it expects
  • Consumer test: A unit test where the consumer defines interactions and makes real HTTP calls against a mock provider
  • Provider verification: The provider replays each interaction in the pact against its real implementation
  • Pact Broker: A service (like PactFlow) that stores pacts and verification results
  • can-i-deploy: A CLI check that asks "does a verified pact exist for this consumer-provider pair at these versions?"

Setup

Install pact-js:

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

The @pact-foundation/pact package works for both consumer tests (mock server) and provider verification.

Writing the Consumer Test

Suppose you have an OrdersClient that fetches orders from an Orders API:

// src/clients/OrdersClient.js
import axios from 'axios';

export class OrdersClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async getOrder(orderId) {
    const response = await axios.get(`${this.baseUrl}/orders/${orderId}`);
    return response.data;
  }

  async getOrdersByUser(userId) {
    const response = await axios.get(`${this.baseUrl}/orders?userId=${userId}`);
    return response.data;
  }
}

The consumer test uses Pact's mock provider:

// src/clients/OrdersClient.pact.test.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { OrdersClient } from './OrdersClient';

const { like, eachLike, string, integer, iso8601DateTimeWithMillis } = MatchersV3;

const provider = new PactV3({
  consumer: 'frontend',
  provider: 'orders-api',
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
});

describe('OrdersClient', () => {
  describe('getOrder', () => {
    it('returns an order by ID', () => {
      // Define what the consumer expects
      provider
        .given('order 42 exists')
        .uponReceiving('a request for order 42')
        .withRequest({
          method: 'GET',
          path: '/orders/42',
        })
        .willRespondWith({
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: {
            id: integer(42),
            userId: integer(7),
            status: string('processing'),
            total: like(99.99),
            createdAt: iso8601DateTimeWithMillis(),
          },
        });

      return provider.executeTest(async (mockProvider) => {
        const client = new OrdersClient(mockProvider.url);
        const order = await client.getOrder(42);

        expect(order.id).toBe(42);
        expect(order.status).toBe('processing');
        expect(typeof order.total).toBe('number');
      });
    });

    it('returns 404 when order does not exist', () => {
      provider
        .given('order 999 does not exist')
        .uponReceiving('a request for non-existent order 999')
        .withRequest({
          method: 'GET',
          path: '/orders/999',
        })
        .willRespondWith({
          status: 404,
          body: { error: like('Order not found') },
        });

      return provider.executeTest(async (mockProvider) => {
        const client = new OrdersClient(mockProvider.url);
        await expect(client.getOrder(999)).rejects.toThrow();
      });
    });
  });

  describe('getOrdersByUser', () => {
    it('returns orders for a user', () => {
      provider
        .given('user 7 has 2 orders')
        .uponReceiving('a request for orders by user 7')
        .withRequest({
          method: 'GET',
          path: '/orders',
          query: { userId: '7' },
        })
        .willRespondWith({
          status: 200,
          body: eachLike({
            id: integer(42),
            status: string('processing'),
          }),
        });

      return provider.executeTest(async (mockProvider) => {
        const client = new OrdersClient(mockProvider.url);
        const orders = await client.getOrdersByUser(7);

        expect(Array.isArray(orders)).toBe(true);
        expect(orders.length).toBeGreaterThan(0);
      });
    });
  });
});

Running this test:

npx jest OrdersClient.pact.test.js

If all expectations are met, Pact writes a pact file to pacts/frontend-orders-api.json.

Understanding Matchers

Pact matchers let you define flexible expectations rather than exact values:

import { MatchersV3 } from '@pact-foundation/pact';

const { like, eachLike, string, integer, boolean, decimal, regex, 
        iso8601Date, iso8601DateTimeWithMillis } = MatchersV3;

// like() — match the type, not the exact value
like(42)         // any integer
like('hello')    // any string
like(true)       // any boolean

// Specific type matchers
integer(42)      // any integer, example value 42
decimal(3.14)    // any decimal, example value 3.14
string('hello')  // any string, example value 'hello'

// For arrays — at least one element with this shape
eachLike({ id: integer(1), name: string('item') })

// Regex
regex('\\d{4}-\\d{2}-\\d{2}', '2024-01-15')

// ISO 8601 date/time
iso8601Date()
iso8601DateTimeWithMillis()

Use like() when you care about the structure, not the specific value. Use exact values only when the consumer literally needs that exact string or number.

The Generated Pact File

After running the consumer tests, inspect pacts/frontend-orders-api.json:

{
  "consumer": { "name": "frontend" },
  "provider": { "name": "orders-api" },
  "interactions": [
    {
      "description": "a request for order 42",
      "providerStates": [{ "name": "order 42 exists" }],
      "request": {
        "method": "GET",
        "path": "/orders/42"
      },
      "response": {
        "status": 200,
        "body": {
          "id": 42,
          "status": "processing",
          "total": 99.99
        },
        "matchingRules": { "$.id": { "combine": "AND", "matchers": [{"match": "integer"}] } }
      }
    }
  ]
}

This file is the contract. Share it with the provider team, or publish it to PactFlow.

Provider Verification

On the provider side (the Orders API), verify the pact:

// tests/pact/provider.pact.test.js
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { app } from '../../src/app'; // your Express/Fastify app

describe('Pact provider verification', () => {
  it('validates the pact with frontend', () => {
    const verifier = new Verifier({
      provider: 'orders-api',
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: [
        path.resolve(process.cwd(), '../frontend/pacts/frontend-orders-api.json'),
      ],
      // Set up provider states before each interaction
      stateHandlers: {
        'order 42 exists': async () => {
          await db.orders.insert({ id: 42, userId: 7, status: 'processing', total: 99.99 });
        },
        'order 999 does not exist': async () => {
          await db.orders.delete({ id: 999 });
        },
        'user 7 has 2 orders': async () => {
          await db.orders.insert([
            { id: 42, userId: 7, status: 'processing' },
            { id: 43, userId: 7, status: 'completed' },
          ]);
        },
      },
      logLevel: 'warn',
    });

    let server;
    beforeAll(() => { server = app.listen(3001); });
    afterAll(() => server.close());

    return verifier.verifyProvider();
  });
});

Run provider verification:

npx jest provider.pact.test.js

If verification passes, the provider can confidently update its code knowing it won't break this consumer.

A pact broker stores pacts and verification results centrally. With PactFlow (the hosted SaaS option):

Consumer publishes after tests:

const publisher = new Publisher({
  pactFilesOrDirs: ['./pacts'],
  pactBroker: 'https://your-org.pactflow.io',
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  consumerVersion: process.env.GIT_COMMIT,
  tags: ['main'],
});
await publisher.publish();

Provider reads from broker:

const verifier = new Verifier({
  provider: 'orders-api',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: 'https://your-org.pactflow.io',
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  publishVerificationResult: true,
  providerVersion: process.env.GIT_COMMIT,
});

can-i-deploy check in CI:

pact-broker can-i-deploy \
  --pacticipant orders-api \
  --version $GIT_COMMIT \
  --to-environment production

This check fails if any consumer has an unverified pact for this version, preventing deployment of breaking changes.

Key Takeaways

  1. Consumer writes first: the consumer defines what it needs, not the provider
  2. Pact file is the artifact: share it with providers via filesystem or a broker
  3. Provider state handlers are essential: set up your database before each interaction
  4. can-i-deploy is the safety net: gate deployments on verified pacts
  5. Matchers beat exact values: use like() and type matchers to avoid brittle tests

Pact is most valuable in organizations with multiple teams — when the consumer and provider teams are different people who don't sit next to each other. In a single-team monorepo, integration tests may be simpler. As soon as you have service boundaries across team boundaries, contract testing pays for itself.

Read more