API Design Testing

Contract Testing

Verifying that services honour their API contracts without deploying together — consumer-driven contract testing with Pact, provider verification, bi-directional contract testing, Pact Broker, OpenAPI-based contract testing with Schemathesis and Dredd, and why end-to-end integration tests don't replace contracts.

⏱ 14 min read

What it is

Contract testing verifies that a service correctly implements the interface expectations of its consumers. A contract is a formal specification of the interactions between a consumer (caller) and a provider (API), capturing the requests the consumer makes and the responses it expects. Contract tests run independently against each service without requiring a live integrated environment.

There are two main approaches:

  • Consumer-driven contract testing (CDCT) — consumers define the contract by writing tests that capture their expectations. The contract is published and the provider verifies it runs against the real provider. Pact is the dominant framework.
  • Provider-driven contract testing — the provider defines the contract (e.g., an OpenAPI spec) and consumers test against it. Schemathesis and Dredd run against the provider to verify the implementation matches the spec.

Why it exists

In microservices architectures, services are deployed independently. Integration tests that require multiple services to be running simultaneously are slow, fragile, expensive to maintain, and block independent deployment. Contract testing detects interface incompatibilities early — in CI, before deployment — without the overhead of full integration test environments. The question changes from "do all these services work together in a test environment?" to "does this service correctly implement the contract its consumers rely on?"

Consumer-driven contracts with Pact

// Consumer test (JavaScript, Pact)
const { Pact } = require('@pact-foundation/pact');

describe('Orders API consumer', () => {
  const provider = new Pact({
    consumer: 'orders-ui',
    provider: 'orders-api',
    port: 1234
  });

  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());

  it('retrieves an order by ID', async () => {
    await provider.addInteraction({
      state: 'order 42 exists',
      uponReceiving: 'a request for order 42',
      withRequest: {
        method: 'GET',
        path: '/orders/42',
        headers: { Authorization: like('Bearer token') }
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: like('42'),
          status: term({ generate: 'confirmed', matcher: 'pending|confirmed|shipped|delivered' }),
          total: like(149.99),
          createdAt: iso8601DateTime()
        }
      }
    });

    // Call the real consumer code against the Pact mock server
    const order = await orderService.getOrder('42');
    expect(order.id).toBe('42');
    expect(order.status).toBe('confirmed');

    // Pact records the interaction as the contract
  });
});

Pact matching rules

Pact contracts use matchers rather than exact value comparison — the contract captures the shape the consumer needs, not specific test data values. Key matchers:

  • like(value) — match by type (the response must have a string/number/object of the same type).
  • eachLike(item) — match an array where each element matches the template.
  • term({ generate, matcher }) — match by regex. The generate value is used in the mock; the matcher regex is verified against the real provider.
  • iso8601DateTime() — validates date-time format.

Using matchers is critical: if you assert total: 149.99 exactly, the contract breaks if the provider returns a different but valid order. Assert structure and type, not ephemeral values.

Provider verification

// Provider verification (JavaScript, Pact)
const { Verifier } = require('@pact-foundation/pact');

describe('Orders API provider verification', () => {
  it('verifies consumer contracts', () => {
    return new Verifier({
      provider: 'orders-api',
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://pact-broker.example.com',
      consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
      providerStatesHandler: async (state) => {
        // Set up test data for provider state
        if (state === 'order 42 exists') {
          await db.seed({ id: '42', status: 'confirmed', total: 149.99 });
        }
      }
    }).verifyProvider();
  });
});

The provider fetches the contracts from the Pact Broker and replays each interaction against the real running provider. For each interaction, the provider state handler sets up the required test data. The verifier checks that responses match the contract's matchers. Provider verification runs in the provider's CI pipeline — a failing verification means the provider has made a breaking change relative to a deployed consumer.

Pact Broker and can-i-deploy

The Pact Broker stores contracts published by consumers and verification results published by providers. It enables the critical can-i-deploy query: "Is it safe to deploy version X of service A, given the currently deployed versions of all its dependencies?"

# Check if orders-api v2.3.0 can be deployed to production
pact-broker can-i-deploy \
  --pacticipant orders-api \
  --version 2.3.0 \
  --to-environment production

# Output:
# Computer says yes ✔
# orders-api v2.3.0 is compatible with orders-ui v1.7.2 (deployed to production)

This makes contract testing a deployment safety gate in CI/CD pipelines. PactFlow (hosted Pact Broker) adds bi-directional contract testing support.

Bi-directional contract testing

