Contract Tests vs Schema Diffs
This comparison belongs to Breaking Change Detection in the API Versioning & Deprecation reference. Teams frequently adopt one technique and assume it covers the other’s ground. It does not: a diff has complete coverage of a narrow thing, contract tests have narrow coverage of a broad thing, and the gap between them is where production incidents live.
The decision trigger
| Incident | Which technique would have caught it |
|---|---|
| A response field was renamed | schema diff |
| An endpoint nobody remembered was deleted | schema diff |
| The default sort order flipped | contract test |
| A new validation rule rejected old payloads | contract test / replay |
The error type URI was reworded |
contract test |
An optional field started returning null always |
contract test |
A type was narrowed from number to integer |
schema diff |
| The server stopped returning a documented field | response validation |
Coverage models compared
| Dimension | Schema diff | Consumer contract tests |
|---|---|---|
| What it compares | two documents | recorded expectations vs a running service |
| Coverage | every operation in the document | only paths a consumer exercises |
| Sees behaviour | no | yes |
| Sees undocumented behaviour | no | yes |
| Needs a running service | no | yes |
| Needs consumer participation | no | yes |
| Runtime | seconds | minutes |
| Fails on prose edits | possibly (exclude them) | no |
| Catches implementation drift | no | partially |
| Setup cost | one CI job | broker, publishing, verification per consumer |
The middle layer nobody runs
Most teams have a diff or contract tests, and almost none validate live responses against their own schema. That middle layer catches implementation drift — the server quietly diverging from its own document — which neither other technique sees. The document says the field exists, so the diff is happy; no consumer test asserts it, so the suite passes; and a client generated from the spec expects a field that never arrives.
// response-validation.test.ts — assert the server matches its own document
import { describe, it, expect } from "vitest";
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
import spec from "../dist/openapi.json" assert { type: "json" };
const ajv = addFormats(new Ajv2020({ strict: false, allErrors: true }));
for (const [name, schema] of Object.entries(spec.components.schemas)) {
ajv.addSchema(schema as object, `#/components/schemas/${name}`);
}
const CASES = [
{ path: "/invoices", schema: "InvoiceListEnvelope" },
{ path: "/invoices/inv_seed_1", schema: "Invoice" },
{ path: "/customers", schema: "CustomerListEnvelope" },
];
describe("live responses match the published schema", () => {
for (const { path, schema } of CASES) {
it(`${path} conforms to ${schema}`, async () => {
const res = await fetch(`${BASE}${path}`, { headers: AUTH });
const body = await res.json();
const validate = ajv.getSchema(`#/components/schemas/${schema}`)!;
if (!validate(body)) {
throw new Error(
`response does not match ${schema}:\n` +
(validate.errors ?? []).map(e => ` ${e.instancePath} ${e.message}`).join("\n"),
);
}
expect(res.status).toBe(200);
});
}
});
This is cheap to add — the schemas already exist — and it is the only check that keeps the document honest. Everything else in this reference assumes the spec describes reality; this is the test that verifies the assumption.
What consumer contract tests add
A consumer-driven contract records what a specific client actually depends on, which is usually a small subset of what the document permits. That specificity is the value: the provider learns which of its documented promises are load-bearing.
// consumer side — recorded expectation, published to a broker
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
const { like, eachLike, integer } = MatchersV3;
const provider = new PactV3({
consumer: "billing-dashboard",
provider: "invoices-api",
});
describe("invoice list", () => {
it("returns items with the fields the dashboard renders", async () => {
await provider
.given("three invoices exist")
.uponReceiving("a request for the first page")
.withRequest({ method: "GET", path: "/invoices", query: { limit: "50" } })
.willRespondWith({
status: 200,
headers: { "Content-Type": "application/json" },
body: {
data: eachLike({
id: like("inv_1"),
total_minor: integer(4200),
currency: like("GBP"),
status: like("paid"), // dashboard branches on this
}),
meta: { has_more: like(true) },
},
})
.executeTest(async mock => {
const page = await listInvoices(mock.url, { limit: 50 });
expect(page.data[0].status).toBeDefined();
});
});
});
The provider then verifies every published expectation before release. What this catches that a diff cannot: the dashboard depends on status being present and on the specific values it branches on, so a server that starts returning a new status value — perfectly additive at the document level — fails verification.
Cost is the other axis, and it explains why so many teams have one technique and not the other:
Choosing what to run
Not every API needs all three layers. The useful rule is to scale with consumer visibility:
| Situation | Diff | Response validation | Contract tests |
|---|---|---|---|
| Internal API, one consumer, same team | yes | yes | probably not |
| Internal platform API, many teams | yes | yes | yes |
| Public API, unknown consumers | yes | yes | limited value |
| API with a published SDK | yes | yes | replace with SDK tests |
Contract tests need consumer participation, which makes them powerful inside an organisation and impractical for a public API where you cannot enumerate — let alone instrument — your callers. For public APIs, spend the same effort on a broad behavioural test suite of your own plus traffic replay, and rely on the diff for structural coverage. That is the same reasoning behind the release gates in Client SDK Generation Pipelines.
Standard compliance
| Concern | Standard | Note |
|---|---|---|
| Document comparison | OpenAPI 3.0 / 3.1 | The input to a diff |
| Response body validation | JSON Schema 2020-12 | The mechanism for the middle layer |
| Error contract stability | RFC 9457 | type URIs asserted by contract tests |
| Compatibility classification | convention | No RFC defines “breaking” for HTTP APIs |
Running both without duplicating effort
The two techniques share one artefact: the schema. Use it as the single source for both, so a change to the document propagates automatically:
// shared-fixtures.ts — one schema, three consumers of it
import spec from "../dist/openapi.json" assert { type: "json" };
// 1. The diff compares this file against the released baseline (CI job).
// 2. Response validation compiles these schemas and checks live bodies.
export const schemaFor = (name: string) => spec.components.schemas[name];
// 3. Contract-test fixtures are generated FROM the schema, so a field added
// to the document appears in the fixtures without a manual edit.
export function exampleFor(name: string): Record<string, unknown> {
const schema = schemaFor(name) as any;
const out: Record<string, unknown> = {};
for (const [prop, def] of Object.entries<any>(schema.properties ?? {})) {
if (!schema.required?.includes(prop)) continue;
out[prop] = def.examples?.[0] ?? def.example ?? defaultForType(def.type);
}
return out;
}
Hand-maintained fixtures are the usual reason a contract-test suite decays: they drift from the schema, tests start failing for uninteresting reasons, and the suite gets muted. Generating the required-field skeleton from the document keeps them honest.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Assuming a diff covers behaviour | Add contract tests or a behavioural suite |
| Assuming contract tests cover every endpoint | Keep the diff for full structural coverage |
| Never validating live responses against the schema | Add the middle layer; it is nearly free |
| Hand-maintaining contract fixtures | Generate required-field skeletons from the schema |
| Contract tests for a public API with unknown consumers | Use your own behavioural suite plus traffic replay |
| Muting a flaky contract suite | Fix the fixture drift; a muted gate is no gate |
FAQ
Do I need consumer contract tests if I already diff my OpenAPI document?
If behaviour matters to your consumers — and it does — then yes. A diff proves one narrow thing very thoroughly: that the document did not change in a structurally incompatible way, across every operation, in seconds, with no running service required. It cannot see a changed default, a reversed sort order, a new validation rule enforced in code, or an error identifier that was reworded, because none of those are expressed in the document. Those are exactly the changes that pass review, pass CI, and break a consumer at deploy time. The two techniques overlap so little that running both is closer to addition than to duplication.
Can consumer-driven contract tests replace a schema diff?
No, because their coverage follows usage rather than the document. If no consumer test touches /v1/legacy-report, it can be deleted, renamed or have every field removed without a single failure — while a diff reports it immediately. Contract tests are also asymmetric in a way people forget: they verify what consumers currently depend on, so a consumer that has not yet shipped its dependency on a field gets no protection. Keep the diff as the floor of structural coverage and let contract tests add behavioural depth on the paths that matter most.
Where does response validation against the schema fit?
It is the layer between the other two, and the one most often missing. A diff compares documents; contract tests compare consumer expectations against a running service; response validation compares the running service against its own document. It catches implementation drift — a field the code stopped populating, a status value the schema does not list, an envelope that lost its meta member — none of which any diff can see and none of which a consumer test will catch unless that consumer happens to depend on the drifted field. The schemas already exist, so adding it is a few dozen lines in your integration suite.
Related
- Breaking Change Detection — up-link: the section this comparison supports
- API Versioning & Deprecation — up-link: the parent reference for versioning and retirement
- Detecting Breaking Changes with oasdiff — the diff half of this pairing, wired into CI
- Adding Required Request Fields Safely — a change both techniques should agree about
- Client SDK Generation Pipelines — where these gates sit in the release chain