API Contract Testing: Schema, OpenAPI and Consumer-Driven Pact

API Contract Testing: Schema, OpenAPI and Consumer-Driven Pact

A backend developer renames price to amount. Every backend test still passes. The release goes out, and the Android app shows “Rs null” on every product. Contract testing exists to stop exactly this: it checks that the API keeps the promises its consumers rely on, automatically, on every change. In this lesson you’ll learn the three levels of API contract testing, and run a real consumer-driven contract with Pact against the live practice API.

Quick answerAPI contract testing verifies that a provider API and its consumers agree on the shape of requests and responses: endpoints, fields, types, required values and status codes. Three common approaches: (1) schema contract tests, which validate responses against a JSON Schema/OpenAPI; (2) spec diffing, which flags breaking changes between OpenAPI versions in CI; and (3) consumer-driven contract testing with tools like Pact, where each consumer records the interactions it needs and the provider’s build verifies it still satisfies all of them.
Advertisement

Why Functional Tests Don’t Catch Contract Breaks

Real-life example: the renamed field The backend team owns GET /products/{id} and its tests. They rename price to amount and update their own tests, which all pass. Nobody owns the question “does the Android app, the iOS app, the partner feed and the search service still understand this response?” A contract test answers it in CI before release.
Functional testContract test
QuestionDoes the API do the right thing?Does the API still speak the language consumers expect?
ChecksBusiness logic, values, side effectsFields, types, required/optional, status codes, headers
Owned byProvider teamShared between provider and consumers
CatchesWrong totals, broken rulesRenamed/removed fields, type changes, new required inputs

Level 1: Schema Contract Tests

The simplest contract: validate every response against a schema. The practice API publishes its own user schema at GET /schema/user:

GEThttps://sqatools.in/wp-json/sqa-api/v1/schema/user
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/schema/user"

Because it says "additionalProperties": false and lists every required field and type, any renamed, removed, retyped or extra field fails validation. You already wrote this test in the JSON Schema lesson; module 6 turns it into a reusable framework helper.

Strength: easy, no coordination needed. Weakness: the provider defines the schema, so it validates what the provider thinks consumers need, not what they actually use.

Advertisement

Level 2: OpenAPI Diff (Breaking-Change Detection)

If the team keeps an OpenAPI file in Git, compare the old and new versions on every pull request:

# Fails the build on breaking changes such as removed fields or new required parameters
oasdiff breaking main-openapi.yaml pr-openapi.yaml --fail-on ERR
ChangeBreaking?
Add an optional response fieldNo
Remove or rename a response fieldYes
Change a field type (number → string)Yes
Add a required request field / parameterYes
Add a new enum value in a responseOften yes (clients with strict enums crash)
Add a new endpointNo

Strength: catches changes before code is even deployed. Weakness: only as good as the spec; if the code drifts from the spec, nothing notices (combine with Level 1).

Level 3: Consumer-Driven Contract Testing with Pact

In consumer-driven contracts, each consumer writes down exactly what it sends and what it needs back. That file (the pact) is shared with the provider, whose build replays it against the real API. The provider can change anything consumers don’t use, and gets told immediately if it breaks something they do use.

  1. Consumer test runs the consumer’s real client code against a Pact mock server that returns the expected response. Pact records the interaction into a JSON pact file.
  2. The pact file is published, usually to a Pact Broker (self-hosted) or PactFlow.
  3. Provider verification replays each recorded request against the real provider and compares the real response with the contract, using matchers (type, regex) instead of exact values.
  4. can-i-deploy checks the broker before release: “is this version compatible with everything in production?”
Real-life example: microservices teams In a company with 40 microservices owned by different teams, end-to-end environments are slow and flaky. Consumer-driven contracts let the checkout team and the pricing team each test independently, while the broker guarantees their versions are compatible before either deploys.
Advertisement

Hands-On: A Real Pact Contract with the Practice API

Scenario: a MobileApp consumer needs id, name, role and active from GET /users/1. Install Pact into your Playwright project:

npm install -D @pact-foundation/pact
import { test, expect } from '@playwright/test';
import path from 'node:path';
import { PactV3, MatchersV3, Verifier } from '@pact-foundation/pact';

const { integer, string, regex, boolean } = MatchersV3;
const PACT_DIR = path.join(__dirname, '..', '..', 'pacts');

test.describe.configure({ mode: 'serial' });

test('consumer: mobile app expects this shape from GET /users/1', async () => {
  const provider = new PactV3({ consumer: 'MobileApp', provider: 'SqaUsersApi', dir: PACT_DIR });

  provider
    .given('user 1 exists')
    .uponReceiving('a request for user 1')
    .withRequest({ method: 'GET', path: '/users/1', headers: { Accept: 'application/json' } })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': regex('application/json.*', 'application/json') },
      body: {
        data: {
          id: integer(1),
          name: string('Asha Verma'),
          role: regex('admin|editor|viewer', 'admin'),
          active: boolean(true),
        },
      },
    });

  await provider.executeTest(async (mock) => {
    // This is the consumer's real client code calling the Pact mock server
    const res = await fetch(`${mock.url}/users/1`, { headers: { Accept: 'application/json' } });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.data.name).toBe('Asha Verma');
  });
});

