Skip to content
Ayhan Sipahi Ayhan Sipahi

Contract Testing with Pact - Ensuring API Compatibility in Microservices

A practical guide to consumer-driven contract testing with Pact in TypeScript microservices, catching breaking API changes before deployment.

Consumer-driven contract testing closes the gap between a passing unit test and a working integration. Each consumer declares what it needs from a provider API, the provider verifies it can deliver that, and a deployment gate blocks any release that would break a dependent service. For TypeScript microservices owned by separate teams, Pact behind a Pact Broker with a can-i-deploy gate is the default worth reaching for before growing the end-to-end suite again.

The value is narrow, and saying so up front avoids disappointment later. Contract testing checks service boundaries: request shape, response shape, status codes. Business workflows still need end-to-end tests, and business logic still needs unit tests. What contract testing removes is the class of failure where a consumer’s mocks and a provider’s actual responses drift apart without anyone noticing.

The API Compatibility Problem

Working with microservices introduces specific technical challenges that traditional testing strategies struggle to address effectively:

Breaking changes slip through: A provider team changes an API field from required to optional, breaking consumer applications that depend on it. Unit tests pass because mocks are updated, but production fails.

Integration test bottleneck: An end-to-end suite spanning several services can easily run for tens of minutes. That slows deployment cycles and creates merge conflicts while teams wait for results.

Mock drift: Consumer unit tests use mocked API responses that don’t match actual provider behavior. Tests pass, but the integration fails in production because the mock was never updated to reflect API changes.

Cross-team coordination overhead: Multiple consumer teams depend on the same provider API, but there’s no systematic way to communicate changes. Breaking changes are discovered during integration or, worse, in production.

Version compatibility tracking: Determining which consumer version is compatible with which provider version becomes a manual spreadsheet exercise that breaks down as services scale.

These problems compound as the number of services grows. Contract testing addresses them by establishing a clear, testable contract between services.

What Is Consumer-Driven Contract Testing?

Consumer-driven contract testing inverts the typical testing model. Instead of the provider defining what the API should do and testing against that definition, consumers define what they need from the API. The provider then verifies they can fulfill those requirements.

Here’s how the workflow operates:

Deployment GateProvider TeamPact BrokerConsumer TeamDeployment GateProvider TeamPact BrokerConsumer TeamWrite consumer testGenerate pact filePublish contractFetch contractsVerify against providerPublish verification resultsCheck can-i-deploySafe to deploy

The consumer test defines expected interactions with the API. From this test, Pact generates a contract file (JSON) that describes the request, expected response structure, and matching rules. The provider fetches this contract and verifies their implementation can satisfy it.

This approach differs from other testing strategies:

  • E2E tests: Test entire system integration, slow and brittle
  • Unit tests with mocks: Test in isolation, fast but subject to mock drift
  • Contract tests: Test service boundaries, fast and accurate for API compatibility

Contract tests are additive. They take API-compatibility coverage off the E2E suite, which lets that suite shrink to the journeys where a failure actually matters.

Setting Up Pact for TypeScript Projects

Install Pact as a development dependency:

npm install --save-dev @pact-foundation/pact
npm install --save-dev @pact-foundation/pact-cli
npm install --save-dev @types/jest

Organize your project structure to separate consumer and provider tests:

project/
├── src/
│  ├── client/
│  │  └── auth-service-client.ts
│  └── api/
│  └── user-controller.ts
├── tests/
│  ├── pact/
│  │  ├── consumer/
│  │  │  └── auth-service.pact.spec.ts
│  │  └── provider/
│  │  └── user-api.pact.spec.ts
│  └── helpers/
│  └── pact-setup.ts
├── pacts/  # Generated contract files
└── package.json

Add npm scripts to run consumer tests, provider verification, and publish contracts:

{
  "scripts": {
    "test:pact:consumer": "jest --testMatch='**/pact/consumer/**/*.spec.ts'",
    "test:pact:provider": "jest --testMatch='**/pact/provider/**/*.spec.ts'",
    "pact:publish": "npx pact-broker publish ./pacts --consumer-app-version=$GIT_COMMIT --broker-base-url=$PACT_BROKER_URL --broker-token=$PACT_BROKER_TOKEN --branch=$GIT_BRANCH",
    "pact:can-deploy": "npx pact-broker can-i-deploy --pacticipant=user-service --version=$GIT_COMMIT --to=production --broker-base-url=$PACT_BROKER_URL --broker-token=$PACT_BROKER_TOKEN"
  }
}

