Contract Testing Guide: Keep Microservices in Sync Without Integration Tests

Contract Testing Guide: Keep Microservices in Sync Without Integration Tests

Contract testing verifies that two services can communicate correctly by testing each side in isolation against a shared "contract" — a formal description of the API they have agreed on. It is faster than integration testing and catches breaking changes before deployment.

Key Takeaways

Contract testing sits between unit tests and integration tests. Unit tests verify each service in isolation. Integration tests verify them together. Contract tests verify the interface between them — without the overhead of running both services simultaneously.

Consumer-driven contracts are more practical than provider-driven. The consumer defines what it needs from the provider, and the provider verifies it can deliver. This prevents providers from breaking consumers without knowing it.

Pact is the most widely adopted contract testing framework. It supports JavaScript, Python, Java, .NET, Go, and Ruby, and has a hosted Pact Broker for sharing contracts across teams.

Contract tests are not a replacement for integration tests. They verify that services can communicate. Integration tests verify that they actually work together in a production-like environment. Both have a place.

In microservices architecture, every service depends on other services' APIs. When Team A changes an endpoint that Team B's service depends on, the result is either a production incident or a tedious synchronization meeting. Integration tests catch these mismatches, but integration tests are slow, flaky, and require every dependent service to be running simultaneously.

Contract testing is the solution. It lets each team verify their side of an API relationship independently, without orchestrating a full integration environment.

This guide explains how contract testing works, when to use it, and how to implement it with Pact — the most popular contract testing framework.

The Problem Contract Testing Solves

Imagine two services:

  • Order Service — handles order creation and calls the Payment Service to process payments
  • Payment Service — processes payments and returns confirmation

Order Service sends requests like this:

POST /payments
{
  "orderId": "ord-123",
  "amount": 49.99,
  "currency": "USD",
  "method": "card",
  "cardToken": "tok_abc"
}

Payment Service responds:

{
  "paymentId": "pay-456",
  "status": "succeeded",
  "amount": 49.99
}

Now the Payment Service team decides to rename cardToken to tokenId for consistency with their new API standards. They update their service and tests, everything passes, and they deploy. One hour later, Order Service starts failing — all payment processing is broken because cardToken is no longer accepted.

This is the contract testing problem. How do you prevent this class of breakage?

Option 1: Integration Tests

Run both services simultaneously and test the real interaction. This works but has costs:

  • Slow: spinning up multiple services in CI takes time
  • Flaky: network calls, database dependencies, state management
  • Hard to isolate: a test failure might be either service
  • Expensive to maintain as you add more services

Option 2: Mocks (Without Contracts)

Order Service mocks the Payment Service in its tests. Payment Service tests independently. Both pass.

The problem: if Order Service mocks the old API while Payment Service changes it, the mocks are wrong and tests pass on both sides while the real integration is broken. This is worse than no testing — you have false confidence.

Option 3: Contract Testing

Order Service defines a "contract" — a formal description of the Payment Service API it needs:

  • Endpoint: POST /payments
  • Request body shape: { orderId, amount, currency, method, cardToken }
  • Expected response: { paymentId, status, amount }

Payment Service verifies against this contract in its own test suite. When the Payment Service team renames cardToken to tokenId, the contract verification fails — before deployment. The breaking change is caught immediately by the team making it.

Consumer-Driven Contracts

The most effective contract testing pattern is consumer-driven contracts. The consumer (Order Service, in our example) defines what it needs, and the provider (Payment Service) verifies it can deliver.

This is counter-intuitive at first. Shouldn't the provider define its API? Yes — but the provider cannot know what all consumers actually use. The consumer defines the minimal interface it needs, and the provider verifies it can satisfy that need.

This approach has a key benefit: the provider only needs to support what consumers actually use. If consumers only use three fields out of a twenty-field response, the contract only covers those three. The provider can refactor or deprecate unused fields without breaking any contracts.

Pact: Consumer-Driven Contract Testing

Pact is the most widely adopted consumer-driven contract testing framework. It supports multiple languages and provides tooling for sharing contracts between teams via the Pact Broker.

How Pact Works

  1. Consumer writes a test that defines expected interactions with the provider
  2. Pact generates a "pact file" (JSON) from the consumer test — this is the contract
  3. Contract is shared with the provider (via file, GitHub, or Pact Broker)
  4. Provider runs verification against the pact file, ensuring it can fulfill each interaction

Consumer Side: Defining the Contract

// Order Service (consumer): payment.pact.spec.js
const { Pact } = require('@pact-foundation/pact');
const { processPayment } = require('./payment-client');

const provider = new Pact({
  consumer: 'OrderService',
  provider: 'PaymentService',
  port: 8080,
});

describe('Payment Service contract', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
  afterEach(() => provider.verify());

  test('processes a valid payment', async () => {
    // Define the expected interaction
    await provider.addInteraction({
      state: 'payment service is available',
      uponReceiving: 'a valid payment request',
      withRequest: {
        method: 'POST',
        path: '/payments',
        headers: { 'Content-Type': 'application/json' },
        body: {
          orderId: 'ord-123',
          amount: 49.99,
          currency: 'USD',
          method: 'card',
          cardToken: 'tok_abc',
        },
      },
      willRespondWith: {
        status: 201,
        headers: { 'Content-Type': 'application/json' },
        body: {
          paymentId: like('pay-456'),    // Any string value is acceptable
          status: 'succeeded',
          amount: like(49.99),           // Any number is acceptable
        },
      },
    });

    // Call the actual client code against the Pact mock server
    const result = await processPayment({
      orderId: 'ord-123',
      amount: 49.99,
      currency: 'USD',
      method: 'card',
      cardToken: 'tok_abc',
    });

    expect(result.status).toBe('succeeded');
    expect(result.paymentId).toBeDefined();
  });
});

Running this test:

  1. Starts a Pact mock server on port 8080
  2. Runs processPayment() against the mock server
  3. Verifies the request matched the defined interaction
  4. Generates a pact file: pacts/OrderService-PaymentService.json

The Generated Pact File

{
  "consumer": { "name": "OrderService" },
  "provider": { "name": "PaymentService" },
  "interactions": [
    {
      "description": "a valid payment request",
      "providerState": "payment service is available",
      "request": {
        "method": "POST",
        "path": "/payments",
        "body": {
          "orderId": "ord-123",
          "amount": 49.99,
          "currency": "USD",
          "method": "card",
          "cardToken": "tok_abc"
        }
      },
      "response": {
        "status": 201,
        "body": {
          "paymentId": { "pact:matcher:type": "type", "value": "pay-456" },
          "status": "succeeded",
          "amount": { "pact:matcher:type": "type", "value": 49.99 }
        }
      }
    }
  ],
  "metadata": { "pactSpecification": { "version": "3.0.0" } }
}

This JSON file is the contract. It captures exactly what Order Service expects from Payment Service.

Provider Side: Verifying the Contract

// Payment Service (provider): pact-verification.spec.js
const { Verifier } = require('@pact-foundation/pact');
const app = require('./app'); // Your Express/Fastify/etc. app

describe('Payment Service provider verification', () => {
  test('fulfills all consumer contracts', () => {
    return new Verifier({
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: [
        path.resolve('./pacts/OrderService-PaymentService.json'),
        // Or point to a Pact Broker URL
      ],
      stateHandlers: {
        'payment service is available': async () => {
          // Set up any required test state
          await db.seedTestData();
        },
      },
    }).verifyProvider();
  });
});

When Payment Service renames cardToken to tokenId, the verification fails:

Verifying a pact between OrderService and PaymentService
  Given payment service is available
    a valid payment request
      returns a response which
        has status code 201 ✓
        includes headers
          "Content-Type" with value "application/json" ✓
        has a matching body
          $ -> Expected cardToken but was tokenId ✗

1 interaction, 0 failures ✗

The breaking change is caught in Payment Service's CI pipeline before it deploys. The team knows exactly which consumer is affected and what they need to update.

Matching Rules: Flexible Contracts

Contract tests should be as flexible as possible while still catching real breaking changes. Pact provides matchers to express "any value of this type" rather than exact values:

const { like, eachLike, regex, string, integer } = require('@pact-foundation/pact').Matchers;

body: {
  paymentId: like('pay-456'),              // Any string value
  amount: like(49.99),                      // Any number value
  status: regex('succeeded|failed|pending', 'succeeded'),  // Matches pattern
  items: eachLike({ productId: like('prod-1'), qty: integer(1) }),  // Array with items of this shape
  createdAt: string('2025-01-15T10:00:00Z'),  // Any string (type check only)
}

Using exact values in contracts is a common mistake. If you specify paymentId: 'pay-456', the provider verification fails whenever Payment Service generates a different (but valid) payment ID. Use like() for values that legitimately vary.

Provider States

Provider states let the consumer define what data or configuration the provider needs to fulfill a specific interaction:

// Consumer test
await provider.addInteraction({
  state: 'user 123 exists and has active subscription',
  uponReceiving: 'a request for user subscription status',
  withRequest: {
    method: 'GET',
    path: '/users/123/subscription',
  },
  willRespondWith: {
    status: 200,
    body: {
      userId: '123',
      plan: like('pro'),
      active: true,
    },
  },
});

// Provider verification — set up the state before each interaction
stateHandlers: {
  'user 123 exists and has active subscription': async () => {
    await db.users.upsert({ id: '123', plan: 'pro', active: true });
  },
  'user 123 does not exist': async () => {
    await db.users.delete({ id: '123' });
  },
}

The Pact Broker

When you have multiple services with multiple contracts, sharing pact files by copying JSON around does not scale. The Pact Broker is a service that stores and manages contracts:

Consumer CI:
  1. Run consumer tests → generate pact file
  2. Publish pact file to Pact Broker

Provider CI:
  1. Fetch contracts from Pact Broker
  2. Run provider verification against each contract
  3. Report results back to Broker

