Recording and Replaying Real API Traffic
Hand-written handlers encode what you think the API returns. A recording encodes what it actually returned, including the four fields nobody documented and the header your client depends on without knowing. This page covers capturing traffic through a proxy, scrubbing it safely, replaying it deterministically, and keeping the recordings from rotting.
Context: fidelity versus intent
Recording and authoring solve different halves of the same problem, and treating them as competitors leads to picking the wrong one.
A recording has perfect fidelity. Every header, every status code, every unexpected null in a field your types declare non-nullable is preserved, because nobody had to notice it to capture it. What a recording cannot do is express a case that did not occur — the 409 on a concurrent edit, the empty list, the rate limit — because those never happened during the session.
An authored handler has perfect intent. It expresses exactly the scenario a test needs, including ones that are hard to provoke in a real system. What it cannot do is contain a detail its author did not know about.
The productive arrangement uses both: record to discover the true shape, then author the scenarios, using the recording as the source of truth for the payload structure. That is the same division of labour that proxy versus inline mocking describes at the architectural level.
Solution
1. Record through a proxy
WireMock’s record-and-playback mode proxies to the real API and writes a mapping per interaction:
# Start WireMock in recording mode, proxying everything to the real API.
docker run --rm -p 8080:8080 \
-v "$PWD/cassettes":/home/wiremock/mappings \
-v "$PWD/cassettes/__files":/home/wiremock/__files \
wiremock/wiremock:3.13.2 --verbose
# Turn recording on, targeting the upstream you want captured.
curl -sf -X POST http://localhost:8080/__admin/recordings/start \
-H 'Content-Type: application/json' \
-d '{
"targetBaseUrl": "https://api.example.com",
"captureHeaders": { "Accept": {}, "Content-Type": {} },
"persist": true,
"repeatsAsScenarios": true,
"transformers": ["response-template"]
}'
# ... drive the application against http://localhost:8080 ...
curl -sf -X POST http://localhost:8080/__admin/recordings/stop | jq '.mappings | length'
Two options in that payload do real work. captureHeaders limits what is recorded as match criteria — without it, every recorded stub demands an exact match on every header the browser happened to send, and replay misses constantly. repeatsAsScenarios turns a repeated request with differing responses into a scenario rather than silently keeping only one, which is what preserves a POST-then-GET sequence.
2. Scrub at capture time, not before commit
A secret that reaches disk has already leaked as far as your backup system is concerned. Scrub as part of the capture step:
// scripts/scrub-cassettes.ts
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const DIR = 'cassettes';
const DROP_HEADERS = ['authorization', 'cookie', 'set-cookie', 'x-api-key', 'proxy-authorization'];
const VOLATILE_FIELDS = ['requestId', 'traceId', 'generatedAt', 'expiresAt'];
function scrub(node: unknown): unknown {
if (Array.isArray(node)) return node.map(scrub);
if (node && typeof node === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
if (DROP_HEADERS.includes(k.toLowerCase())) continue;
if (VOLATILE_FIELDS.includes(k)) { out[k] = `<${k}>`; continue; }
if (k === 'email' && typeof v === 'string') { out[k] = '[email protected]'; continue; }
out[k] = scrub(v);
}
return out;
}
return node;
}
for (const file of readdirSync(DIR).filter((f) => f.endsWith('.json'))) {
const path = join(DIR, file);
writeFileSync(path, JSON.stringify(scrub(JSON.parse(readFileSync(path, 'utf8'))), null, 2) + '\n');
}
console.log('scrub-cassettes: done');
Normalising the volatile fields to a placeholder does double duty: it removes noise that would otherwise produce a diff on every re-record, and it makes an accidental real value stand out in review. Run the detector from mock data privacy and anonymisation over the directory afterwards so nothing depends on this script being exhaustive.
3. Replay, and fail loudly on a miss
Replay is the easy half — the discipline is refusing to fall through:
# docker-compose.replay.yml
services:
mock-api:
image: wiremock/wiremock:3.13.2
# No --proxy-all: a request with no recording must 404, never reach the internet.
command: ["--global-response-templating", "--disable-banner"]
ports: ["8080:8080"]
volumes:
- ./cassettes:/home/wiremock/mappings:ro
- ./cassettes/__files:/home/wiremock/__files:ro
The absence of a proxy fallback is the whole safety property. A replay stack configured with --proxy-all silently reaches the real API whenever a recording is missing, which means an offline demo works on the office network and fails on a train — and nobody knows why, because it never failed during testing.
For MSW-based replay, the same rule expressed in code:
// src/mocks/replay.ts
import { http, HttpResponse } from 'msw';
import cassettes from '../../cassettes/index.json';
interface Interaction {
method: string;
path: string;
query?: Record<string, string>;
status: number;
body: unknown;
headers?: Record<string, string>;
}
const key = (method: string, path: string) => `${method.toUpperCase()} ${path}`;
const INDEX = new Map<string, Interaction>(
(cassettes as Interaction[]).map((i) => [key(i.method, i.path), i])
);
export const replayHandlers = [
http.all('https://api.example.com/*', ({ request }) => {
const url = new URL(request.url);
const found = INDEX.get(key(request.method, url.pathname));
if (!found) {
// Loud, specific, and actionable — never a silent pass-through.
return HttpResponse.json(
{ error: 'no_recording', message: `No cassette for ${key(request.method, url.pathname)}` },
{ status: 501 }
);
}
return HttpResponse.json(found.body, { status: found.status, headers: found.headers });
}),
];
A 501 with the missing key in the message turns “the app is broken offline” into a one-line fix. This is the replay mode referenced in swapping mock and live APIs with env vars.
Verification
# Replay works with the network unavailable
docker compose -f docker-compose.replay.yml up -d
curl -s --max-time 3 http://localhost:8080/orders/ord_1 | jq '.id'
# No credentials survived the scrub
grep -rniE 'authorization|bearer |api[_-]?key|set-cookie' cassettes && exit 1 || echo 'cassettes clean'
# Every cassette records what it was captured against
jq -r 'select(.recordedAt == null) | input_filename' cassettes/*.json # expect no output
The middle command belongs in CI. Recordings are the single most common way a real token reaches a repository, and a grep costs nothing.
Gotchas and edge cases
-
Recorded stubs match too strictly by default. A capture that records every request header produces stubs that only match the exact browser, version and locale that recorded them. Limit
captureHeadersto the headers that genuinely vary the response — usuallyAcceptandContent-Typeand nothing else. -
Timestamps in bodies make every re-record a full diff. If
generatedAtis not normalised, re-recording changes every file and the review becomes unreadable, so nobody reviews it. Normalise volatile fields as part of the scrub, and the diff shrinks to the fields that actually changed. -
A cassette with no expiry becomes a fossil. Stamp each with
recordedAtand the API version, and run a scheduled job that re-records and fails on a structural difference — the same classification described in versioning mock contracts alongside API releases. Without it, the recording quietly contradicts the live contract and every test that depends on it is testing history.
Related
- When to Use Proxy vs Inline Mocking — the architectural decision behind recording through a proxy
- Versioning Mock Contracts Alongside API Releases — keeping captures from going stale
- Swapping Mock and Live APIs with Env Vars — the replay mode these cassettes serve
← Back to Proxy vs Inline Mocking Strategies