Versioning Mock Contracts Alongside API Releases
Your mocks were generated from the API specification eight months ago. The API has moved on twice since; nobody regenerated. The suite is green, and it is green about a contract that no longer exists. This page covers pinning mock definitions to an API version, serving two versions concurrently, and making an unannounced break fail in CI rather than in production.
Context: mocks rot silently
A mock has no mechanism for noticing that the thing it imitates has changed. Unlike an integration test, it never talks to the real API, so a field removal, a type change or a renamed enum value produces no signal at all — the suite keeps passing against a contract the provider abandoned.
The rot is asymmetric, which is what makes it dangerous. A mock that is behind the API produces false confidence: your code handles a shape that no longer arrives. A mock that is ahead produces false failures that people learn to ignore. Both end with the same sentence in a post-incident review: “it worked against the mock”.
The fix is to treat the specification revision as part of the mock’s identity. A mock that does not record which spec it came from cannot be checked against anything.
Solution
1. Stamp the definitions with the spec revision
Store mocks under a version directory and keep a manifest recording exactly what they were generated from:
mocks/
v1/
manifest.json
mappings/
get-orders.json
post-orders.json
v2/
manifest.json
mappings/
get-orders.json
{
"apiVersion": "v1",
"specSource": "https://api.example.com/openapi.json",
"specRevision": "2026-03-14T09:12:00Z",
"specSha256": "9f2a41d0c7b6e83512ab90cd77ee4413b1f0a662d9c8e5a4771b0e2f6c3d8890",
"generatedAt": "2026-03-14T10:02:11Z",
"generatorVersion": "3.2.1",
"consumers": ["web-storefront", "admin-console"]
}
The specSha256 is what makes drift detectable. Comparing a freshly fetched spec’s hash against the pinned one is a single cheap check that answers “has anything at all changed?” before any expensive diffing runs.
2. Serve two versions at once
Consumers migrate on different schedules, so both versions have to be reachable simultaneously. With WireMock, mount both mapping directories and let the path or a header select:
# docker-compose.yml
services:
mock-api:
image: wiremock/wiremock:3.13.2
command: ["--global-response-templating", "--disable-banner"]
ports: ["8080:8080"]
volumes:
- ./mocks/v1/mappings:/home/wiremock/mappings/v1:ro
- ./mocks/v2/mappings:/home/wiremock/mappings/v2:ro
- ./mocks/__files:/home/wiremock/__files:ro
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/__admin/health"]
interval: 5s
timeout: 3s
retries: 6
start_period: 10s
Each mapping declares the version it belongs to in its match criteria, so the two sets cannot collide:
{
"request": {
"method": "GET",
"urlPathPattern": "/v1/orders/[^/]+",
"headers": { "Accept": { "contains": "application/json" } }
},
"response": {
"status": 200,
"jsonBody": {
"id": "ord_1",
"status": "paid",
"customer_name": "A. Patel",
"total": { "amount": 4250, "currency": "GBP" }
},
"headers": { "Content-Type": "application/json", "X-Api-Version": "v1" }
}
}
The X-Api-Version response header is worth the two seconds it costs. When a consumer reports “the mock returned the wrong shape”, the header in their captured response says immediately which version answered.
3. Fail the build on an unannounced break
The check is a scheduled job that fetches the live spec, compares it to the pinned revision, and classifies the difference:
// scripts/check-contract-drift.ts
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
interface Manifest { apiVersion: string; specSource: string; specSha256: string; }
const manifest: Manifest = JSON.parse(readFileSync('mocks/v1/manifest.json', 'utf8'));
const spec = await (await fetch(manifest.specSource)).text();
const sha = createHash('sha256').update(spec).digest('hex');
if (sha === manifest.specSha256) {
console.log(`contract: unchanged since pin (${manifest.apiVersion})`);
process.exit(0);
}
const pinned = JSON.parse(readFileSync(`mocks/${manifest.apiVersion}/spec.json`, 'utf8'));
const current = JSON.parse(spec);
const breaking: string[] = [];
const additive: string[] = [];
for (const [path, ops] of Object.entries(pinned.paths ?? {})) {
if (!current.paths?.[path]) { breaking.push(`path removed: ${path}`); continue; }
for (const method of Object.keys(ops as object)) {
if (!current.paths[path][method]) breaking.push(`operation removed: ${method.toUpperCase()} ${path}`);
}
}
for (const [name, schema] of Object.entries<any>(pinned.components?.schemas ?? {})) {
const now = current.components?.schemas?.[name];
if (!now) { breaking.push(`schema removed: ${name}`); continue; }
for (const req of schema.required ?? []) {
if (!now.properties?.[req]) breaking.push(`required property removed: ${name}.${req}`);
}
for (const prop of Object.keys(now.properties ?? {})) {
if (!schema.properties?.[prop]) additive.push(`new property: ${name}.${prop}`);
}
}
for (const a of additive) console.log(`NOTICE ${a}`);
for (const b of breaking) console.error(`BREAK ${b}`);
if (breaking.length) {
console.error(`\ncontract-drift: ${breaking.length} breaking change(s) since ${manifest.apiVersion} was pinned.`);
process.exit(1);
}
console.log(`contract-drift: ${additive.length} additive change(s), none breaking — regenerate when convenient.`);
The classification is the whole design. A removed path or required property fails the build, because code written against the mock will break. A new property is a notice, because nothing breaks — but the consumer is now blind to a field it may want, and the notice is what prompts a regeneration.
Run it on a schedule as well as on push, so drift is discovered between releases rather than at the next deploy:
# .github/workflows/contract-drift.yml
name: Contract drift
on:
schedule: [{ cron: "0 6 * * 1-5" }]
pull_request:
paths: ["mocks/**"]
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci
- run: npx tsx scripts/check-contract-drift.ts
Verification
# The pinned hash matches the spec you think it does
jq -r '.specSha256' mocks/v1/manifest.json
curl -s "$(jq -r '.specSource' mocks/v1/manifest.json)" | sha256sum
# Both versions answer, and say which one they are
curl -s -D - -o /dev/null http://localhost:8080/v1/orders/ord_1 | grep X-Api-Version
curl -s -D - -o /dev/null http://localhost:8080/v2/orders/ord_1 | grep X-Api-Version
If the two hashes differ, the manifest was not updated when the mocks were last regenerated — which means the drift check has been comparing against the wrong baseline and reporting nothing.
Gotchas and edge cases
-
Hashing the raw spec is sensitive to formatting. A provider that reserialises their OpenAPI document with different key ordering produces a new hash and a spurious “changed” result every time. Canonicalise before hashing — parse and re-serialise with sorted keys — so the hash tracks content rather than whitespace.
-
Deleting an old version breaks consumers you cannot see. The manifest’s
consumersarray only lists the ones you know about. Before removingv1, check the mock’s request journal for traffic to/v1/*over the previous fortnight; anything still arriving is a consumer nobody recorded, as covered in inspecting the WireMock request journal. -
A widened type is breaking in one direction only. Changing
status: "paid" | "pending"to a free-form string does not break existing consumers, but changing it the other way does — and a naive property-level diff sees neither. Compare enum members and type constraints explicitly, or the most common real-world break slips through the check entirely.
Related
- Detecting OpenAPI Contract Drift in CI — the broader drift pipeline this pinning feeds
- Validating Mock Responses Against OpenAPI — checking the responses themselves, not just the spec
- Consumer-Driven Contract Testing with Pact — the inverse approach, where consumers publish their expectations
← Back to Contract Testing & Drift Detection