Can I Deploy?
  broker.canIDeploy(service: 'PaymentService', version: '2.1.0', environment: 'production')
  → YES (all consumer contracts are satisfied)
  → NO (OrderService v1.5.2 contract is not satisfied)

PactFlow is the hosted version (from the Pact team). You can also self-host the open-source Pact Broker.

Publishing and Fetching Contracts

# Publish pact from consumer CI
npx pact-broker publish ./pacts \
  --broker-base-url https://your-pact-broker.example.com \
  --consumer-app-version $(git rev-parse HEAD) \
  --tag main

<span class="hljs-comment"># Verify in provider CI — fetch all contracts for this provider
PACT_BROKER_BASE_URL=https://your-pact-broker.example.com \
PACT_BROKER_TOKEN=<span class="hljs-variable">$PACT_TOKEN \
npx jest pact-verification.spec.js

Can I Deploy?

The Pact Broker's can-i-deploy command checks whether a version of a service can be safely deployed without breaking any consumers:

npx pact-broker can-i-deploy \
  --pacticipant PaymentService \
  --version $(git rev-parse HEAD) \
  --to-environment production

This is the key workflow: before deploying, check that all consumer contracts are satisfied. If any consumer's contract is not verified against this provider version, the deploy is blocked.

Contract Testing vs. Integration Testing

Contract Tests Integration Tests
Speed Milliseconds (no real services) Minutes (services must start)
Isolation Each service tested independently Both services running
Flakiness Low (no network, no state) Higher (network, shared state)
Catches API contract mismatches Runtime integration issues
Feedback loop Fast (each team runs independently) Slow (shared environment)
When to run Every PR, every commit Pre-production, staging

Contract tests do not replace integration tests. They test different things:

  • Contract tests: "Can these services communicate?"
  • Integration tests: "Do these services actually work together in a production-like environment?"

Use contract tests to get fast feedback on API compatibility. Use integration tests to catch runtime issues, data flow problems, and end-to-end behavior.

When Contract Testing Makes Sense

Contract testing adds the most value when:

You have multiple teams working on dependent services. A single-team monolith does not need contract testing — you can change both sides simultaneously. Contract testing solves the team coordination problem.

Deployment is independent. If you always deploy all services together, integration tests catch compatibility issues. Contract testing helps when services deploy independently.

You have many consumers. If Payment Service has 8 consumers (Order, Subscription, Invoice, Refund, ...), running full integration tests with all 8 becomes expensive. Contract tests let each team verify their interface independently.

Integration environments are slow or unreliable. If your integration test environment takes 30 minutes to spin up and fails 15% of the time, contract tests give you a faster, more reliable feedback loop.

Getting Started Checklist

  1. Identify a producer-consumer pair where breakages have caused incidents
  2. Install Pact in both services
  3. Write consumer tests that define the minimal interface needed
  4. Generate pact files and share them (start with a file, graduate to Pact Broker)
  5. Add provider verification to the provider's test suite and CI pipeline
  6. Add can-i-deploy checks to the deployment pipeline
  7. Expand to more service pairs as the pattern proves valuable

Frequently Asked Questions

What is the difference between consumer-driven and provider-driven contracts?

In consumer-driven contracts, the consumer defines what it needs and the provider verifies it can deliver. In provider-driven contracts, the provider defines the API and consumers verify they conform to it. Consumer-driven is more practical because providers cannot know what each consumer actually uses — consumers define the minimal interface they need, which is more precise.

Does contract testing work for event-driven or async APIs?

Yes. Pact supports message contracts for event-driven architectures. The consumer defines what events it expects to receive and their structure. The provider verifies it publishes events matching that structure.

Can I use contract testing with GraphQL?

Yes, but the approach differs from REST. GraphQL's flexible query language means the contract is the specific query the consumer makes. Tools like graphql-contract-testing and Pact's GraphQL support handle this.

Is Pact free?

Pact (the framework) is open-source and free. PactFlow (the hosted Pact Broker with additional features) has a paid tier for larger teams. You can also self-host the open-source Pact Broker.

How is contract testing different from API schema validation?

Schema validation (OpenAPI/Swagger) verifies that your API implementation matches a specification you wrote. Contract testing verifies that your API implementation satisfies what your actual consumers need. Schema validation is provider-focused; contract testing is consumer-focused. Both are useful; they solve different problems.

Summary

Contract testing is a practical solution to the API compatibility problem in microservices. By having consumers define contracts and providers verify them, you get:

  • Fast feedback on breaking changes — caught in the team's own CI, not in a shared environment
  • Clear ownership — the team making a breaking change knows exactly which consumers are affected
  • Deployable confidence — can-i-deploy checks prevent incompatible versions from reaching production together
  • Reduced integration test burden — contract tests replace the "can they communicate" layer of integration tests

The learning curve is real — Pact setup takes a day or two to get right, and team coordination around pact files and the broker requires process discipline. But for teams that have suffered through the "who broke the API?" incident at 2am, the investment pays for itself quickly.

Read more