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.

Where drift opens up, and where the check catches it Two parallel timelines. The API specification advances through revisions v1.0, v1.1 and v2.0. The mock stays generated from v1.0 throughout. The gap between the two lines widens after each release. A marker at the v1.1 release shows an additive change producing a non-blocking notice, and a marker at v2.0 shows a required-field removal producing a hard build failure. API specification v1.0 v1.1 — adds a field v2.0 — removes a required field Mock definitions generated from v1.0 never regenerated — and nothing says so NOTICE — additive refresh the fixtures FAIL — breaking required field removed

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
Classifying a specification change A fetched specification is hashed and compared with the pinned hash. An identical hash exits immediately as unchanged. A different hash is diffed: removed paths, removed operations and removed required properties classify as breaking and fail the build; new properties classify as additive and print a notice without failing. fetch spec hash it sha === pinned? one cheap comparison yes exit 0 — unchanged no diff and classify per path and schema NOTICE — additive build still passes BREAK — exit 1 required field removed

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 consumers array only lists the ones you know about. Before removing v1, 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.


When an old version can go Three signals that a pinned version is safe to delete: no consumer manifest references it, no traffic to its paths appears in the mock journal over a meaningful window, and the drift check has been clean since the last consumer migrated. All three should hold, not just the first. No manifest references it the consumers you know about necessary, not sufficient No traffic in the journal over at least a fortnight catches consumers nobody recorded Drift clean since migration nothing changed while waiting the version is genuinely dormant Deleting on the first signal alone is how a consumer nobody documented discovers the removal in production.

← Back to Contract Testing & Drift Detection