test('provider: the real API honours the contract', async () => {
  test.setTimeout(60_000);
  await new Verifier({
    provider: 'SqaUsersApi',
    providerBaseUrl: 'https://sqatools.in/wp-json/sqa-api/v1',
    pactUrls: [path.join(PACT_DIR, 'MobileApp-SqaUsersApi.json')],
    stateHandlers: { 'user 1 exists': async () => { /* data is fixed on the practice API */ } },
    logLevel: 'warn',
  }).verifyProvider();
});

Both tests pass against the live API. Notice the contract only mentions the four fields the app uses; the provider can add fields like city freely. The matchers mean “an integer like 1”, “a string like Asha Verma”, “one of admin/editor/viewer”, so the contract doesn’t break when data values change.

Advertisement

See it fail on purpose

Change name: string('Asha Verma') to full_name: string('Asha Verma') in the consumer test, as if the app expected a renamed field. The consumer test still passes (it talks to the mock), but provider verification fails: the real API has no full_name. The real verifier output:

Verifying a pact between MobileApp and SqaUsersApi
  a request for user 1
     Given user 1 exists
    returns a response which
      has status code 200 (OK)
      includes headers "Content-Type" with value "application/json" (OK)
      has a matching body (FAILED)

1.1) has a matching body
     $.data -> Actual map is missing the following keys: full_name

That’s the early warning you want: precise, in CI, before any user sees “null”.

Provider States

given('user 1 exists') is a provider state: a precondition the provider must set up before replaying the request. In real projects the state handler seeds the database (create user 1, create an order with status “shipped”…). The practice API’s data is fixed, so the handler is empty.

Advertisement

Pact in CI/CD

# Consumer pipeline: run consumer tests, then publish the pact
npx pact-broker publish ./pacts --consumer-app-version "$GIT_SHA" --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_URL" --broker-token "$PACT_BROKER_TOKEN"

# Provider pipeline: verify pacts fetched from the broker (Verifier with pactBrokerUrl), publish results

# Before deploying either side
npx pact-broker can-i-deploy --pacticipant MobileApp --version "$GIT_SHA" --to-environment production

The broker shows a matrix of which consumer and provider versions are compatible, and the can-i-deploy gate prevents incompatible releases.

Choosing an Approach

SituationRecommended
One team, one public API, many unknown consumersSchema tests + OpenAPI diff
Many internal microservices, known consumersConsumer-driven contracts (Pact) + broker
Partner integrations with a signed specOpenAPI as contract + schema tests on both sides
GraphQLSchema checks (GraphQL Inspector) + Pact’s GraphQL support
Event-driven (Kafka messages)Pact message contracts or schema registry compatibility rules
Advertisement

Common Mistakes

  • Using exact values in contracts ("name": "Asha Verma") instead of matchers, so every data change breaks the contract.
  • Putting business-logic checks in contracts. Contracts check shape; functional tests check behaviour.
  • Consumers listing every field “just in case”: the contract should contain only what the consumer really uses.
  • Never running provider verification in the provider’s own pipeline.
  • Skipping the can-i-deploy gate, so incompatible versions still reach production.
Advertisement

Interview Questions: API Contract Testing

What is API contract testing?

Testing that a provider API and its consumers agree on the structure of requests and responses, such as fields, types, required values and status codes, so changes on one side don’t break the other.

What is consumer-driven contract testing?

An approach where each consumer defines the interactions it relies on in a contract (pact), and the provider verifies it satisfies all consumer contracts in its own build. Consumers drive what must stay stable.

What is Pact?

An open-source consumer-driven contract testing tool. Consumer tests run against a Pact mock server and generate pact files; the provider replays them with the Pact verifier; a Pact Broker shares contracts and answers can-i-deploy.

How is contract testing different from functional testing?

Functional tests verify behaviour and business logic. Contract tests verify the interface shape both sides depend on. Both are needed.

What are provider states in Pact?

Named preconditions, such as “user 1 exists”, that the provider sets up (usually by seeding data) before replaying an interaction during verification.

Why use matchers instead of exact values in contracts?

Matchers check type and format (integer, string, regex, each-like arrays) rather than exact data, so contracts stay valid when real data changes while still catching structural breaks.

What is a breaking change?

A change that can break existing consumers: removing or renaming fields, changing types, adding required request fields or parameters, removing endpoints, or adding enum values consumers don’t handle.

What is can-i-deploy?

A Pact Broker command that checks whether a given application version is compatible with the versions of its consumers and providers in a target environment, used as a deployment gate.

Can JSON Schema validation be considered contract testing?

Yes, it’s a provider-side form of contract testing. It ensures responses follow the published schema, but unlike consumer-driven contracts it doesn’t capture what each consumer actually uses.

When would you not use Pact?

For public APIs with many unknown consumers, where schema tests and OpenAPI breaking-change checks are more practical, or for very small systems where the coordination cost outweighs the benefit.

Advertisement
View the full API testing syllabus