Skip to content
SDETRole
SDETRole

Lesson 9 of 9 · API Testing

Contract Testing

Catch breaking API changes before deployment with consumer-driven contracts: how Pact works, provider states, verification, the broker and can-i-deploy.

advanced 9 min read Updated

You will learn to

  • Explain what contract tests catch that functional and end-to-end tests miss
  • Describe the consumer-driven contract workflow from consumer test to provider verification
  • Write a consumer contract with flexible matchers and provider states
  • Use a broker and can-i-deploy to gate releases on contract compatibility

Prerequisites

  • REST APIs Explained
  • Designing API Assertions
  • API Automation with Playwright

In a system of many services, the most common integration failure is not a crash. It is a provider team renaming a field, changing a type or tightening validation, with every one of their own tests passing, and a consumer breaking in production the next morning. End-to-end tests can catch this, but only if they happen to exercise that field, and only after both services are deployed together into a shared environment.

Contract testing catches these breaks earlier and more cheaply. This lesson explains the consumer-driven approach popularised by Pact, a well-established open-source contract testing tool, with examples in Pact JS.

What a contract is

A contract is a record of the requests a consumer makes and the minimum it needs from each response. It is checked from both sides:

  • The consumer (a web app, mobile app or another service) proves its code works against the contract.
  • The provider (the API) proves it actually honours the contract.

If both sides pass, the two can be deployed independently with confidence that they will work together, without ever running them together in a test environment.

Test type What it proves Where it runs
Provider functional tests The API behaves correctly by its own spec Provider’s pipeline
Schema validation (OpenAPI) Responses match the provider’s published schema Provider’s pipeline
Contract tests The provider still gives each consumer what that consumer actually uses Both pipelines, joined by a broker
End-to-end tests Deployed services work together for a user journey Shared environment

Schema validation and contract testing are related but different. A schema describes everything the provider offers; a consumer contract describes what a consumer depends on. Removing an optional field may be valid by the schema and still break a consumer that relies on it.

The consumer-driven workflow

  1. Consumer test: the consumer’s unit-level test runs against a Pact mock server. The test declares the expected interaction (“given user 1 exists, GET /api/mock/users/1 returns a user with an integer id and a string name”), and the consumer’s real API client code calls the mock.
  2. Pact file: if the test passes, Pact writes a JSON contract file.
  3. Publish: the consumer pipeline publishes the contract to a Pact Broker, tagged with the consumer’s version (usually the Git commit).
  4. Provider verification: the provider’s pipeline fetches the contracts for its consumers, replays each request against a running instance of the provider, and checks every response satisfies the contract.
  5. Deploy gate: before either side deploys, can-i-deploy asks the broker whether the version being deployed has been verified against the versions already in the target environment.

The important property is that each side runs only its own code. The consumer never calls the real provider in its contract test, and the provider never needs the consumer running.

A consumer test in Pact JS

Here is a consumer, an admin web app, that displays users from the Practice API. Its API client:

// src/users-client.ts
export interface User {
  id: number;
  name: string;
  role: string;
}

export async function getUser(baseUrl: string, id: number): Promise<User> {
  const res = await fetch(`${baseUrl}/api/mock/users/${id}`, {
    headers: { Accept: 'application/json' },
  });
  if (!res.ok) throw new Error(`GET user ${id} failed: ${res.status}`);
  const { id: userId, name, role } = await res.json();
  return { id: userId, name, role };
}

The consumer contract test, using Pact JS (v12 or later) with Vitest or Jest:

// test/users-client.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { getUser } from '../src/users-client';

const { like, integer, string, regex } = MatchersV3;

const provider = new PactV3({ consumer: 'admin-web', provider: 'practice-users-api' });

describe('users client', () => {
  it('fetches a single user', async () => {
    provider
      .given('user 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/mock/users/1',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: {
          'Content-Type': regex('application/json.*', 'application/json; charset=utf-8'),
        },
        body: like({
          id: integer(1),
          name: string('Priya Shah'),
          role: string('tester'),
        }),
      });

    await provider.executeTest(async (mockServer) => {
      const user = await getUser(mockServer.url, 1);
      expect(user).toEqual({ id: 1, name: 'Priya Shah', role: 'tester' });
    });
  });
});

Notes on the design:

  • Only the fields the consumer uses are in the contract (id, name, role). The provider is free to add, change or remove email, createdAt and anything else without breaking this consumer. This is what makes contracts less brittle than full-response snapshots.
  • Matchers, not exact values: integer(1) means “any integer, for example 1”. Exact values would make verification fail whenever the provider’s test data differed.
  • Provider state: given('user 1 exists') names a precondition. The provider sets it up during verification; the consumer does not care how.
  • executeTest starts the mock server, runs your code against it, then checks that the expected request actually happened. A consumer test that never calls the mock fails.

On success, Pact writes pacts/admin-web-practice-users-api.json. An abridged version looks like this:

{
  "consumer": { "name": "admin-web" },
  "provider": { "name": "practice-users-api" },
  "interactions": [
    {
      "description": "a request for user 1",
      "providerStates": [{ "name": "user 1 exists" }],
      "request": {
        "method": "GET",
        "path": "/api/mock/users/1",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json; charset=utf-8" },
        "body": { "id": 1, "name": "Priya Shah", "role": "tester" },
        "matchingRules": {
          "body": {
            "$.id": { "combine": "AND", "matchers": [{ "match": "integer" }] },
            "$.name": { "combine": "AND", "matchers": [{ "match": "type" }] },
            "$.role": { "combine": "AND", "matchers": [{ "match": "type" }] }
          }
        }
      }
    }
  ],
  "metadata": { "pactSpecification": { "version": "3.0.0" } }
}

You should rarely edit this file by hand; it is generated from the consumer test, which keeps it honest.

Provider verification

On the provider side, a test starts the real API (usually locally or in a container, with a test database) and runs the Pact verifier against it:

// provider/test/pact.verify.test.ts
import { Verifier } from '@pact-foundation/pact';

it('honours its consumer contracts', async () => {
  await new Verifier({
    provider: 'practice-users-api',
    providerBaseUrl: 'http://localhost:4321',
    pactUrls: ['../admin-web/pacts/admin-web-practice-users-api.json'],
    stateHandlers: {
      'user 1 exists': async () => {
        // insert or reset user 1 in the provider's test database
      },
    },
  }).verifyProvider();
});

In a real setup the verifier pulls contracts from the broker instead of a file path and publishes the verification result back, so the broker knows which provider versions satisfy which consumer versions.

Provider states are where most teams spend their effort. Keep state names descriptive of data, not of implementation (“user 1 exists”, “user 1 has no orders”), and make handlers idempotent so verification can run repeatedly.

The broker and can-i-deploy

Without a broker, contract files get copied between repositories and quickly go stale. A Pact Broker (open source and self-hostable, or available as a hosted service) stores:

  • every contract, keyed by consumer name and version;
  • every verification result, keyed by provider name and version;
  • which versions are deployed to which environments.

That lets a pipeline ask a precise question before deploying:

pact-broker can-i-deploy \
  --pacticipant admin-web \
  --version "$GIT_COMMIT" \
  --to-environment production

The answer is yes only if this version of admin-web has a successful verification against the provider version currently in production. After deploying, the pipeline runs pact-broker record-deployment so the broker knows what is live. This turns contract testing from “we have some contract tests” into an actual release gate.

When contract testing is worth it

Contract testing pays off when:

  • several teams own services that call each other, and they deploy independently;
  • a provider has multiple consumers (web, mobile, partner integrations) with different needs;
  • end-to-end environments are slow, shared or unreliable.

It is usually not worth it for a single team owning both sides and deploying them together, for public APIs where you do not know your consumers (use versioning and schema checks instead), or for asynchronous messaging until you have the basics working for HTTP. Pact also supports message contracts for queues and events, which follow the same consumer-driven idea.

Practice exercise

A mobile app consumes the Practice API’s login and profile endpoints. It sends POST /api/mock/auth/login with a username and password, stores accessToken, then calls GET /api/mock/auth/me and displays name and role. Try both requests in the API Playground first.

Design the consumer contract: for each interaction, write the description, provider state (if any), request, and the response fields with the matching rule you would use (exact or by type). Explain how the provider can verify the /auth/me interaction when the token value in the contract is only an example.

Show a model answer

Interaction 1: successful login

  • Description: “a login with valid credentials”
  • Provider state: “a user tester exists with password practice123”
  • Request: POST /api/mock/auth/login, header Content-Type: application/json, body with username and password
  • Response: status 200; accessToken matched by type (any string; real tokens differ every time); tokenType matched exactly as "Bearer", because the app builds the header from it; expiresIn matched by type (integer). The app does not use anything else, so nothing else goes in.

Interaction 2: profile

  • Description: “a request for the current user”
  • Provider state: “the request is authenticated as tester”
  • Request: GET /api/mock/auth/me with Authorization matched by regex ^Bearer .+$ (example Bearer sdr_practice_7f3c9a1e)
  • Response: status 200; name and role matched by type.

Interaction 3 (optional, valuable): expired or invalid token

  • Request: GET /api/mock/auth/me with an invalid token; response status 401. The app depends on this to send the user back to login, so it is part of the contract. Do not include the detail text unless the app displays it.

Verifying interaction 2: during verification the provider receives the example token from the contract, which may not be valid. Two common solutions: the state handler for “the request is authenticated as tester” makes that example token valid in the provider’s test setup, or the verifier’s request filter replaces the Authorization header with a freshly issued test token before the request reaches the provider.

Key takeaways

  • Contract tests check that a provider still gives each consumer what it actually uses, without running the services together.
  • In the consumer-driven model, the consumer test generates the contract against a mock server, and the provider verifies it against the real API.
  • Put only the fields the consumer uses in the contract, and match by type or pattern rather than exact values.
  • Provider states set up data for verification; keep them descriptive and idempotent.
  • A broker plus can-i-deploy turns contracts into a release gate. Contract tests complement, and do not replace, functional and end-to-end tests.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →