Don't Trust Your Stub. Trust the Transcript.
A recorded HAR fixture lets a coding agent read back exactly what the API said, not guess at it.

Ticket stubs
See the accompanying code repo on Github here.
The Stub That Used to Be True
Here’s a test that used to live in ShopApp, the companion project for this series of articles.
@Test("fetchProducts decodes the remote response")
func fetchProductsDecodesResponse() async throws {
let client = MockNetworkClient()
try client.setJSON(StoreProduct.stubs)
let repo = DefaultStoreRepository(client: client)
let products = try await repo.fetchProducts(category: nil)
#expect(products.count == StoreProduct.stubs.count)
}
This is the standard for a reliable unit test — explicit state, fast feedback, no simulator required. It loads a stubbed response object — a Swift array standing in for what the API returns. It was testing that the repository decodes a JSON array into StoreProduct values, that caching works, and that the URL gets built right. It was not checking that StoreProduct.stubs return what the actual server response is. The stubs were written by a coding agent against the API contract at the time it was defined in the spec.
For a fast-evolving project, these are rarely constant. It’s the situation every mobile developer building against an actively developed backend recognizes immediately. You’re building a feature. The backend for it is being built at the same time, by a different team. While they finalize the internals, they pass you a sample JSON file—what they expect the response to look like once it’s ready. Sometimes that guess is close. Sometimes it’s way off, and nobody finds out until the real endpoint ships.
A couple of mismatches like that, you can live with. I’ve seen it happen on a team almost weekly for two months straight. At that point it stops being an annoyance and starts costing the relationship between the two teams. Backend causes real delays on their side; the frontend team is still expected to report done, on schedule, because from the outside the work looks finished. Then a live integration test fails, and it isn’t a quiet fix anymore—it’s a conversation at the management level about a team that looked done and wasn’t.
Your stub is still matching a contract that stopped existing days ago, and the CI is still green. It went stale during the exact week the feature was being built, because the thing it modeled was moving on the same timeline as the code testing it. The gap between “my tests pass” and “my code works against the real API” isn’t spotted until it shows up in QA, or worse, in production. The problem was set in place the moment the stub was written instead of recorded from something real.
This is what it looks like now:
@Test(
"fetchProducts decodes the remote response",
.replay("fetchProducts", matching: .default, filters: replayFilters, rootURL: replaysRootURL)
)
func fetchProductsDecodesResponse() async throws {
let repo = DefaultStoreRepository(client: DefaultNetworkClient())
let products = try await repo.fetchProducts(category: nil)
#expect(products.count == 88)
}
It’s the same intent—prove the repository decodes what the server sends. StoreProduct.stubs is not used anymore because there is no manually added data standing in for the API response. fetchProducts.har is a transcript of a real request against a real backend, redacted and committed to the repo, replayed byte-for-byte on every test run—offline. The stub was deleted and replaced by a recording of the thing itself.
A coding agent can trigger this recording to a HAR file and replay it for offline tests. It is a verified source of truth instead of an agent making a guess.
A Passing Test Isn’t the Same as Accurate
A bug in StoreProduct.stubs is the same problem the last article covered for navigation state, one layer down, in data instead of screens. Nothing kept it in sync with whatStoreRepository.fetchProducts would actually decode from the real API. A StoreProduct.stubs array with a missing field or a wrong type still compiles, still runs, and still comes back green in a unit test.
What Actually Changed
A HAR file — HTTP Archive — is a plain JSON record of a request and its response: the URL, headers, body, and timing, all in a format any tool can read back. It’s a standard export format that browsers, Charles, Postman, and Proxyman have all supported for almost a decade. Anyone with HTTP debugging proxy tools could capture a real request/response pair from a live session and export it as a HAR file.
Capturing a fixture with Proxyman is a manual, human, GUI-driven act—open the proxy, perform the flow, export the session, place the file, and wire it into the test. Replay reduces that into a command: REPLAY_RECORD_MODE=once against the existing test, and the fixture is captured. Keeping a fixture current becomes as simple as running the test that needs it—something an agent can trigger itself.
Once recorded, replay is deterministic. Every subsequent run replays the exact same response, with no dependence on the backend’s current state.
The real payoff isn’t the recording itself, though. It’s what happens the moment a recording changes. Re-record fetchProducts.har against a backend that renamed a field or added a required parameter, and the fixture carries the exact difference nobody told you about in the scenario above. A coding agent doesn’t need a person to notice that gap first. It can re-record, diff the result against the previous fixture, and when something structural changed, update the code that decodes it and the tests that assert against it—in the same loop that caught the change. The key difference is not just that “recording is easier now.” In the above scenario the two-month stretch of weekly surprises stops being two months of surprises because nothing has to wait for a human to notice.
Note that a raw git diff on a re-recorded .har is noisy by design—the Date response header and Replay’s own recording metadata change on every capture, and Swift’s JSONEncoder doesn’t guarantee stable key ordering between separate encodes, so the body can show as fully rewritten even when nothing in it actually changed. Compare the decoded JSON structure, not the file bytes, and the field rename shows up exactly as small and legible as the loop above depends on it being.
Coding agents are supposed to make delivery faster. When a step that used to require a human—opening a proxy, exporting a session, wiring the file into a test—becomes something the agent does on its own. Every future contract change gets the same automated handling, not just the first one someone happened to catch.
Stepping Through the Process
Point fetchProductsDecodesResponse at an endpoint with no recorded fixture yet, and run it. Nothing gets captured automatically. It fails with a guided response in the terminal:
⚠️ Replay Archive Missing
Test: fetchProductsDecodesResponse
Expected archive: Replays/fetchProducts.har
To record this test's HTTP traffic (archive missing), run:
env REPLAY_RECORD_MODE=once swift test --filter fetchProducts decodes the remote response
To run against the live network (ignore fixtures), run:
env REPLAY_PLAYBACK_MODE=live swift test --filter fetchProducts decodes the remote response
Note: Archives are NOT created automatically to prevent
accidental recording of incorrect responses.
This is an intentional guardrail similar to a compiler error on an unhandled case. A deliberate decision has to be made to go capture real traffic before this test can pass. The fix is one flag: REPLAY_RECORD_MODE=once — reaching the test with it on an iOS-only package takes a specific mechanism, not the obvious one; see the appendix for exactly how and why.
Run the test again with recording on against a real ShopAppServer instance, and the request actually goes out—GET /v1/products—and the response comes back and gets written to fetchProducts.har. Open the resulting file, and it’s just a plaintext JSON file. The request: GET http://localhost:8080/v1/products. The response: status 200, and a body that’s the real product catalog, captured exactly as the server sent it—down to a stray floating-point artifact in one product’s price (2499.989999999999488, not a clean 2499.99) that nobody would have thought to type by hand, because nobody would have known it was there.
Every run after this one replays that exact file instead of touching the network. It’s calling the transcript.
What This Doesn’t Fix By Itself
A HAR fixture needs something real to record from: a live backend, or at minimum a realistic staging one. Stubs have their place when there is no backend to work with. The typical usage scenario is that it records once and replays forever. In the opening case where there is active parallel development, re-recording can be done deliberately and often can be done easily before a PR hits the review stage. REPLAY_RECORD_MODE=rewrite is the flag for this ongoing practice. It replaces an existing fixture on demand, which is exactly what would be needed for this case.
Where Each Tool Actually Belongs
The MockNetworkClient validates request construction: URLs, query parameters, and caching behavior. A Replay .har fixture validates response fidelity against a contract that’s actually real. A snapshot test validates rendered output for a given model state. They all run offline without loading the app in the simulator.
@Test(
"Root view renders real recorded product data end-to-end",
.replay("fetchProducts", matching: .default, filters: replayFilters, rootURL: replaysRootURL)
)
func rootViewRendersRealRecordedData() async throws {
let model = StoreModel(repository: DefaultStoreRepository(client: DefaultNetworkClient()))
await model.load()
assertSnapshot(
of: StoreView(model: model),
as: .image(layout: .device(config: .iPhone13Pro)),
named: "loaded_from_replay"
)
}
A real StoreModel calls a real StoreRepository, served by the same fetchProducts.har fixture StoreRepositoryTests already decoded, rendered by a real StoreView, and snapshotted—fetch and render, both real, both offline, in one test, no mock standing in for either layer. The detail that makes it deterministic instead of flaky is easy to miss: model.load() is awaited to full completion before StoreView is even constructed. There’s no delay, no polling, no race between the view appearing and the data arriving—the test simply doesn’t move on until the async work is finished.
A Safety Net for the Safety Net
fetchProducts.har gets committed to the repo, in plain text, the same as any other source file. That’s fine; while the endpoint it’s recording has nothing to hide—ShopAppServer has no authentication today. It stops being fine the moment a real backend does, and a HAR file recorded against staging with a live session token or an API key sitting in a header ends up committed to a repo other people, or other tools, can read.
StoreRepositoryTests.swift applies a filter to every fixture it records, not just the ones that look sensitive today:
let replayFilters: [Filter] = [
.headers(removing: ["Authorization", "Cookie"]),
.queryParameters(removing: ["token", "api_key"]),
]
Applied at record time, this prevents a token being accidentally added to the repo. The Authorization header comes back as [FILTERED] is visibly redacted. ReplayFilterTests.swift proves this against a manufactured entry, since nothing ShopAppServer actually returns has a secret in it yet to demonstrate the real thing.
That “yet” is the whole point. A policy that only redacts endpoints someone remembers have secrets is the same failure mode as a hand-typed stub going stale—it depends on a human noticing, on a day nobody’s specifically checking for it. This is exactly the kind of rule that belongs in AGENTS.md, applied to every recorded fixture as a blanket policy, not left to a code review comment someone might miss on the one PR that happens to add an authenticated endpoint.
Where This Matters Most for an Agent
The record opt-in for a HAR fixture isn’t a new idea—it’s the same process as taking a snapshot baseline, down to the mechanism. Swift Snapshot Testing reads a SNAPSHOT_TESTING_RECORD environment variable with modes named all, failed, missing, and never. Replay reads REPLAY_RECORD_MODE with modes named none, once, and rewrite. They have different names but the same concept: an explicit mode governs whether capturing happens, checked from the environment, not hardcoded into the test.
Where they differ is the default they start from. Snapshot Testing’s default is missing—records automatically, the moment a baseline isn’t found on disk, and no flag is required. That’s a reasonable default for a UI snapshot: being wrong costs a pixel diff someone reviews before committing, so most projects leave it alone. Replay’s default is none—it never auto-captures, not even on the very first run. A missing HAR fixture means an agent is about to make a real request against a real backend, possibly at a real cost, possibly carrying a real credential in a header. That’s a higher-stakes first run than a screenshot, and the default is more conservative to match: nothing happens until a human or an agent explicitly says “once” or “rewrite.”
That default matters specifically for a coding agent, which might run this test suite unattended, dozens of times in a session. There’s no unsupervised path from “the archive is missing” to “traffic went out” or “a fixture got “written”—the constraint doesn’t depend on the agent remembering to be careful. It’s the same shape as the Bool-flag problem earlier in this piece: not a rule to follow, a state the operation itself won’t let anyone reach by accident.
The CLI makes this something an agent can act on directly, not just something to avoid. swift package replay status, inspect, and validate return structured information about what a fixture contains and whether it’s valid—the same kind of scriptable surface as swift test itself, not a paragraph in a README an agent has to interpret correctly. Asked whether a fixture might be stale, or what an endpoint actually returns, an agent has a command to run for that — not a guess, and not a question for a human to answer first.
Is Replay a Power Tool?
The idea isn’t new. Replay’s author credits Ruby’s VCR and Python’s VCR.py — HAR-backed recording has been standard practice elsewhere for well over a decade; Replay packages it for Swift, with a special macro function for tests. A hand-rolled URLProtocol subclass plus a small Codable HAR model reproduces the core mechanism in a few hundred lines, cookie handling included, for free from Foundation’s own URL Loading System. A companion sketch does exactly this, for a team stuck on an older toolchain that can’t take the dependency—and it fixes a real Replay bug along the way, encoding with sorted keys from the start instead of the non-deterministic ordering covered earlier in this piece. The case for Replay was never “only this can do it.”
What it actually rests on: not writing and maintaining those few hundred lines yourself, recording policy declared per test instead of scattered through setup and teardown, and a CLI that inspects fixtures for you — though it still can’t reach a test on an iOS-only package, the same caveat as before. A productivity case, not a capability one, worth saying plainly so a reader doesn’t assume offline HAR replay is something only this library can do.
What it’s not: a replacement for XCUITest-level device truth, a fix for WebSocket or streaming traffic, or a contract test against a live, evolving API. A stale HAR fixture is confidently wrong in exactly the way a stale snapshot is—recording discipline is a process the team still owns.
The honest framing, then: it’s a power tool the way a type system is one—not because it does something previously impossible, but because it moves a whole category of drift from something a human has to remember to check to something that fails the build.
Where to Start
Adopting Replay can be reduced to three steps.
Add Replay as a dependency—one line in Package.swift, .package(url: “https://github.com/mattt/Replay", from: “0.4.0”) — on whatever test target needs it, the same way ShopApp added it in the commit that started this whole shift.
Pick one existing test, ideally the smallest one that hits a real endpoint, and record one fixture for it—not the whole suite. One test, one .har file, so the process happens once, deliberately, before it turns into routine: run it cold, watch it fail with Replay Archive Missing, set REPLAY_RECORD_MODE=once, run it again, and turn the flag back off.
Leave everything else alone. Whatever your project’s request-construction spy already is—its own MockNetworkClient equivalent — stays exactly where it is, doing exactly the job it was already doing: URLs, query parameters, caching, and call counts. This isn’t a rip-and-replace, and treating it like one is how a genuinely useful tool turns into a rewrite nobody asked for.
Then go back to the scenario in the first section. The backend team renames a field or wraps the response in a pagination envelope mid-sprint, without telling anyone. With a hand-typed stub, nobody finds out until a live integration test fails in front of a manager. With a recorded fixture, the next deliberate re-record — REPLAY_RECORD_MODE=rewrite, run before the PR that touches that endpoint goes up—carries the difference in the file itself. Not a surprise in staging, months in. A diff, in a PR, days earlier than it would have shown up otherwise.
Next in this series: what a SwiftUI feature module’s public API actually is, and the alternative to a plain public initializer that lets micro-apps reach test scenarios without opening up the whole module.
Appendix: Verifying the Failure Mode
The claim earlier in this piece—that a re-recorded fixture carries a backend contract change and that a stale decoder fails loudly instead of silently—was checked directly against the ShopAppServer.
The change: in ShopAppServer, StoreProduct.category was renamed to categoryName in Models.swift, routes.swift, and SeedData.swift — the same “backend renamed a field” scenario described earlier, made real. ShopApp’s own StoreProduct.swift was left untouched, exactly as a client-side developer would experience it: the backend changes, nobody tells you, and your code doesn’t know yet.
How the HAR file updates: running the three commands above against the modified server changed Features/Store/Tests/Sources/Replays/fetchProducts.har in place — the file hash 139638b1… became 6a79c752…. Checked directly: the old “category” key no longer appears anywhere in the file (0 occurrences); “categoryName” does. No manual editing—the CLI-driven record pass rewrote the committed fixture entirely on its own.
What failed: the same test run that captured the new fixture also exercised the app’s real decode path against the live response, and it failed immediately:
DecodingError.keyNotFound: Key ‘category’ not found in keyed decoding container.
Path: [0]. Debug description: No value associated with key CodingKeys(stringValue: “category”, intValue: nil) (“category”).
Re-run afterward with recording off—purely offline, fixture only, no server—the identical failure reproduces in 0.03 seconds. That’s the whole claim in one number: the same information a live integration test would eventually have surfaced, in thirty milliseconds, from a committed file, before anything shipped.
Appendix: Extending AGENTS.md for Replay
Article 1 ended with an AGENTS.md appendix—rules an AI coding agent follows automatically instead of relearning by trial and error. This is additive to that same file, not a new one.
## Replay Fixtures
### Rule: never set REPLAY_RECORD_MODE in CI
# Correct - CI always plays back committed fixtures
xcodebuild test -scheme StoreTests -destination '…'
# Never set a record mode in a CI job
REPLAY_RECORD_MODE=once xcodebuild test -scheme StoreTests -destination '…'
Why: a fixture recorded in CI wasn't reviewed by anyone before it became the new source of truth. New or updated fixtures are recorded locally, inspected, and committed like any other diff - the same discipline as a snapshot test, where `record: true` is never something that ships committed either.
### Rule: apply redaction filters to every fixture, not just the ones that look sensitive today
let replayFilters: [Filter] = [
.headers(removing: ["Authorization", "Cookie"]),
.queryParameters(removing: ["token", "api_key"]),
]
@Test("…", .replay("name", matching: .default, filters: replayFilters, rootURL: replaysRootURL))
Why: a policy that only redacts endpoints someone remembers have secrets depends on a human noticing, on the one day nobody's specifically checking. Apply the filter list to every new fixture, regardless of whether the endpoint being recorded looks like it has anything to hide right now.
### Rule: default to strict matching; only loosen it with a stated reason
# Correct - default
.replay("fetchProducts", matching: .default, …)
# Correct - loosened, with the reason stated in a comment
// .url breaks on the page cursor in the query string
.replay("fetchOrders", matching: [.method, .path], …)
# Never loosen a matcher without saying why
.replay("fetchOrders", matching: [.method, .path], …)
Why: .default (method plus the full URL) is the strictest option and the right starting point - it occasionally catches more than it should, and a false mismatch is a cheap, loud failure. Loosening it silently hides the fact that a fixture's matching guarantee just got weaker, for a reason nobody wrote down.
### Rule: MockNetworkClient and Replay fixtures answer different questions - don't blur them
# Correct - request construction, caching, call counts: MockNetworkClient
let client = MockNetworkClient()
#expect(client.receivedRequests.count == 1)
# Correct - response fidelity against a real contract: Replay
@Test("…", .replay("fetchProducts", matching: .default, filters: replayFilters, rootURL: replaysRootURL))
func fetchProductsDecodesResponse() async throws { … }
# Never reach for a hand-typed stub array to answer "does this match
# what the server actually returns" - it can't, by construction.
Why: an agent adding a new repository test should be able to tell which tool it needs from this rule alone. Testing how the request gets built is MockNetworkClient's job. Testing whether the response decodes correctly against reality is Replay's. Neither one substitutes for the other, and a test that needs both uses both.
References
NSHipster Replay
https://nshipster.com/replay/
The HTTP Archive (HAR) Format
https://w3c.github.io/web-performance/specs/HAR/Overview.html
The companion HAR-without-Replay sketch. This code has not been tested to work and is only there for the explanation above.
https://github.com/ronanociosoig/Shopapp/blob/main/reference/HAR-fundamentals-without-Replay.swift
An older library that is similar by recording a network response, but does not require Swift 6:
The previous article: AI Writes Fast. Your Safety Net Needs To Be Faster
/articles/2026/ai-writes-fast/
The ShopApp iOS application
The ShopAppServer Vapor application