Writing Consumer Contract Tests

Consumer tests define the contract by specifying expected API interactions. Here’s a basic example:

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { AuthServiceClient } from '@/client/auth-service-client';
import path from 'path';

const { like, string, eachLike } = MatchersV3;

const provider = new PactV3({
  consumer: 'user-service',
  provider: 'auth-service',
  dir: path.resolve(__dirname, '../../../pacts')
});

describe('Auth Service Contract', () => {
  test('retrieves user profile', async () => {
    await provider
      .given('user with ID 123 exists')
      .uponReceiving('a request for user profile')
      .withRequest({
        method: 'GET',
        path: '/users/123',
        headers: { 'Authorization': like('Bearer token123') }
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: like({
          id: string('123'),
          email: string('[email protected]'),
          name: string('John Doe'),
          roles: eachLike('user')
        })
      })
      .executeTest(async (mockserver) => {
        const client = new AuthServiceClient(mockserver.url);
        const user = await client.getUser('123');
        expect(user.email).toBe('[email protected]');
      });
  });
});

Key principles for writing effective consumer tests:

1. Exercise real consumer code: Don’t just make HTTP requests with a library like axios. Use your actual HTTP client implementation:

// DON'T - not testing real client
.executeTest(async (mockserver) => {
  const response = await axios.get(`${mockserver.url}/users/123`);
  expect(response.data.email).toBe('[email protected]');
});

// DO - testing real client
.executeTest(async (mockserver) => {
  const client = new AuthServiceClient(mockserver.url);
  const user = await client.getUser('123');
  expect(user.email).toBe('[email protected]');
});

2. Use flexible matching: Specify types, not exact values. This prevents brittle tests that break when irrelevant data changes:

// BAD: Over-specified contract
.willRespondWith({
  status: 200,
  body: {
    id: '123',
    email: '[email protected]',
    createdAt: '2024-01-15T10:30:00.000Z',
    updatedAt: '2024-01-15T10:30:00.000Z',
    // Many fields consumer doesn't actually use
  }
})

// GOOD: Specify only what consumer uses
.willRespondWith({
  status: 200,
  body: like({
    id: string('123'),
    email: string('[email protected]')
  })
})

3. Test only what you use: Don’t include API fields in the contract that your consumer doesn’t actually need. This allows the provider to evolve unused parts of the API without breaking your contract.

4. Avoid testing provider bugs: Don’t specify exact error messages or internal provider behavior:

// DON'T
.willRespondWith({
  status: 400,
  body: { error: 'Invalid email format: must contain @' }
})

// DO
.willRespondWith({
  status: 400,
  body: like({ error: string('validation error') })
})

Pact Matcher Reference

Matchers define flexible contract rules that verify type and structure rather than exact values:

import { MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, atLeastLike, regex, iso8601DateTime, number, string } = MatchersV3;

const productResponse = like({
  id: string('prod-123'),  // Any string value
  name: string('Laptop'),  // Any string value
  price: number(999.99),  // Any number value
  sku: regex('SKU-[0-9]+', 'SKU-12345'), // Must match pattern
  createdAt: iso8601DateTime('2024-01-15T10:30:00Z'), // ISO8601 format
  tags: eachLike('electronics'),  // Array of strings
  reviews: atLeastLike(
    { rating: number(5), comment: string('Great!') },
    2  // At least 2 reviews
  )
});

Matcher usage guidelines:

  • like(): Most common - matches type and structure
  • eachLike(): For arrays where all elements match a pattern
  • atLeastLike(): When minimum array length matters
  • regex(): Use sparingly for formats like SKU, email patterns
  • Avoid exact value matching unless the value is semantically meaningful (like API version numbers)

Testing POST/PUT Requests

Testing request bodies ensures consumers send correct data:

test('creates new user', async () => {
  const newUser = {
    email: '[email protected]',
    name: 'Jane Smith',
    roles: ['user']
  };

  await provider
    .given('no user with email [email protected] exists')
    .uponReceiving('a request to create user')
    .withRequest({
      method: 'POST',
      path: '/users',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': like('Bearer token123')
      },
      body: like(newUser)
    })
    .willRespondWith({
      status: 201,
      headers: { 'Content-Type': 'application/json' },
      body: like({
        id: string('user-456'),
        ...newUser,
        createdAt: iso8601DateTime()
      })
    })
    .executeTest(async (mockserver) => {
      const client = new AuthServiceClient(mockserver.url);
      const created = await client.createUser(newUser);
      expect(created.email).toBe(newUser.email);
    });
});