Bi-directional contract testing (BDCT), supported by PactFlow, bridges consumer-driven and provider-driven approaches. The consumer publishes its Pact contract; the provider publishes its OpenAPI spec. PactFlow performs the compatibility verification: it checks whether the provider's OpenAPI spec is a superset of the consumer's Pact contract without running either side's tests against a live server. This enables teams to adopt contract testing without requiring all parties to use Pact.

OpenAPI-based contract testing

Schemathesis

# Run Schemathesis against a live API
schemathesis run https://api.example.com/openapi.json \
  --checks all \
  --auth "Bearer $TOKEN" \
  --workers 4

# Output includes:
# - Schema violations (response doesn't match documented schema)
# - 5xx responses on valid inputs
# - Invalid status codes
# - Missing required response fields

Schemathesis uses property-based testing: it generates hundreds of test cases from the OpenAPI schema (valid inputs, edge cases, boundary values) and checks that the provider's responses match the documented schemas. It automatically finds issues that hand-written tests miss — undocumented fields, missing null handling, unexpected 500 errors on valid inputs.

Dredd

# Run Dredd against a live API using the OpenAPI spec
dredd openapi.yaml https://api.example.com \
  --hookfiles=dredd-hooks.js

# dredd-hooks.js — set up authentication and test data
before('orders > GET /orders > 200', (transaction, done) => {
  transaction.request.headers['Authorization'] = 'Bearer test-token';
  done();
});

Dredd runs each documented example request from the OpenAPI spec against the provider and verifies the response matches the documented status code and schema. More transaction-oriented (one test per documented example) vs Schemathesis's generative approach. Use Dredd for "do the documented examples work?" and Schemathesis for "does the API handle edge cases correctly?"

Why integration tests don't replace contracts

Integration tests require all services to be running simultaneously in a shared environment. This creates several problems in a microservices context:

  • Deployment coupling — all services must be at compatible versions in the integration environment; independent deployment becomes harder to achieve.
  • Slow feedback — integration environments take minutes to provision; contract tests run in seconds in isolation.
  • Flakiness — test-environment infrastructure issues cause false failures unrelated to code changes.
  • Incomplete coverage — integration tests only cover interactions tested against the specific version deployed in the environment; contract tests cover all deployed consumer versions.
  • No deployment safety gate — integration tests don't answer "is it safe to deploy this provider version given the consumers already in production?"

Contract testing answers the deployment compatibility question directly, cheaply, and without requiring a shared environment. Integration tests remain valuable for end-to-end workflow validation but are insufficient alone for interface compatibility verification in independent deployment workflows.

Pros and cons

Pros

  • Fast — contract tests run in milliseconds against mocks; no live environment needed for consumer tests.
  • Catches breaking changes before deployment — the provider can't deploy a change that breaks a published contract.
  • Documents real consumer usage — contracts describe exactly what each consumer uses, not the full API surface.
  • Enables independent deployment — can-i-deploy provides a deployment safety gate per consumer version.

Cons

  • Initial setup overhead — requires a Pact Broker or PactFlow, provider state management, and CI integration.
  • Pact contracts require both sides to adopt the same framework; bi-directional contracts alleviate this.
  • Contracts only cover documented interactions — they won't catch bugs in parts of the API no consumer exercises.
  • Provider states (test data setup) for complex scenarios can be difficult to maintain.

Decision checklist

  • Are consumer tests using matchers (type/format) rather than exact values?
  • Is the Pact Broker integrated into both consumer and provider CI pipelines?
  • Is can-i-deploy a required gate before deployment to each environment?
  • Are provider state handlers setting up appropriate test data for each interaction?
  • For OpenAPI-based testing, is Schemathesis running against staging in CI?
  • Are contracts versioned by consumer version (not just latest) to support rollbacks?
  • OpenAPI Specification — the contract format for provider-driven (Schemathesis, Dredd) contract testing.
  • API Versioning — contract testing makes breaking change detection explicit rather than version-based.
  • REST API Design — well-designed REST APIs have stable, testable contracts.

Further reading

  • Pact documentation — docs.pact.io
  • PactFlow Bi-Directional Contract Testing — pactflow.io/bi-directional-contract-testing
  • Schemathesis documentation — schemathesis.readthedocs.io
  • Dredd — dredd.readthedocs.io
  • Martin Fowler — Contract Testing — martinfowler.com/bliki/ContractTest.html
  • Ian Robinson — Consumer-Driven Contracts: A Service Evolution Pattern.