
API Contract Testing: Prevent Breaking Clients Before Release
API contract testing catches breaking API changes before they reach clients. It protects the request shapes, response fields, error formats, status codes, enum values, and compatibility rules that real clients depend on, even when the endpoint still works for the newest version of your own code.
The failure mode is familiar: tests pass, the API deploys, and a mobile app, partner integration, SDK, or older frontend starts failing because a "small cleanup" changed the contract. A field was renamed. An enum became stricter. A required request header was added. An error response changed shape. None of those changes looked dangerous inside the service, but they were breaking changes at the client boundary.
This article is part of the API Correctness hub. It pairs especially well with How to Write API Integration Tests and API Versioning Without Breaking Clients: integration tests prove real endpoint behavior, versioning gives you a policy for incompatible evolution, and contract testing makes accidental incompatibility visible in CI.
What API Contract Testing Protects
An API contract is the agreement between a provider and its clients.
For an HTTP API, that agreement usually includes:
- paths and methods
- required headers
- request body fields
- query parameters
- response status codes
- response body shape
- nullable fields
- enum values
- pagination format
- error code and error body shape
- versioning and deprecation behavior
The OpenAPI Specification describes a standard interface description for HTTP APIs. In practical terms, an OpenAPI document lets humans and tools understand the capabilities of a service without reading its source code.
That makes OpenAPI a useful provider-side contract artifact. It can describe what the API claims to support, and a diff between two versions can flag incompatible schema changes before a pull request merges.
But OpenAPI is not the whole story.
Some client expectations are behavioral:
- "A cancelled subscription returns
409, not404." - "A missing optional field means unknown, not false."
- "A duplicate request with the same idempotency key replays the original response."
- "The mobile app branches on
error.code, not the English message." - "The
nextCursorfield remains present until the collection is exhausted."
Those expectations need examples and tests, not only schema.
Good API contract testing combines both:
- a written provider contract, often OpenAPI
- compatibility rules for what counts as breaking
- consumer examples for behavior clients actually rely on
- CI checks that compare the proposed contract against the published one
The Breaking Change That Looks Safe
Imagine a GET /api/orders/{id} response.
Current contract:
{
"id": "ord_123",
"status": "paid",
"totalCents": 4200,
"currency": "USD",
"customer": {
"id": "cus_456",
"email": "ada@example.com"
}
}
An engineer removes customer.email because the backend no longer needs it.
New response:
{
"id": "ord_123",
"status": "paid",
"totalCents": 4200,
"currency": "USD",
"customer": {
"id": "cus_456"
}
}
The endpoint still works. The database query is faster. The newest frontend code may not read the field. The service's own tests may still pass.
But an older mobile app might still display a receipt screen using customer.email. A partner integration might export the field into a fulfillment system. An SDK type might treat the field as required.
From the provider's point of view, the change was cleanup. From the client's point of view, it was a breaking change.
Contract testing exists for exactly this kind of mismatch.
Contract Tests Are Different From Integration Tests
API integration tests and contract tests overlap, but they answer different questions.
| Test type | Main question | Typical failure caught |
|---|---|---|
| API integration test | Does the real endpoint produce correct behavior through routing, auth, persistence, and serialization? | Transaction bug, auth bypass, validation gap, persistence mismatch |
| Provider contract diff | Did the documented API contract change in a breaking way? | Removed field, changed type, required request field, renamed response property |
| Consumer contract test | Does the provider still satisfy the behavior a specific client relies on? | Error code changed, enum handling changed, response example no longer matches |
Integration tests should still assert important response contracts through the real request path. That is why the API integration testing guide includes focused response and error assertions.
Contract testing adds a different layer: it compares the API's public promise against what clients are allowed to depend on.
If integration tests are about correctness inside one service boundary, contract tests are about compatibility across service boundaries.
Start With A Written Contract
The first step is to produce a contract artifact from the current released API.
For HTTP APIs, that usually means OpenAPI:
paths:
/api/orders/{id}:
get:
operationId: getOrder
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Order details
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
The OpenAPI learning docs describe paths as the container for API operations, with each path item describing the HTTP operations available on that endpoint. That structure is what makes generated docs, schema diffs, and compatibility checks possible.
The important operational habit is this:
Compare the proposed contract against the last published contract, not just against code in the same branch.
If CI generates OpenAPI from the pull request and compares it only to itself, it proves nothing about backward compatibility.
A useful workflow keeps a baseline copy of the production contract:
contracts/
production/openapi.json
candidate/openapi.json
Then CI does three checks:
- generate the candidate contract from the branch
- diff candidate against production
- fail or require approval when the diff is incompatible
The production contract is the client's world. The candidate contract is your proposed change. The diff is where accidental breakage becomes visible.
Define What Counts As Breaking
No contract test is useful until the team agrees on compatibility rules.
Microsoft's Graph API documentation gives concrete examples of non-backward-compatible changes, including changes to URLs or request/response fundamentals, removing or renaming declared properties, changing property types, removing APIs or parameters, and adding a required request header. That is a good practical starting point for your own policy.
For most JSON HTTP APIs, a working compatibility table looks like this:
| Change | Usually breaking? | Why |
|---|---|---|
| Remove a response field | Yes | Existing clients may read it |
| Rename a response field | Yes | Equivalent to remove plus add |
Change number to string | Yes | Client parsing and generated types can fail |
| Add a required request field | Yes | Existing clients will not send it |
| Add a required header | Yes | Existing clients will not know about it |
| Tighten validation on existing input | Often | Previously accepted requests may fail |
| Add optional response field | Usually no | Tolerant clients should ignore unknown fields |
| Add optional request field with default | Usually no | Existing clients can omit it |
| Add enum value | Depends | Tolerant clients are fine; exhaustive clients may fail |
| Change error body shape | Often | Clients may branch on stable error codes |
| Change pagination shape | Often | Clients may depend on cursor and page fields |
Do not hide "depends" behind vague review comments. Write the rule.
For example:
Adding an enum value is compatible for server-to-client fields only when all supported clients
are required to handle unknown enum values. If any supported generated SDK exposes a closed enum,
the change requires compatibility review.
This is the kind of rule a contract test cannot invent. The test can enforce policy only after the policy exists.
Compare OpenAPI Changes In CI
The simplest contract gate is an OpenAPI diff.
The exact tool matters less than the workflow:
contract:
steps:
- yarn generate:openapi
- openapi-diff contracts/production/openapi.json build/openapi.json
- yarn test:contracts
In a real pipeline, split the result into three categories:
| Diff result | CI behavior | Example |
|---|---|---|
| Compatible | Pass | Added optional response field |
| Suspicious | Require review | Added enum value consumed by generated SDK |
| Breaking | Fail or require version bump | Removed response field |
Not every breaking change should be blocked forever. Sometimes the change is intentional. But intentional breaking changes need the release path from API Versioning Without Breaking Clients: a new version, migration guide, support window, telemetry, and deprecation plan.
The contract gate should force that decision before release, not after client errors appear.
Add Consumer Examples For Behavior The Schema Cannot Prove
Schema diffing catches shape changes. It does not catch every client expectation.
That is where consumer examples help.
The Pact documentation describes contract testing as checking each application in isolation against a shared understanding documented in a contract. In Pact's consumer-driven model, the contract is generated from consumer tests and describes concrete request/response interactions the provider must satisfy.
You do not need to adopt Pact on day one to use the idea. Start by writing down the examples clients actually depend on.
const consumerExamples = [
{
name: 'mobile receipt screen reads customer email',
request: {
method: 'GET',
path: '/api/orders/ord_paid',
headers: { Authorization: 'Bearer mobile-test-token' },
},
expect: {
status: 200,
body: {
id: expect.any(String),
status: 'paid',
totalCents: 4200,
currency: 'USD',
customer: {
id: expect.any(String),
email: 'ada@example.com',
},
},
},
},
]
Then run those examples against the provider in CI:
for (const example of consumerExamples) {
it(example.name, async () => {
await seedProviderState(example.name)
const response = await request(app)
[example.request.method.toLowerCase()](example.request.path)
.set(example.request.headers)
expect(response.status).toBe(example.expect.status)
expect(response.body).toMatchObject(example.expect.body)
})
}
This test is intentionally narrow. It is not trying to retest every business rule. It protects the exact provider behavior a consumer needs to keep working.
Pact's provider verification model follows the same broad shape: replay the expected interactions against the provider and compare the actual response with the expected response. That is valuable because the provider proves compatibility without requiring every consumer to be deployed beside it.
Test Error Contracts Too
Many teams protect successful responses and forget error responses.
Clients often depend on errors more than backend teams expect:
- retry on
rate_limited - show a specific form message for
invalid_coupon - treat
inventory_unavailableas recoverable - refresh auth on
token_expired - stop retrying on
duplicate_idempotency_key
If you change the error contract, client behavior can break even when status codes look reasonable.
Example contract:
{
"error": {
"code": "inventory_unavailable",
"message": "One or more items are no longer available.",
"fields": {
"items.0.quantity": "Only 1 item remains."
},
"requestId": "req_123"
}
}
Protect the stable parts:
expect(response.status).toBe(409)
expect(response.body).toMatchObject({
error: {
code: 'inventory_unavailable',
fields: {
'items.0.quantity': expect.any(String),
},
},
})
Do not assert the exact English message unless the message is part of the public contract. English copy changes often. Machine-readable error codes should be much more stable.
This is also where API Idempotency Keys: Prevent Duplicate Requests Safely matters. If clients rely on a stable duplicate-request response, that response is a contract, not an implementation detail.
Run Contract Checks Before Deployment
Contract testing is most useful before release, while the change is still cheap to fix.
A practical release workflow:
- Generate the candidate OpenAPI document from the branch.
- Diff it against the production OpenAPI document.
- Run provider integration tests for important endpoints.
- Run consumer contract examples.
- Fail the build for incompatible changes unless a versioning plan is attached.
- Publish the new contract only after deployment succeeds.
- Keep the previous production contract available for rollback and comparison.
This sequence matters.
If you publish the candidate contract before the deployment succeeds, clients may generate SDKs against behavior that is not live. If you update the production baseline before the diff runs, the breaking change becomes the new normal. If you run contract tests after deployment only, you have turned a pre-release safety check into an incident detector.
The same discipline applies to database-backed API changes. A response field may depend on a new column, a backfill, or a staged read switch. If the database migration is not backward-compatible, the API contract may become unstable during rollout. Treat that as part of the release plan, not only as a storage concern.
What Contract Testing Does Not Prove
Contract testing is not a replacement for API integration tests, load tests, security review, or production monitoring.
It does not prove:
- the business workflow is correct
- the database transaction is safe
- authorization rules are complete
- performance is acceptable
- retries are idempotent
- concurrency is controlled
- old and new code can run together during rollout
Those risks need other tests and design checks.
This is why contract testing belongs in a layered API correctness strategy:
- Integration tests prove real endpoint behavior.
- Race-condition tests prove overlapping writes cannot violate invariants.
- Idempotency tests prove retries do not repeat side effects.
- Versioning policy gives breaking changes a migration path.
- Contract tests protect compatibility at the client boundary.
When all of those layers are missing, a passing test suite can still ship broken behavior. That broader failure pattern is covered in Why Tests Pass But Production Still Breaks.
Practical Checklist
Before merging an API change, check:
- Is the production OpenAPI contract stored somewhere CI can compare against?
- Does CI generate the candidate contract from the branch?
- Are breaking-change rules written down?
- Does the OpenAPI diff fail or require approval for incompatible changes?
- Are important consumer examples represented as contract tests?
- Are error codes and error body shapes protected?
- Are enum and pagination changes reviewed for client compatibility?
- Does an intentional breaking change have a new version or deprecation plan?
- Are SDKs, docs, and examples updated before clients need them?
- Is the new contract published only after the deployment succeeds?
If those checks feel heavy, start with two endpoints:
- the highest-traffic external API endpoint
- the endpoint whose response clients most often misuse or depend on
Contract testing does not need to cover the whole API on day one. It needs to make the most expensive accidental breaks visible before release.
Final Takeaway
API contract testing is not paperwork around an API. It is a release safety mechanism for client compatibility.
The core habit is simple: keep a published contract, compare proposed changes against it, encode the client behaviors that matter, and make incompatible changes choose an explicit versioning path.
That turns "we did not realize this was breaking" into a reviewable decision before clients pay the cost.