The contract verifies both the request format the consumer sends and the response structure the consumer expects.

Provider Verification with State Handlers

Provider verification tests that the actual API implementation can fulfill consumer contracts. This requires setting up appropriate test data for each provider state:

import { Verifier } from '@pact-foundation/pact';
import { db } from '@/database';

const opts = {
  provider: 'auth-service',
  providerBaseUrl: 'http://localhost:3000',
  pactBrokerUrl: process.env.PACT_BROKER_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  publishVerificationResult: true,
  providerVersion: process.env.GIT_COMMIT,
  stateHandlers: {
    'user with ID 123 exists': {
      setup: async () => {
        await db.users.insert({
          id: '123',
          email: '[email protected]',
          name: 'John Doe',
          roles: ['user']
        });
        return { userId: '123' };
      },
      teardown: async () => {
        await db.users.delete({ id: '123' });
      }
    },
    'no users exist': {
      setup: async () => {
        await db.users.deleteAll();
      }
    }
  }
};

describe('Provider Verification', () => {
  beforeAll(async () => {
    // Start provider API on localhost:3000
  });

  afterAll(async () => {
    // Stop provider API
  });

  test('verifies all consumer contracts', async () => {
    await new Verifier(opts).verifyProvider();
  });
});

State handlers are critical for isolated, repeatable tests. Each provider state sets up exactly the data needed for that test scenario, then cleans up afterward.

For parallel test execution, avoid shared test data:

stateHandlers: {
  'user has 3 orders': async () => {
    const testUserId = `test-user-${Date.now()}`;
    await createTestUser(testUserId);
    await createOrders(testUserId, 3);
    return { userId: testUserId };  // Returned data can be used in requests
  }
}

Pact Broker Integration

Pact Broker is the central contract repository that enables the full consumer-driven workflow. You can self-host the open-source version or use PactFlow (hosted SaaS):

Self-hosted with Docker:

docker run -d -p 9292:9292 \
  -e PACT_BROKER_DATABASE_URL=postgres://user:pass@host/pactdb \
  pactfoundation/pact-broker

Publishing contracts from consumer:

npx pact-broker publish ./pacts \
  --consumer-app-version=$GIT_COMMIT \
  --broker-base-url=$PACT_BROKER_URL \
  --broker-token=$PACT_BROKER_TOKEN \
  --branch=$GIT_BRANCH

Publishing verification results from provider:

This happens automatically when publishVerificationResult: true is set in the Verifier options.

Using can-i-deploy:

The can-i-deploy feature checks if a consumer version is compatible with deployed provider versions:

import { canDeploy } from '@pact-foundation/pact-cli';

const canDeployOpts = {
  pacticipant: 'user-service',
  version: process.env.GIT_COMMIT,
  to: 'production',
  pactBroker: process.env.PACT_BROKER_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN
};

const result = await canDeploy(canDeployOpts);
if (!result.success) {
  throw new Error('Cannot deploy: incompatible contracts');
}

This gate in your deployment pipeline prevents deploying incompatible service versions.

CI/CD Integration

Contract testing only provides value when integrated into your deployment pipeline. Here’s a GitHub Actions example:

name: Contract Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3

      - name: Install dependencies
        run: npm ci

      - name: Run consumer contract tests
        run: npm run test:pact:consumer

      - name: Publish pacts to broker
        if: github.ref == 'refs/heads/main'
        run: npm run pact:publish
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GIT_COMMIT: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}

  provider-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3

      - name: Start provider API
        run: npm start &

      - name: Wait for API
        run: npx wait-on http://localhost:3000/health

      - name: Run provider verification
        run: npm run test:pact:provider
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GIT_COMMIT: ${{ github.sha }}

  can-i-deploy:
    needs: [consumer-tests, provider-tests]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3

      - name: Check deployment safety
        run: npx pact-broker can-i-deploy \
          --pacticipant user-service \
          --version ${{ github.sha }} \
          --to production \
          --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
          --broker-token ${{ secrets.PACT_BROKER_TOKEN }}

The pipeline flow:

  1. Consumer tests run and generate pact files
  2. Pacts are published to the broker (main branch only)
  3. Provider tests fetch pacts from the broker and verify
  4. Verification results are published
  5. can-i-deploy checks if deployment is safe
  6. Deployment proceeds only if contracts are satisfied

