StackPractices
intermediate By Mathias Paulenko

Test API Contracts with Consumer-Driven Contracts

How to prevent breaking changes between microservices using consumer-driven contract testing with Pact and OpenAPI validators.

Overview

Microservices talk to each other over APIs — dozens of them. When one service changes a response field or drops a status code, downstream consumers break silently, and the failure usually surfaces in production first. Integration tests catch some of this, but they’re slow and need every service running at once.

Contract testing solves this by having each consumer define its expectations of a provider API (the contract). These contracts are shared, verified independently, and fail fast when a provider breaks a consumer’s assumptions. Pact is the most widely adopted framework for consumer-driven contract testing.

When to Use

Use this recipe when:

  • Managing 5+ microservices with inter-service HTTP or message queue communication. See Integration Testing for verifying component interactions.
  • Experiencing production outages caused by API changes in upstream services. See Call REST API for what works with API clients.
  • Wanting to decouple deployment pipelines so services deploy independently. See Microservices Patterns for distributed architecture guidance.
  • Migrating from monolithic to microservices and needing safety nets for API boundaries
  • Working with external API providers where you can’t control their release cycle

Skip it for public APIs consumed by unknown clients (you can’t collect contracts from consumers you don’t know) and for in-process module calls inside a monolith, where the compiler already guards the boundary.

Solution

Consumer Test (Pact JS)

const { PactV3 } = require('@pact-foundation/pact');
const { like, regex } = require('@pact-foundation/pact').MatchersV3;

const provider = new PactV3({
  consumer: 'order-service',
  provider: 'user-service',
});

describe('User Service Contract', () => {
  test('returns user by ID', async () => {
    await provider
      .given('user with id 123 exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/users/123',
      })
      .willRespondWith({
        status: 200,
        body: {
          id: like(123),
          name: like('Alice'),
          email: regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'alice@example.com'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const user = await fetchUser(mockServer.url, 123);
      expect(user.name).toBe('Alice');
    });
  });
});

Provider Verification (Pact JS)

const { Verifier } = require('@pact-foundation/pact');

describe('Pact Verification', () => {
  test('validates against consumer contracts', async () => {
    await new Verifier({
      provider: 'user-service',
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://pact-broker.example.com',
      publishVerificationResult: true,
      providerBranch: process.env.GIT_BRANCH,
      stateHandlers: {
        'user with id 123 exists': async () => {
          await seedUser({ id: 123, name: 'Alice' });
        },
      },
    }).verifyProvider();
  });
});

Every given(...) in a consumer test needs a matching stateHandlers entry on the provider. Miss one and verification fails with “missing provider state”, even if the API itself is correct.

Generated Contract (pact.json)

The consumer run produces a JSON file per consumer-provider pair, published to the broker:

{
  "consumer": { "name": "order-service" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request for user 123",
      "providerState": "user with id 123 exists",
      "request": { "method": "GET", "path": "/users/123" },
      "response": {
        "status": 200,
        "body": { "id": 123, "name": "Alice", "email": "alice@example.com" }
      }
    }
  ]
}

CI Gate with can-i-deploy

Once contracts are in a broker, gate the provider pipeline so a breaking change never reaches production:

# .github/workflows/provider-verify.yml
- name: Verify pacts
  run: npm run test:pact:verify

- name: Can I deploy?
  run: |
    docker run --rm pactfoundation/pact-cli:latest \
      broker can-i-deploy \
      --pacticipant user-service \
      --version ${{ github.sha }} \
      --to-environment production

can-i-deploy exits non-zero if any consumer contract is unverified or failing for that version, and the deploy step never runs.

Async Message Contracts (Pact JS)

Pact also covers event-driven contracts — a consumer declares the message shape it expects instead of an HTTP request:

const { MessageConsumerPact } = require('@pact-foundation/pact');

const messagePact = new MessageConsumerPact({
  consumer: 'order-service',
  provider: 'inventory-events',
});

await messagePact
  .given('inventory was updated')
  .expectsToReceive('a stock.updated event')
  .withContent({ sku: like('ABC-123'), quantity: like(5) })
  .verify(async (message) => {
    expect(message.contents.sku).toBe('ABC-123');
  });

