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.
Why Functional Tests Don’t Catch Contract Breaks
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 test | Contract test | |
|---|---|---|
| Question | Does the API do the right thing? | Does the API still speak the language consumers expect? |
| Checks | Business logic, values, side effects | Fields, types, required/optional, status codes, headers |
| Owned by | Provider team | Shared between provider and consumers |
| Catches | Wrong totals, broken rules | Renamed/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:
https://sqatools.in/wp-json/sqa-api/v1/schema/usercurl -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.
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
| Change | Breaking? |
|---|---|
| Add an optional response field | No |
| Remove or rename a response field | Yes |
| Change a field type (number → string) | Yes |
| Add a required request field / parameter | Yes |
| Add a new enum value in a response | Often yes (clients with strict enums crash) |
| Add a new endpoint | No |
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.
- 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.
- The pact file is published, usually to a Pact Broker (self-hosted) or PactFlow.
- 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.
- can-i-deploy checks the broker before release: “is this version compatible with everything in production?”
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.
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.
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
| Situation | Recommended |
|---|---|
| One team, one public API, many unknown consumers | Schema tests + OpenAPI diff |
| Many internal microservices, known consumers | Consumer-driven contracts (Pact) + broker |
| Partner integrations with a signed spec | OpenAPI as contract + schema tests on both sides |
| GraphQL | Schema checks (GraphQL Inspector) + Pact’s GraphQL support |
| Event-driven (Kafka messages) | Pact message contracts or schema registry compatibility rules |
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-deploygate, so incompatible versions still reach production.
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.