Contract Testing: API Compatibility Verification

Integration tests are expensive. They require spinning up multiple services, managing test data across databases, and dealing with flaky network calls. When they fail, you're often left debugging...

Key Insights

  • Contract testing verifies API compatibility between services without requiring them to run simultaneously, catching breaking changes before they reach production
  • Consumer-driven contracts shift the power dynamic—API consumers define their expectations, and providers verify they meet them, reducing unnecessary coupling
  • Integrating contract tests with a Pact Broker and CI/CD pipelines enables safe, independent deployments through automated “can I deploy” checks

Introduction to Contract Testing

Integration tests are expensive. They require spinning up multiple services, managing test data across databases, and dealing with flaky network calls. When they fail, you’re often left debugging whether the failure is a real bug or just infrastructure noise.

Contract testing solves a specific problem: verifying that two services can communicate correctly without actually running them together. Instead of testing the integration, you test the contract—the agreed-upon interface between a consumer and a provider.

In distributed systems, breaking changes are inevitable. A backend team renames a field from userName to username. A mobile team expects a field that the API team considers optional. These mismatches slip through code review, pass unit tests, and explode in production.

Contract testing catches these issues at build time. Each service tests against a shared contract definition, and if either side violates the agreement, the build fails. No staging environment required.

The Contract Testing Pyramid

Traditional testing pyramids show unit tests at the base, integration tests in the middle, and end-to-end tests at the top. Contract tests occupy a unique position—they provide integration-level confidence with unit-test speed.

Here’s where they fit:

  • Unit tests: Test individual functions and classes in isolation
  • Contract tests: Verify service interfaces match expectations
  • Integration tests: Test actual service communication
  • End-to-end tests: Validate complete user flows

Contract tests don’t replace integration tests entirely, but they dramatically reduce how many you need. Reserve integration tests for critical paths and edge cases that contracts can’t capture.

Two approaches exist for defining contracts:

Consumer-driven contracts (CDC): Consumers write tests expressing their expectations. These generate contracts that providers must satisfy. This approach ensures providers don’t break their consumers.

Provider-driven contracts: Providers publish their API specification (like OpenAPI), and consumers verify compatibility. This works well for public APIs with many unknown consumers.

Consumer-driven contracts are more powerful for internal microservices because they capture actual usage patterns, not theoretical API surfaces.

Core Concepts: Consumers, Providers, and Contracts

Three terms define contract testing:

Consumer: Any service that makes requests to another service. A frontend calling a REST API is a consumer. A backend service calling another microservice is also a consumer.

Provider: The service that responds to requests. It provides data or functionality that consumers depend on.

Contract: A formal definition of the expected interactions between a consumer and provider. It specifies request formats and expected responses.

A contract captures specific interactions, not the entire API surface. If a consumer only uses two endpoints, the contract only covers those two endpoints.

{
  "consumer": { "name": "order-service" },
  "provider": { "name": "inventory-service" },
  "interactions": [
    {
      "description": "a request for product availability",
      "request": {
        "method": "GET",
        "path": "/products/123/availability",
        "headers": {
          "Accept": "application/json"
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        },
        "body": {
          "productId": "123",
          "available": true,
          "quantity": 50
        },
        "matchingRules": {
          "body": {
            "$.productId": { "match": "type" },
            "$.available": { "match": "type" },
            "$.quantity": { "match": "integer" }
          }
        }
      }
    }
  ]
}

The matchingRules section is crucial. Instead of asserting exact values, we verify types and structure. This prevents brittle tests that break when test data changes.

Implementing Contract Tests with Pact

Pact is the most widely adopted contract testing framework. It supports JavaScript, Java, Python, Go, Ruby, and .NET. The workflow follows two phases: consumer tests generate pacts, and provider tests verify them.

Consumer Side

The consumer writes tests describing its expectations. Pact spins up a mock server that responds according to those expectations.

// order-service/tests/inventory.contract.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { InventoryClient } from '../src/inventory-client';

const { like, integer } = MatchersV3;

const provider = new PactV3({
  consumer: 'order-service',
  provider: 'inventory-service',
  dir: './pacts',
});

describe('Inventory Service Contract', () => {
  it('returns product availability', async () => {
    await provider
      .given('product 123 exists with stock')
      .uponReceiving('a request for product availability')
      .withRequest({
        method: 'GET',
        path: '/products/123/availability',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          productId: like('123'),
          available: like(true),
          quantity: integer(50),
        },
      })
      .executeTest(async (mockServer) => {
        const client = new InventoryClient(mockServer.url);
        const result = await client.checkAvailability('123');
        
        expect(result.available).toBe(true);
        expect(result.quantity).toBeGreaterThan(0);
      });
  });
});

Running this test generates a pact file in the ./pacts directory. This file becomes the contract.

Provider Side

The provider runs verification tests against the generated pact. Pact replays the recorded requests and validates responses.

// inventory-service/tests/pact.verification.test.ts
import { Verifier } from '@pact-foundation/pact';
import { app } from '../src/app';

describe('Pact Verification', () => {
  let server: any;

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

  afterAll(() => {
    server.close();
  });

  it('validates the inventory-service contract', async () => {
    const verifier = new Verifier({
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: ['../order-service/pacts/order-service-inventory-service.json'],
      stateHandlers: {
        'product 123 exists with stock': async () => {
          // Set up test data for this provider state
          await seedProduct({ id: '123', quantity: 50 });
        },
      },
    });

    await verifier.verifyProvider();
  });
});

The stateHandlers configure the provider’s state before each interaction. The string 'product 123 exists with stock' matches the given clause from the consumer test.

Handling Schema Evolution and Versioning

APIs evolve. Fields get added, deprecated, and removed. Contract testing helps manage this evolution safely.

Consider a breaking change where someone renames quantity to stockCount:

// Provider returns new schema
{
  productId: '123',
  available: true,
  stockCount: 50  // Was: quantity
}

The consumer’s contract test still expects quantity. When the provider runs verification, Pact fails immediately:

Verifier Error: Missing field 'quantity' in response body
Expected: { productId: "123", available: true, quantity: 50 }
Actual:   { productId: "123", available: true, stockCount: 50 }

This failure happens in CI, not production. The provider team now knows they need to either maintain backward compatibility or coordinate with the consumer team.

Pending pacts help when introducing new consumer expectations. A pending pact allows provider verification to pass even if new interactions fail. Once the provider implements the feature and verification succeeds, the pact is no longer pending.

const verifier = new Verifier({
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: 'https://your-broker.pactflow.io',
  enablePending: true,
  includeWipPactsSince: '2024-01-01',
});

CI/CD Integration and Pact Broker

Local pact files don’t scale. The Pact Broker provides centralized contract storage, versioning, and deployment safety checks.

Here’s a GitHub Actions workflow integrating contract testing:

# .github/workflows/contract-tests.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run consumer contract tests
        run: npm test -- --testPathPattern=contract
      
      - name: Publish pacts to broker
        run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version=${{ github.sha }} \
            --branch=${{ github.ref_name }} \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-token=${{ secrets.PACT_BROKER_TOKEN }}          

  provider-verification:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Verify provider against pacts
        run: npm run test:pact:verify
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          PACT_PROVIDER_VERSION: ${{ github.sha }}
      
      - name: Can I deploy?
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant=inventory-service \
            --version=${{ github.sha }} \
            --to-environment=production \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }}          

The can-i-deploy command is the key to safe deployments. It checks whether this version of the service is compatible with everything currently in the target environment.

Best Practices and Common Pitfalls

Keep contracts minimal. Only specify fields your consumer actually uses. Over-specifying creates unnecessary coupling and brittle tests.

Use loose matching. Prefer type matchers over exact values. like('any-string') is better than 'specific-value' unless the exact value matters.

Handle authentication thoughtfully. Don’t include real tokens in contracts. Use provider states to configure authentication bypass for contract verification.

stateHandlers: {
  'user is authenticated': async () => {
    // Configure provider to accept test auth header
    process.env.BYPASS_AUTH = 'true';
  },
}

Don’t use contract tests for business logic. They verify structure, not behavior. A contract test confirms you get a 200 response with the right shape—not that the calculation inside is correct.

Know when to skip contract testing. It’s overkill for monoliths, simple CRUD APIs with few consumers, or third-party APIs you don’t control. Focus on boundaries where teams work independently and deploy separately.

Contract testing shines in microservices architectures where multiple teams own different services. It enables independent deployments while maintaining confidence that everything still works together. Start with your most critical service boundaries and expand from there.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.