Contract testing between services: the approach that catches the gaps

By Sven Lindqvist · 21 July 2026132 views
Contract testing between services: the approach that catches the gaps

Two teams maintain two services: the Orders team owns the orders API, and the Payments team owns the payments API. Orders calls Payments to charge a card. Both teams have extensive test suites. Orders mocks the Payments API in its tests; Payments has no tests that simulate what Orders actually sends.

The Payments team refactors their charge endpoint. They change the request body format: paymentMethodId becomes methodId. Their unit tests pass — the function signature in their code is correct. The Orders team's unit tests pass — they mock the Payments API to return a success response.

Both test suites pass. The integration fails. The Orders service sends paymentMethodId; the Payments service expects methodId.

The incident lasts four hours. Both teams had complete test coverage. Neither test suite caught the problem because each tested its own service in isolation, with mocks that reflected the team's own assumptions — not the actual behavior of the other service.

Contract testing exists specifically to prevent this failure mode.

Why mocks fail at interface boundaries

Mocks are representations of a dependency that are defined by the team writing the test. The Orders team wrote a mock for the Payments API that returns { status: 'succeeded' } for any valid charge request. The mock is always up to date with what the Orders team believes the Payments API does. It is never updated when the Payments API actually changes.

This is not a flaw in how the Orders team writes tests. It is a structural limitation of mock-based testing at service boundaries: the mock is only as accurate as the team's knowledge of the other service. When the other service changes, the mock becomes wrong, and neither team's tests catch it.

End-to-end integration tests that run both services together would catch this, but they are expensive to set up, slow to run, and brittle when either service has external dependencies. Contract testing is the middle path: it catches interface mismatches without requiring both services to run simultaneously.

What contract testing is

A contract is the agreement between a consumer and a provider about the shape of a request and response. Contract testing verifies that both sides honor the contract:

  • The consumer (Orders) defines what it sends and what it expects to receive
  • The provider (Payments) verifies that its implementation satisfies the consumer's expectations

The contract test does not require both services to run simultaneously. The consumer generates a contract file; the provider runs its tests against the contract file independently.

This separation is the key property that makes contract testing practical. The Orders team runs their contract tests in their own CI pipeline, generating a contract file. The Payments team, in their own CI pipeline, runs verification against that contract file. If the Payments team's change breaks the contract, their CI pipeline fails — before the change is deployed.

Consumer-Driven Contract Testing with Pact

Pact is the most widely-used consumer-driven contract testing framework. The consumer defines interactions (request/response pairs); Pact generates a contract file that the provider can verify against its actual implementation.

Consumer side (Orders service)

// orders-service/src/__tests__/payments.contract.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { PaymentsClient } from '../clients/PaymentsClient';

const { like, string, integer } = MatchersV3;

const provider = new PactV3({
  consumer: 'OrdersService',
  provider: 'PaymentsService',
  dir: './pacts',  // Contract files saved here
});

