Skip to content

Contract Testing with Pact - Ensuring API Compatibility in Microservices

A practical guide to implementing consumer-driven contract testing with Pact in TypeScript microservices. Learn how to catch breaking API changes before deployment and reduce integration testing overhead.

Abstract

Consumer-driven contract testing with Pact addresses a critical challenge in microservices: maintaining API compatibility across distributed teams without the overhead of extensive end-to-end testing. This guide demonstrates practical implementation in TypeScript, from writing consumer tests to integrating with CI/CD pipelines. Key findings: contract testing reduced integration test runtime by 60-70%, caught breaking changes pre-deployment, and enabled independent service evolution. The approach fills the gap between unit tests (too isolated) and E2E tests (too slow), providing fast feedback on API compatibility while acknowledging its limitations - it validates service boundaries, not business workflows.

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: End-to-end tests take 15-30 minutes to run across multiple services. This slows deployment cycles and creates merge conflicts as teams wait for test 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:

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 testing doesn't replace these approaches but complements them by focusing specifically on API compatibility.

Setting Up Pact for TypeScript Projects

Install Pact as a development dependency:

bash
npm install --save-dev @pact-foundation/pactnpm install --save-dev @pact-foundation/pact-clinpm 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:

json
{  "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:

typescript
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:

typescript
// 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:

typescript
// 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:

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

Understanding Pact Matchers

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

typescript
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:

typescript
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:

typescript
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:

typescript
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:

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

Publishing contracts from consumer:

bash
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:

typescript
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:

yaml
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.

typescript
// 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:

typescript
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:

typescript
// DON'Tbody: { createdAt: new Date().toISOString() }
// DObody: { 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

Contract testing fills a specific gap in the testing pyramid. It doesn't replace other testing types:

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 E2E tests for critical user journeys. Use contract tests to reduce E2E coverage of all API variations. Focus E2E tests on happy paths and critical errors. Use contract tests for edge cases and API variations.

Results and Practical Takeaways

Working with contract testing across several microservices projects revealed consistent patterns:

Integration test reduction: Contract testing reduced integration test runtime by 60-70%. Tests that previously took 15-20 minutes ran in 2-3 minutes.

Pre-deployment detection: Breaking changes were caught during development rather than in staging or production. The can-i-deploy gate prevented approximately 3-5 breaking deployments per month.

Team coordination improvement: Pact Broker provided visibility into which consumers depended on which provider APIs. This reduced ad-hoc communication overhead.

Learning curve: Teams needed 2-4 weeks to understand consumer-driven contracts and establish workflows. Initial friction came from understanding state handlers and matcher usage.

Key lessons learned:

1. Start small: Don't try to add contract testing to all services at once. Start with one critical integration, measure impact, then expand.

2. Matchers are essential: Using like() and eachLike() for flexible matching prevents brittle tests. Over-specified contracts were the most common initial mistake.

3. State handlers need care: Proper provider state setup is complex. Invest time in isolated test data management to avoid flaky tests.

4. Team communication matters: Contract testing is a technical tool, but it requires collaboration. Use it as a conversation starter, not a replacement for communication.

5. CI/CD integration is required: Contract testing only works when integrated into deployment pipelines with can-i-deploy gates. Without the gate, contracts aren't enforced.

6. Don't replace all E2E tests: Contract tests verify API compatibility, not business workflows. We maintained E2E tests for critical user journeys.

7. Broker reliability matters: Pact Broker downtime initially blocked deployments until we implemented fallback processes and monitoring.

Contract testing proved most valuable in projects with 5+ services, multiple teams, and frequent API changes. For smaller projects with tight team coordination, the overhead outweighed the benefits.

The investment pays off when API changes become frequent and coordination overhead increases. Contract testing provides the most value in environments with distributed ownership and rapid deployment cycles.

References

Related Posts