Handling Breaking Changes

Breaking changes require a migration strategy. Here’s how to handle a field type change:

Scenario: Provider needs to change phone from required to optional.

// Old contract (consumer expects)
{
  phone: string('555-1234')  // Required field
}

// Provider wants to make phone optional
{
  phone: string('555-1234') | null
}

If the provider makes this change directly, consumer contract verification fails. The migration pattern:

  1. Provider adds new optional field alongside old required field
  2. Consumers gradually update to use the new field
  3. After all consumers migrate, provider removes the old field

Using version selectors to test against the right contracts:

const opts = {
  provider: 'auth-service',
  providerBaseUrl: 'http://localhost:3000',
  pactBrokerUrl: process.env.PACT_BROKER_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  consumerVersionSelectors: [
    { mainBranch: true },  // Latest from main
    { deployedOrReleased: true },  // Currently in production
    { matchingBranch: true }  // Same branch as provider
  ],
  enablePending: true,  // Don't fail on new contracts
  includeWipPactsSince: '2024-01-01' // Include work-in-progress pacts
};

The enablePending feature allows new consumer contracts to be verified without failing the provider build. This prevents chicken-and-egg deployment issues where neither consumer nor provider can deploy first.

Common Pitfalls and Solutions

Pitfall 1: Over-Specified Contracts

Testing every field in an API response makes contracts brittle. The provider can’t evolve unused fields without breaking consumer tests.

Solution: Only specify fields the consumer actually uses. Review contracts periodically to remove unused fields.

Pitfall 2: Using Pact as a General Stub

Using the Pact mock provider for integration tests without running verification defeats the purpose.

Solution: Use Pact exclusively for contract testing. For general stubbing, use tools like MSW or WireMock.

Pitfall 3: Random Test Data

Using new Date().toISOString() or uuid() in contracts causes them to change on every test run.

Solution: Use static test data or matchers:

// DON'T
body: { createdAt: new Date().toISOString() }

// DO
body: { createdAt: iso8601DateTime('2024-01-15T10:30:00Z') }

Pitfall 4: Not Testing Real Consumer Code

Writing Pact tests that don’t exercise your actual HTTP client misses client bugs.

Solution: Always use your production client code in contract tests. The test should call the same methods your application calls.

Pitfall 5: Broker Reliability

Pact Broker downtime blocks deployments if can-i-deploy checks fail.

Solution: Use a hosted solution like PactFlow for reliability, or implement high availability for self-hosted brokers. Set up monitoring and have a fallback process for outages.

Contract Testing in the Testing Strategy

Here is where contract tests sit relative to the other layers:

Business Logic

API Boundaries

Service Integration

Critical Workflows

Unit Tests

Contract Tests

Integration Tests

E2E Tests

Production

When to use contract testing:

  • Services communicate over HTTP APIs
  • Multiple teams own different services
  • Need to ensure API compatibility
  • Want faster feedback than E2E tests

When NOT to use contract testing:

  • Single team owns all services (use integration tests instead)
  • UI-to-backend testing (use E2E tests)
  • Business workflow testing (use E2E tests)
  • Performance testing (use load tests)

Balanced testing approach:

Test TypeScopeSpeedConfidenceWhen to Use
UnitSingle functionFast (ms)LowBusiness logic
ContractAPI boundaryFast (sec)MediumService compatibility
IntegrationMultiple servicesMedium (min)HighService interaction
E2EFull systemSlow (min)HighestCritical workflows

Keep the E2E suite for critical user journeys and the failures that would wake someone up. API variations and edge cases belong one layer down in contract tests, where they run in seconds instead of minutes and point at a specific field rather than a red pipeline.

Where This Default Holds

Contract testing earns its cost when separate teams own separate HTTP services and provider APIs change often enough that coordination turns into a standing meeting. The cost is not trivial. Provider states need isolated test data, matchers need discipline to stay loose, and the broker becomes one more piece of infrastructure that someone has to keep running and monitor.

Override the default when a single team owns every service in the chain. Integration tests against real dependencies buy the same confidence there without a broker to operate. The same applies to an API that changes a couple of times a year: the pipeline wiring costs more attention than the breakages it would catch.

If you do adopt it, start with one consumer/provider pair wired end to end, can-i-deploy gate included. Contracts published without a gate are documentation, and documentation drifts away from the running system. Adding the second pair is nearly free once the pipeline exists; the first one is where the learning happens.

References

Related posts