describe('Payments API contract', () => {
  test('charge succeeds with valid payment method', async () => {
    // Define what the consumer will send and what it expects back
    await provider.addInteraction({
      states: [{ description: 'payment method pm_123 exists and is valid' }],
      uponReceiving: 'a charge request for an order',
      withRequest: {
        method: 'POST',
        path: '/v1/charges',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': like('Bearer sk_live_key'),
        },
        body: {
          paymentMethodId: string('pm_123'),  // Consumer sends paymentMethodId
          amount: integer(2999),
          currency: string('usd'),
          orderId: string('order_456'),
        },
      },
      willRespondWith: {
        status: 200,
        body: {
          id: like('ch_789'),
          status: string('succeeded'),
          amount: integer(2999),
        },
      },
    });

    await provider.executeTest(async (mockServer) => {
      // PaymentsClient is pointed at the Pact mock server
      const client = new PaymentsClient(mockServer.url);

      const result = await client.charge({
        paymentMethodId: 'pm_123',
        amount: 2999,
        currency: 'usd',
        orderId: 'order_456',
      });

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

  test('charge fails with invalid payment method', async () => {
    await provider.addInteraction({
      states: [{ description: 'payment method pm_invalid does not exist' }],
      uponReceiving: 'a charge request with invalid payment method',
      withRequest: {
        method: 'POST',
        path: '/v1/charges',
        body: {
          paymentMethodId: string('pm_invalid'),
          amount: integer(2999),
          currency: string('usd'),
          orderId: string('order_456'),
        },
      },
      willRespondWith: {
        status: 400,
        body: {
          error: like('payment_method_not_found'),
          message: like('Payment method not found'),
        },
      },
    });

    await provider.executeTest(async (mockServer) => {
      const client = new PaymentsClient(mockServer.url);
      await expect(client.charge({
        paymentMethodId: 'pm_invalid',
        amount: 2999,
        currency: 'usd',
        orderId: 'order_456',
      })).rejects.toThrow('payment_method_not_found');
    });
  });
});

Running this test generates a Pact file:

// pacts/OrdersService-PaymentsService.json
{
  "consumer": { "name": "OrdersService" },
  "provider": { "name": "PaymentsService" },
  "interactions": [
    {
      "description": "a charge request for an order",
      "request": {
        "method": "POST",
        "path": "/v1/charges",
        "body": {
          "paymentMethodId": "pm_123",  // Consumer says it sends THIS field name
          "amount": 2999,
          "currency": "usd",
          "orderId": "order_456"
        }
      },
      "response": {
        "status": 200,
        "body": {
          "id": "ch_789",
          "status": "succeeded",
          "amount": 2999
        }
      }
    }
  ]
}

Provider side (Payments service)

// payments-service/src/__tests__/contract.spec.ts
import { Verifier } from '@pact-foundation/pact';

describe('Pact verification: OrdersService', () => {
  test('satisfies the contract defined by OrdersService', async () => {
    const opts = {
      providerBaseUrl: 'http://localhost:4000',  // Running payments service
      pactUrls: ['../orders-service/pacts/OrdersService-PaymentsService.json'],
      stateHandlers: {
        'payment method pm_123 exists and is valid': async () => {
          // Set up the provider state — create the payment method in test data
          await db.paymentMethods.create({ id: 'pm_123', isValid: true });
        },
        'payment method pm_invalid does not exist': async () => {
          // Ensure pm_invalid does not exist in the test database
          await db.paymentMethods.delete({ id: 'pm_invalid' });
        },
      },
    };

    await new Verifier(opts).verifyProvider();
  });
});

When the Payments team changes paymentMethodId to methodId, the provider verification fails:

Expected request body: { "paymentMethodId": "pm_123", ... }
Actual behavior: POST /v1/charges with "paymentMethodId" returns 400 (field unknown)

The contract test catches the breaking change before the change is deployed. The Payments team must either update their API to accept both field names (backwards compatible) or coordinate with the Orders team to update the consumer before the provider change is deployed.

Pact Broker: sharing contracts across teams

For multi-team environments, the Pact Broker stores and distributes contract files centrally:

# Install Pact Broker (self-hosted or use pactflow.io)
docker run -d \
  -p 9292:9292 \
  -e PACT_BROKER_DATABASE_URL=postgres://postgres@postgres/postgres \
  pactfoundation/pact-broker

# Publish contracts from consumer CI pipeline
npx pact-broker publish ./pacts \
  --broker-base-url=https://pact.mycompany.com \
  --consumer-app-version=$GIT_SHA \
  --branch=$GIT_BRANCH

# Verify against broker in provider CI pipeline
# Provider always verifies against the latest consumer contracts

The Broker also provides a "can-i-deploy" check:

# Before deploying OrdersService to production, verify:
# "Is the version of OrdersService we're deploying compatible with
#  the version of PaymentsService currently in production?"
npx pact-broker can-i-deploy \
  --pacticipant OrdersService \
  --version $GIT_SHA \
  --to-environment production \
  --broker-base-url https://pact.mycompany.com

This check fails if the contract has not been verified by the provider, or if the provider has changed in a way that breaks the consumer's expectations. The can-i-deploy check is the mechanism that makes contract testing a deployment gate rather than just a test.

Contract testing without Pact

For smaller teams or simpler interfaces, a lighter-weight approach uses JSON Schema to validate that both sides agree on the request and response structure:

// shared/schemas/payments-api.schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "ChargeRequest": {
      "type": "object",
      "required": ["paymentMethodId", "amount", "currency"],
      "properties": {
        "paymentMethodId": { "type": "string" },
        "amount": { "type": "integer", "minimum": 1 },
        "currency": { "type": "string", "enum": ["usd", "eur", "gbp"] }
      }
    },
    "ChargeResponse": {
      "type": "object",
      "required": ["id", "status", "amount"],
      "properties": {
        "id": { "type": "string" },
        "status": { "type": "string", "enum": ["succeeded", "pending", "failed"] },
        "amount": { "type": "integer" }
      }
    }
  }
}
// Consumer: validate requests against schema before sending
import Ajv from 'ajv';
import schema from '../shared/schemas/payments-api.schema.json';

const ajv = new Ajv();
const validateChargeRequest = ajv.compile(schema.definitions.ChargeRequest);

function charge(request: ChargeRequest) {
  if (!validateChargeRequest(request)) {
    throw new Error(`Invalid charge request: ${JSON.stringify(validateChargeRequest.errors)}`);
  }
  return fetch('/v1/charges', { method: 'POST', body: JSON.stringify(request) });
}

// Provider: validate incoming requests against the same schema
app.post('/v1/charges', (req, res) => {
  if (!validateChargeRequest(req.body)) {
    return res.status(400).json({ error: 'Invalid request', details: validateChargeRequest.errors });
  }
  // ...
});

Both consumer and provider import and validate against the same schema. A change to the schema is a visible, explicit change that both teams must coordinate.

The schema approach is less powerful than Pact — it does not capture state handlers, response matching, or the full interaction lifecycle — but it is simpler to add and provides the core benefit: both sides are constrained to the same interface definition.

When to add contract tests

Contract tests are most valuable when:

  • Two teams own two services that interact
  • One team regularly makes changes to their API
  • The services are too coupled to deploy independently (breaking changes propagate)
  • End-to-end tests are too slow or brittle to catch interface mismatches reliably

Contract tests are less valuable when:

  • Both services are owned by the same team and deployed together
  • The interface is simple and rarely changes
  • The services are already covered by fast integration tests that run both services

The overhead of contract testing — setting up the framework, writing the consumer interactions, implementing provider state handlers — is approximately one to two days of engineering time per interface. The payoff is preventing the kind of four-hour incident that caused the Orders/Payments integration failure. For interfaces that change regularly across team boundaries, the investment pays for itself in the first incident prevented.

Contract testing fills the gap between unit tests (which test in isolation) and full integration tests (which require both services running). It catches interface mismatches at the cheapest possible moment — before deployment — rather than in production, where the cost of the miss is an incident and a support escalation.

Contract testing for async message-based interfaces

The Pact examples above cover synchronous HTTP interfaces. Many service integrations are asynchronous: service A publishes a message to a queue or event bus; service B consumes it. The contract in this case is the shape of the message, not the shape of an HTTP request.

Pact supports message-based contracts:

// Consumer side: defines what messages it expects to receive
// notification-service/src/__tests__/order-events.contract.spec.ts
import { MessageConsumerPact, synchronousBodyHandler } from '@pact-foundation/pact';

const messagePact = new MessageConsumerPact({
  consumer: 'NotificationService',
  provider: 'OrdersService',
  dir: './pacts',
});

