Root cause #
A mock is only as honest as the schema it was recorded against. When you capture fixtures, you implicitly freeze a snapshot of the API contract at that moment. The live service keeps evolving: a 200 gains a required field, a string becomes an enum, an endpoint changes its response shape. Your fixtures do not move with it. Because the mock returns exactly what the test expects, every assertion passes — the suite is now validating itself against a fossil. The flakiness shows up downstream, as environment-specific failures when the test runs against a real or staging backend whose payload no longer matches the frozen fixture.
The fix is to treat the recorded schema as an artifact that must be reconciled with the source of truth on every run. oasdiff compares two OpenAPI documents and reports added, removed, and modified paths, parameters, and schemas. Run it in CI between the schema your fixtures were built from and the live spec, and drift becomes a build failure instead of a production incident.
Step-by-step fix #
1. Pin the schema your fixtures were captured against #
Commit the exact spec snapshot alongside your fixtures so there is a stable left-hand side to diff.
# Save the contract version your recorded fixtures assume.
curl -s "$API_URL/openapi.json" > fixtures/openapi.snapshot.json
git add fixtures/openapi.snapshot.json
# Trade-off: an extra committed file, but it makes drift detectable instead of invisible.
2. Fetch the live spec and diff it in CI #
Pull the current spec at run time and compare. A nonzero exit from oasdiff should fail the job.
# .github/workflows/schema-diff.yml
name: schema-diff
on: [pull_request]
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch live spec
run: curl -s "${{ secrets.API_URL }}/openapi.json" > live.json
- name: Diff fixture schema vs live
# --fail-on ERR blocks the build when the live contract drifts from the snapshot.
run: npx oasdiff diff fixtures/openapi.snapshot.json live.json --fail-on ERR
3. Gate only on changes that matter #
Use the breaking-change mode so cosmetic additions do not block every PR, while real divergence still fails.
- name: Breaking-change check
# 'breaking' only fails on backward-incompatible drift, reducing false alarms.
run: |
npx oasdiff breaking fixtures/openapi.snapshot.json live.json \
--format githubactions --fail-on ERR
4. Refresh the snapshot deliberately #
When a diff is legitimate, re-record fixtures and bump the snapshot in the same PR so the change is reviewable.
# Re-capture only when the contract change is intended and reviewed.
curl -s "$API_URL/openapi.json" > fixtures/openapi.snapshot.json
npm run record:fixtures # regenerate mocks from the new contract
For the runtime assertion counterpart that validates payloads during the test itself, see Validating OpenAPI Contracts in E2E Pipelines.
Pitfalls #
- Diffing against a spec that is itself stale. Mitigation: always fetch the live spec at run time, never a committed copy of it.
- Failing on every additive change. Mitigation: use
oasdiff breakingso only backward-incompatible drift blocks the build. - Snapshot and fixtures updated in separate PRs. Mitigation: regenerate both in one commit so they can never disagree.
- No diff for endpoints your fixtures do not cover. Mitigation: also lint fixture coverage so untested paths are visible.
- Secrets leaking the spec URL in logs. Mitigation: store
API_URLas a secret and avoid echoing the curl output.
Reliability targets #
| Target | Goal |
|---|---|
| Undetected contract drift | 0 fixtures out of sync per release |
| Schema-diff job runtime | < 30s |
| False-positive diff failures | < 5% of PRs |
| Staging-vs-mock payload mismatches | 0 per 1k runs |
Frequently Asked Questions #
Q: How is this different from validating responses at runtime? A: Runtime validation checks one live response against a schema during a test. Diffing compares whole specs ahead of time, so it catches drift even on endpoints the current test run never exercises.
Q: Should I fail on all changes or only breaking ones?
A: Gate the build on breaking changes with oasdiff breaking, and report non-breaking ones as warnings. That keeps additive evolution from blocking unrelated PRs.
Q: Where does the live spec come from if the backend is not deployed in CI? A: Point the diff at a shared staging spec endpoint, or have the backend publish its generated OpenAPI document as a CI artifact you can fetch.
Comparing Structure, Not Bytes #
A raw text diff between a committed fixture and a fresh capture produces noise on every run: timestamps differ, generated identifiers differ, collection ordering differs, and a run with different seed data differs everywhere. Teams that start there conclude the check is useless within a week.
Reducing both sides to a structural summary — the set of keys, their types, nullability and nesting, with values discarded — removes all of that and leaves exactly the changes that matter: fields added, removed or renamed, and types changed. That summary is stable across reseeds and comparable across weeks, which is what makes an automated comparison worth running at all.
// Reduce a payload to its shape so the comparison ignores values.
// Trade-off: shape comparison catches renames and type changes and is blind to
// semantic drift, such as an amount switching from minor to major units.
export function shapeOf(value) {
if (Array.isArray(value)) return [shapeOf(value[0] ?? null)];
if (value === null) return 'null';
if (typeof value !== 'object') return typeof value;
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, shapeOf(value[k])]));
}
Two refinements make the output more actionable. Reporting directional differences — fields the live response has that the fixture lacks, and vice versa — distinguishes an additive provider change from a removal, which have very different urgency. And summarising collections by the shape of their first element rather than element by element keeps the diff readable for payloads with hundreds of rows.
Severity: What Should Block and What Should Warn #
The most common reason a drift check gets muted is that it fails for reasons the author cannot act on, so getting the severity right matters as much as the comparison.
A fixture that contradicts a published schema is provably wrong and should fail the build. There is no ambiguity: the document the provider publishes says one thing and the fixture says another, and whichever is stale, the fixture cannot be correct.
A fixture that differs from today’s live capture may simply reflect different seed data or a provider deployment in progress. That deserves a warning, a diff in the log and a ticket routed to the team owning the integration — not a red build blocking unrelated work.
A semantic difference with an unchanged shape — an amount switching units, an enum value repurposed — is invisible to both checks and needs a small set of live assertions on the specific meanings the client depends on.
The pattern is a gradient of certainty: block where the evidence is conclusive, warn where it is suggestive, and cover the remainder with targeted assertions. Applying a single severity to all three either produces noise or misses the case that actually breaks production.
Making the Diff Reviewable #
A drift report is only acted on if a human can read it in under a minute, which puts real weight on presentation.
Three choices do most of that work. Report directional differences separately — fields the live response has that the fixture lacks, and the reverse — because an additive change and a removal have very different urgency. Group by entity rather than listing paths flatly, so a reader sees “Invoice gained two fields” instead of twelve lines. And cap the output, reporting a count when a diff exceeds a readable size, since an unreadable report is functionally the same as no report.
The final touch is to name the likely cause alongside the difference: a renamed field usually appears as one removal and one addition with the same type, and saying so in the output saves the reader from re-deriving it.
Running the comparison against a staging environment rather than production is not only a data-protection matter: staging usually receives changes first, so the diff arrives with lead time rather than after the change is already serving users.
Reporting the count of unchanged entities alongside the differences gives the reader a sense of scale, which a bare list of diffs does not.