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.