Consumer-Driven Contract Testing with Pact and TypeScript: A Practical Guide

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Context & Why: Eliminating API Integration Friction

Microservices give engineering teams autonomy and speed, but integration across service boundaries often turns into a minefield. Frontend apps break without warning when backend engineers rename userId to id, change a response status from 200 OK to 204 No Content, or drop deprecated fields during sprint releases.

Teams usually fall back on two imperfect testing strategies:

  • Mock-heavy unit tests: Fast and practically free to run. However, static frontend mocks drift out of sync with real APIs within days because nothing validates them against live service contracts.
  • End-to-End (E2E) staging suites: Slow, fragile, and expensive to maintain. When an E2E test fails in staging, engineers can easily spend 3 to 4 hours tracing race conditions and bad test fixtures across multiple downstream services.

Consumer-Driven Contract Testing (CDCT) resolves this conflict. Instead of guessing API schemas or waiting for staging environments, the consumer (such as a React or Node.js client) codifies the exact request patterns and response shapes it expects. It saves these expectations into a contract file (a Pact). The backend provider then pulls the contract and independently verifies its real endpoints against those rules.

When we implemented this across 12 microservices, our staging integration incidents dropped by 80%. We stopped holding risky synchronized release calls and scaled up to 5+ independent production deployments every day.

Installation: Setting Up Pact in a TypeScript Project

The Pact Foundation maintains native TypeScript bindings through @pact-foundation/pact. Let’s configure a consumer test suite with TypeScript and Jest.

Initialize your workspace and install the required dependencies:

npm init -y
npm install --save-dev typescript jest ts-jest @types/jest @pact-foundation/pact axios
npx ts-jest config:init

Add a dedicated test command to your package.json:

{
  "scripts": {
    "test:pact": "jest --testMatch='**/*.pact.test.ts' --runInBand"
  }
}

Why --runInBand? Pact boots a lightweight mock HTTP server for each test suite. Running tests sequentially prevents port collisions and race conditions on localhost.

Configuration: Defining Consumer Expectations and Generating Pacts

Contract testing begins on the client side. Your test exercises the actual API client against a mock server orchestrated by Pact. Once every assertion passes, Pact serializes the interaction into a JSON contract file.

1. The Consumer API Client

Below is a typed HTTP client that retrieves user details from a backend service:

import axios from 'axios';

export interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

export class UserApiClient {
  constructor(private baseUrl: string) {}

  async getUser(id: string): Promise<User> {
    const response = await axios.get<User>(`${this.baseUrl}/users/${id}`);
    return response.data;
  }
}

2. Writing Flexible Consumer Assertions

Avoid strict, hardcoded value checks. If you assert on an exact string like 'John Doe', the backend test breaks whenever mock database fixtures change. Use Pact V3 matchers instead—they validate data types, formats, and regex patterns so harmless data variations pass without false alarms.

import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserApiClient } from './userApiClient';

const { like, email, regex } = MatchersV3;

const provider = new PactV3({
  consumer: 'FrontendWebApp',
  provider: 'UserService',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('UserApiClient Contract Tests', () => {
  it('successfully retrieves an existing user profile', async () => {
    provider
      .given('a user with ID 101 exists')
      .uponReceiving('a GET request for user 101')
      .withRequest({
        method: 'GET',
        path: '/users/101',
      })
      .willRespondWith({
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: {
          id: regex('^\\d+$', '101'),
          name: like('John Doe'),
          email: email('[email protected]'),
          role: like('ADMIN'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const client = new UserApiClient(mockServer.url);
      const user = await client.getUser('101');

      expect(user.id).toBe('101');
      expect(user.name).toBe('John Doe');
      expect(user.role).toBe('ADMIN');
    });
  });
});

Running npm run test:pact compiles your suite and writes pacts/FrontendWebApp-UserService.json to disk.

Verification & Monitoring: Provider Verification and CI/CD Automation

A contract is only useful if the provider continuously validates it. In the backend repository, we configure a verification suite that replays recorded consumer interactions directly against live route handlers.

1. Provider Verification Script

This test boots the backend server and instructs Pact to replay all contract payloads against it:

import path from 'path';
import { Verifier } from '@pact-foundation/pact';
import { startServer, stopServer } from '../src/server';

describe('Pact Provider Verification', () => {
  const PORT = 8080;
  let server: any;

  beforeAll(async () => {
    server = await startServer(PORT);
  });

  afterAll(async () => {
    await stopServer(server);
  });

  it('validates requests against consumer contracts', async () => {
    const verifier = new Verifier({
      provider: 'UserService',
      providerBaseUrl: `http://localhost:${PORT}`,
      pactUrls: [
        path.resolve(process.cwd(), '../frontend/pacts/FrontendWebApp-UserService.json'),
      ],
      stateHandlers: {
        'a user with ID 101 exists': async () => {
          // Seed ephemeral test database record
          return Promise.resolve('Database seeded with user 101');
        },
      },
    });

    const output = await verifier.verifyProvider();
    console.log('Pact Verification Complete:', output);
  });
});

2. Automating Contract Checks with Pact Broker and CI/CD

Passing JSON files manually through pull requests or shared storage quickly falls apart. A dedicated Pact Broker (or hosted PactFlow instance) acts as the centralized contract registry and state tracker.

A resilient automated pipeline runs in three distinct phases:

  1. Frontend CI: Runs consumer tests, generates contracts, and publishes them to the broker tagged with the git commit SHA and branch name.
  2. Backend CI: Fetches published consumer contracts from the broker, validates them against the target backend build, and pushes verification results back to the registry.
  3. Deployment Guard (can-i-deploy): Queries the broker before any production deploy to guarantee full version compatibility between consumer and provider.
# Verify whether FrontendWebApp is safe to ship to production
npx pact-broker can-i-deploy \
  --pacticipant FrontendWebApp \
  --version $GIT_COMMIT_SHA \
  --to-environment production \
  --broker-base-url https://your-broker.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN

3. Field-Tested Tips for Production Teams

  • Assert only on consumed fields: If the frontend only renders name and email, exclude created_at and internal metadata from the contract. This allows backend teams to refactor internal data without triggering contract breaks.
  • Isolate provider states dynamically: Use stateHandlers to inject temporary fixtures into the test database right before each interaction runs, rather than relying on brittle, pre-seeded static databases.
  • Trigger verification with webhooks: Configure Pact Broker webhooks so that when a frontend PR publishes a modified contract, it automatically triggers a provider verification build in the backend repository.
Share: