Faking an external service you do not own
Hard-WonA stateful fake plus a control API, so a real app runs a full journey with no live calls — and the isolation you must build before the first run.
- Authors
- Leon Mallett, Founder of Captivated Ltd with Claude Code
- Status
- Last confirmed working 21 August 2026 on Rust with an embedded HTTP router 2026-08, Playwright 1.x
- Written
- 17 August 2026
- Licence
- Handover-1.0
Read this when a project depends on an external network service you do not own — a social platform, a model host, a payments provider, a partner API — and you want the real application, or its real client code, to run end to end without touching the live service. Deterministically, in CI, driveable by an agent.
The line to remember: stand up a stateful fake of the service and make the base URL per-connection data, so a test account is just one whose endpoint points at the fake. Then add a control API to script the messy cases — rate limits, expired tokens, server errors, asynchronous processing — on demand.
Why not stub each request
Per-request stubs are perfect for unit tests: one request, one canned reply, asserted in isolation. They fall short for end-to-end confidence because they are stateless and per-test. They cannot model “create a thing, then later read that same thing’s metrics”, they do not exercise the real scheduler, retry and token-refresh loops over wall-clock, and they cannot be driven by the running application.
For that you want a small persistent, stateful service that behaves like the real API across a whole session.
The two seams
Everything rests on two seams. If they are not there, add them — they are cheap and independently good design.
1. The base URL is per-connection data, not a constant. Each integration
builds its client from a stored per-account endpoint. A test connection is then
just a record whose endpoint points at the fake — no branching, no if test
scattered through the code.
This one pays for itself immediately. In the case this document comes from, the adapters needed no changes at all, because the base URL was already per-account data.
2. A control API on the fake, to script behaviour on demand: reset state, queue a specific status and headers for the next call to an endpoint, inject an inbound event, read back what the application actually sent. Without it you can only ever test the happy path.
Anatomy
One implementation, two delivery modes. Put the fake in a library module that builds a router, then serve it from a small binary and bind it in-process from integration tests. One source of truth, used both by the running application and by hermetic tests.
A stateful store behind a lock — created records keyed by id, per-record metrics, injected inbound events, and a per-endpoint queue of scripted responses. In memory, reset between tests through the control API.
Response shapes matched to the client’s parser, field for field. Lift them from the fixtures your unit tests already use, so the fake cannot drift from what the client actually deserialises. Field names are case-sensitive and services disagree with each other about them; a fake that is nearly right produces failures that look like application bugs.
A control plane for reset, scripted responses, event injection, and reading back what was received.
Determinism: no clock, no randomness. Use a monotonic counter for ids and fixed timestamps, or pass them in. Reproducibility comes from removing ambient nondeterminism, not from tolerating it.
Test mode, so the running application can use it
A dev-only affordance that seeds test connections pointed at the fake, bypassing real credential flows.
Gate it out of release behind two independent fences. Compile the commands only in debug builds, and hide the interface behind a development flag. A signed release binary must not be able to seed a fake account or route real work to a fake endpoint. One fence is a single mistake away from shipping.
Badge test connections everywhere in the interface, so a fake can never be mistaken for real at a glance.
Only seed what the fake actually implements. If it covers two of four integrations, seed two. Handing someone a connection that cannot work is the same dead end as a broken onboarding step.
Fidelity, which is the load-bearing caveat
A hand-rolled fake tests your application against your assumptions about the API, not against reality. Be honest about that boundary:
- Seed shapes from recorded real responses where you can, rather than from memory.
- Keep per-request contract tests as the layer that pins exact shapes against captured real payloads. Let the fake focus on stateful flow.
- Log the gaps — endpoints returning empty, failure modes not modelled — so “it passed against the harness” is never mistaken for “it works against the real service”.
- Still run a periodic real smoke test on a throwaway account for the paths that matter.
It proves your pipeline is correct given the shapes you expect. It cannot prove your assumptions are still true.
Driving a real backend from an ordinary browser
If your platform’s end-to-end driver is weak — and on some desktop frameworks it is — there is an escape hatch. Run a dev-only HTTP bridge inside the application that shares live application state and exposes the real command handlers over a request endpoint, plus a server-sent-events stream mirroring the application’s events. Give the frontend a transport switch: native IPC when running normally, the bridge when running in a plain browser.
A browser automation tool then drives the real journey through the real backend against the fake service, with no WebDriver involved.
Two gotchas that cost real time:
Getting genuine application state into the bridge handlers is the trick. Resolve the shared state from the application handle inside the handler, so no command logic is duplicated. A bridge that reimplements handlers is testing itself.
Framework plugin APIs are not bridged. Anything provided by the host environment rather than your own code — updaters, native dialogs, file-path conversion — will throw in a browser-run view. Drive around those screens, or set up state through the bridge rather than through a screen that calls one.
Isolation is not optional
The end-to-end run must never see real accounts or touch real credentials.
- Redirect the data directory to a throwaway database.
- Short-circuit the credential store for test accounts, so no real secret is ever loaded.
Bake both in before the first run, not after. A real-account publish from a test run is a real publish — and the way anyone learns that is by doing it.
Adapting to any stack
The two seams transfer; only the tools change. A small embedded HTTP server in your own language, started on an ephemeral port, with the client’s base URL injected from configuration.
If a vendor SDK hardcodes its host, wrap it so the host is injectable. That wrapper is the seam, and it is worth adding regardless of testing.
When a stack is not one you have done before, find the same two things: make the endpoint injectable per connection, and add a control API to script failures and inject inbound events. That is the whole pattern.
The companion problem
This fakes the far side, the network. Driving the near side — the interface, by stable identity rather than pixels — is a separate discipline with its own document. Together they let a full user journey run end to end with no live external calls and no cursor.