Recording and Replaying HTTP Traffic for Local Tests
Integration tests against a partner's sandbox fail twice a week with ETIMEDOUT or 503 Service Unavailable, take four minutes because every test makes real calls, and cannot run on a plane. The partner has no OpenAPI document, so a generated mock is not an option, and hand-writing stubs for forty endpoints means guessing at response shapes. Record and replay captures the sandbox's real responses once, stores them as stub files, and serves them back offline — deterministic, fast and shaped exactly like the real thing. This page does that with WireMock's recorder, including the scrubbing step that makes recordings safe to commit, as part of mocking external APIs and third-party services.
A recording is a snapshot. The workflow below treats it as one: dated, scrubbed, reviewed, and refreshed on a schedule.
Diagnostic
Measure how much the test suite depends on the live sandbox:
#!/usr/bin/env bash
set -euo pipefail
start=$(date +%s)
npm run test:integration -- --grep partner --reporter dot 2>&1 | tail -3 || true
echo "duration: $(( $(date +%s) - start ))s"
grep -rhoE 'https://sandbox\.partner\.example[^"'"'"' ]*' test/ src/ 2>/dev/null | sed -E 's/\?.*//' | sort -u | wc -l | xargs echo "distinct sandbox endpoints referenced:"
gh run list --workflow integration.yml --limit 20 --json conclusion --jq '[.[] | select(.conclusion=="failure")] | length' | xargs echo "failed runs in last 20:"
Expected bad output:
38 passing (3m 52s)
2 failing
duration: 236s
distinct sandbox endpoints referenced: 17
failed runs in last 20: 6
Two tests failed on network errors this run, the suite takes four minutes, and a third of recent CI runs failed — mostly on sandbox availability rather than code.
Root cause
Tests that call a live sandbox inherit every property of the network and the provider: latency, rate limits, maintenance windows, shared test data that other customers change, and outages. None of those relate to the code under test, yet they fail the build. Hand-written stubs avoid the network but need someone to know the response shapes, which is exactly what is missing when no specification exists. Recording resolves both: the real API defines the shapes once, and replay makes subsequent runs independent of it. The risk moves elsewhere — recordings capture whatever the sandbox returned, including credentials echoed back, customer-like personal data and timestamps, and they silently age as the real API evolves. A recording workflow is only safe with a scrubbing step and a refresh schedule.
Resolution
- Run WireMock with a recordings directory that you can review before committing:
services:
partner-mock:
image: wiremock/wiremock:3.9.1
command: ["--disable-banner", "--root-dir", "/home/wiremock"]
ports:
- "127.0.0.1:8090:8080"
volumes:
- ./mocks/partner:/home/wiremock
- Record through the proxy by pointing tests at WireMock while it forwards to the sandbox:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait partner-mock
curl -fsS -X POST http://localhost:8090/__admin/recordings/start -H 'content-type: application/json' -d '{
"targetBaseUrl": "https://sandbox.partner.example",
"extractBodyCriteria": {"textSizeThreshold": "2kb", "binarySizeThreshold": "1kb"},
"requestBodyPattern": {"matcher": "equalToJson", "ignoreArrayOrder": true, "ignoreExtraElements": true},
"repeatsAsScenarios": true,
"persist": true
}'
PARTNER_BASE_URL=http://localhost:8090 PARTNER_API_KEY="${PARTNER_SANDBOX_KEY:?}" npm run test:integration -- --grep partner
curl -fsS -X POST http://localhost:8090/__admin/recordings/stop | jq '.mappings | length' | xargs echo "mappings recorded:"
repeatsAsScenarios turns repeated identical requests with different responses into a WireMock scenario, which preserves state changes such as an order moving from pending to shipped.
- Scrub before committing. Replace tokens, emails, names and addresses, and strip request-matching on credentials so replay does not need a real key:
#!/usr/bin/env bash
set -euo pipefail
dir=mocks/partner
grep -rlE 'Bearer [A-Za-z0-9._-]{20,}|sk_(live|test)_|@[a-z0-9-]+\.(com|net|org)' "$dir" | while read -r f; do
sed -E -i.bak \
-e 's/Bearer [A-Za-z0-9._-]{20,}/Bearer REDACTED/g' \
-e 's/sk_(live|test)_[A-Za-z0-9]+/sk_test_REDACTED/g' \
-e 's/[A-Za-z0-9._%+-]+@[a-z0-9-]+\.(com|net|org)/[email protected]/g' "$f"
rm -f "$f.bak"
done
for f in "$dir"/mappings/*.json; do
jq 'del(.request.headers.Authorization)' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
done
gitleaks detect --no-git --source "$dir" --redact && echo "recordings clean"
Replay in tests and CI by pointing the base URL at the mock with no sandbox key at all. Any test that still needs the network fails immediately, which reveals an unrecorded call.
Date the recording and schedule a refresh. Write
mocks/partner/RECORDED_ATwith the date and sandbox version, and run the recording job monthly in CI, opening a pull request with the diff.
Expected output
$ PARTNER_BASE_URL=http://localhost:8090 npm run test:integration -- --grep partner --reporter dot
40 passing (9s)
$ curl -fsS http://localhost:8090/__admin/requests/unmatched | jq '.requests | length'
0
$ cat mocks/partner/RECORDED_AT
2026-09-18 sandbox API v3.14
The suite runs in nine seconds with no network, every request matched a recorded mapping, and the recording records when and against which API version it was made.
The monthly re-recording is where recordings earn their keep a second time. Its pull request shows a diff of real responses between last month and now — a renamed field, a new enum value, a changed error body — which is often the first signal that the partner changed their API. Reviewing that diff before merging keeps both the mocks and the application's assumptions current, and turns an unannounced provider change into a planned piece of work.
Prevention
Fail CI if recordings contain secrets or personal data by running
gitleaksover the mocks directory, as in blocking committed secrets with a gitleaks pre-commit hook.Fail tests on unmatched requests so new client calls cannot silently fall through to a missing stub.
Keep the refresh automated. A monthly job that re-records, scrubs and opens a pull request turns API drift into a reviewable diff instead of a production surprise.
Platform caveats
Apple Silicon (ARM64): WireMock images are multi-arch; recording and replay behave identically on arm64.
macOS (Docker Desktop): recordings are written into the bind-mounted directory as the container's user; if files appear owned by root on Linux hosts, run the container with
user: "${UID}:${GID}".
WSL2: keep the mocks directory in the Linux filesystem so recordings are written with LF line endings and exact-match body stubs behave consistently.
Rollback
Point the base URL back at the sandbox; recordings can stay in the repository unused:
#!/usr/bin/env bash
set -euo pipefail
docker compose rm -sf partner-mock
export PARTNER_BASE_URL=https://sandbox.partner.example
npm run test:integration -- --grep partner
Frequently Asked Questions
Is it safe to commit recorded responses?
Only after scrubbing tokens, personal data and anything else the sandbox returned that should not be in git, and after a secret scanner passes. Review recordings like code before merging.
How do recordings handle APIs whose responses change between calls?
With repeatsAsScenarios, WireMock turns repeated identical requests with different responses into a scenario that replays them in order. Tests that depend on that order should reset scenarios before running.
How often should we re-record?
Monthly is a reasonable default for stable partner APIs, and immediately after the partner announces changes. Automate it so the diff arrives as a pull request.
What about requests with timestamps or random IDs in the body?
Use equalToJson with ignoreExtraElements, or JSON-path matchers on the fields that matter, so recordings match regardless of volatile values. Exact body matching makes replay brittle.