describe('Order placed event contract', () => {
  test('notification service handles order.placed events', async () => {
    await messagePact
      .given('an order has been placed')
      .expectsToReceive('an order.placed event')
      .withContent({
        eventType: 'order.placed',
        orderId: like('order_123'),
        userId: like('user_456'),
        totalAmount: integer(2999),
        currency: string('usd'),
        placedAt: like('2026-07-16T10:00:00Z'),
      })
      .withMetadata({ contentType: 'application/json' })
      .verify(synchronousBodyHandler(async (message) => {
        // The notification service handler must process this message without error
        const notificationService = new NotificationService();
        await notificationService.handleOrderPlaced(message);
      }));
  });
});
// Provider side: verifies that order events match the consumer's expectations
// orders-service/src/__tests__/message-contract.spec.ts
import { MessageProviderPact } from '@pact-foundation/pact';

describe('Order events message contract', () => {
  test('order.placed events match NotificationService contract', async () => {
    const verifier = new MessageProviderPact({
      provider: 'OrdersService',
      pactUrls: ['../notification-service/pacts/NotificationService-OrdersService.json'],
      messageProviders: {
        'an order.placed event': async () => ({
          contents: {
            eventType: 'order.placed',
            orderId: 'order_123',
            userId: 'user_456',
            totalAmount: 2999,
            currency: 'usd',
            placedAt: new Date().toISOString(),
          },
          metadata: { contentType: 'application/json' },
        }),
      },
    });

    await verifier.verify();
  });
});

When the Orders team changes the event shape — adding a field, removing a field, or changing a field name — the message provider verification fails in their CI pipeline, preventing the breaking change from being deployed.

Evolving contracts without breaking consumers

Providers that need to change their API while maintaining backwards compatibility can use versioned contracts. The standard approach is:

  1. Add new fields without removing old fields. Old consumers that do not use the new field are unaffected. New consumers can optionally use the new field.

  2. Deprecate old fields before removing them. Announce the deprecation, give consumers a migration window, then remove the field after all consumers have been updated.

  3. Version the API endpoint when changes are breaking and the migration window is extended.

Pact's pending contracts feature supports the deprecation workflow: a contract that is marked pending does not fail the provider's CI if the provider does not yet satisfy it. This allows consumers to define their future expectations before the provider implements them:

# Provider verifies against all contracts, but pending contracts are non-blocking
npx pact-broker publish-contracts \
  --pacticipant NotificationService \
  --contract ./pacts/NotificationService-OrdersService.json \
  --version $GIT_SHA \
  --status pending  # Does not fail provider CI yet

The pending state is removed once the provider implements the new contract, at which point both consumer and provider must satisfy it.

Practical rollout for teams without contract testing

A team starting from zero can add contract testing incrementally, starting with the interface that breaks most often:

  1. Identify the highest-risk interface — the one where both teams are actively making changes and where breakage is discovered in integration or production.

  2. Add consumer-side tests first — define interactions for the happy path and the most common error cases. Generate the Pact file. This provides immediate value: the consumer has a test that verifies its assumptions about the provider's behavior.

  3. Add provider-side verification — wire the Pact file into the provider's test suite. The first run may reveal existing mismatches. Fix them, and the two services are now contractually aligned.

  4. Integrate with CI — add the consumer test to the consumer's CI pipeline and the provider verification to the provider's pipeline. From this point, any change that would break the contract fails CI before deployment.

The initial investment per interface is one to two days. Each subsequent interface is faster because the tooling is already set up. A team of two services can achieve contract testing coverage for their primary interface in a single sprint.

The deeper benefit of contract testing is cultural as much as technical. Teams that practice consumer-driven contract testing develop an explicit, shared vocabulary for their interfaces. The consumer's interactions document what the consumer actually sends and what it actually needs from the response — not what the provider team believes the consumer sends. This documentation is generated from running tests, so it is always current. An interface that changed three months ago has a contract that reflects the change. Teams no longer need to dig through OpenAPI specs or ask "which fields does the Orders service actually use?" — the contract file answers that question with test coverage as evidence.

The Orders/Payments incident that caused a four-hour outage was a communication failure as much as a technical failure. The Payments team did not know which consumers depended on paymentMethodId. Contract testing would have answered that question automatically and turned a surprise production breakage into a blocked CI build on the Payments team's side.

Comments

No comments yet. Be the first!

Sign in to leave a comment.