OpenAPI Validator (Python)

from openapi_spec_validator import validate_spec
import requests

spec = requests.get('https://api.example.com/openapi.json').json()
validate_spec(spec)  # validates schema correctness

# Then validate responses against the spec at runtime
from openapi_core import validate_response
validate_response(spec, response)

Companion files: the api-contract-testing companion repo has runnable versions of all four examples plus a ready-to-adapt GitHub Actions workflow.

Explanation

  • Consumer-driven contracts: The consumer (client) writes a test that describes exactly what it needs from the provider. Pact records each interaction and writes a contract file (JSON), one per consumer-provider pair.
  • Pact Broker: the service that stores published contracts and keeps a compatibility matrix of which service versions work together — that’s what lets teams ship independently.
  • Provider verification: the provider replays every published contract against its real API in CI. Remove a field or change a type and verification fails before the merge.
  • Provider states: given('user with id 123 exists') is only a label until the provider wires it up — stateHandlers seed the data so the replayed request has something real to hit.
  • Can-I-Deploy: the broker command that answers “is this version safe to release” against every consumer contract it knows about.

For the architectural decision behind this approach, see the Contract Testing pattern.

Variants

ToolLanguageContract StyleBest For
PactMulti (JS, JVM, Go, Python)Consumer-drivenInternal microservices
OpenAPI validatorsMultiProvider-drivenPublic APIs, documentation-first
Spring Cloud ContractJVMProvider-drivenSpring ecosystems
BigQuery data contractsSQLSchema-drivenData warehouses

What Works

  • Keep contracts focused on fields you use: if the consumer only needs id and name, don’t assert the entire response schema. This gives the provider freedom to evolve unused fields.
  • Version contracts alongside code: store contract tests in the same repository as the consumer service. CI generates and publishes contracts on every build.
  • Use a Pact Broker for visibility: without a broker, teams share contract files manually, which breaks down quickly at scale.
  • Run provider verification in CI: every pull request on the provider should verify against all consumer contracts before merging.
  • Don’t test business logic in contracts: contracts verify the shape of the API, not the correctness of calculations or business rules.

Common Mistakes

  • Overly strict contracts: asserting every field and exact values makes contracts brittle.
  • Skipping provider verification: generating contracts without verifying them on the provider side creates false confidence. Both sides matter.
  • Storing contracts in shared drives or email: use a Pact Broker. It tracks compatibility matrices and powers can-i-deploy checks.
  • Testing through the UI: contract tests should exercise the API client directly, not Selenium or Playwright. UI tests belong in E2E suites.

The diagram shows the full loop: the consumer publishes a pact, the provider verifies it in CI, and the pipeline blocks any change that breaks the contract.

Mermaid flowchart LR diagram

Further Reading

Frequently Asked Questions

Can I use OpenAPI specs instead of Pact?

They answer different questions. An OpenAPI spec says what the provider should return; a Pact contract says what each consumer actually depends on. Schema validation tells you the provider drifted from its own spec — Pact tells you a real consumer broke. Teams usually keep both: OpenAPI for docs, Pact as the merge gate.

Is contract testing a replacement for integration tests?

No — it narrows them. Contract tests verify message format compatibility at the API boundary, not business logic or real network behavior. Keep a thin layer of integration tests for critical flows; contracts cover the combinatorial space in between.

What are the limitations of contract testing?

A contract only proves the message shape matched — the provider can pass every check and still return a wrong total. Contracts also go stale fast without a broker and a CI gate enforcing them, and they say nothing about auth, latency, or failure behavior. That part still needs integration and E2E tests.

How do I handle versioning in contract testing?

Version contracts semantically and publish them to a broker — Pact Broker tracks which consumer/provider versions are compatible. The can-i-deploy check gates merges: it answers "is this version safe to release" against every known contract. Consumers and providers then evolve independently within what the contract allows.

When should I write a contract test vs an E2E test?

Contract test when you're checking that provider and consumer agree on message shape — cheap, fast, runs without standing up real services. E2E when you're validating a user journey through real infrastructure — expensive and slow, reserved for the few flows that must work end to end. If a contract test covers the risk, don't pay the E2E price for it.