external-service-simulation

Simulating External Services

YouTeacher's end-to-end tests drive the real browser through the real app, but the app leans on services that live outside it — Google and LinkedIn for OAuth login, Google Analytics 4 for telemetry, reCAPTCHA for bot defense, Mailgun for transactional email, Sender.net for newsletter subscriptions, and a handful of image CDNs. None of those can be hit for real in a test: they're slow, they cost money, they require credentials the test rig shouldn't hold, and — for the OAuth providers and reCAPTCHA — they refuse to be scripted at all. So the whole outer edge is replaced with a local stand-in, and every test runs against that edge instead of the internet.

There are two pieces. One is a standalone mock server (mock-service/server.js) — a plain Node http server that answers the shapes the real vendors return. The other is per-context interception set up in the Playwright fixtures (tests/e2e/fixtures.ts), which reroutes the browser's outbound calls to that server before any test runs.

OAuth: redirect the provider to a local page

The fixture intercepts requests headed for the Google and LinkedIn OAuth hosts and answers them with a redirect to the mock server's authorize endpoint, forwarding only the params that matter — the redirect URI, the state, and a test_id the test helper attaches beforehand. The mock then plays out the three-legged dance the app expects: authorize hands back a callback with an authorization code, the app exchanges that code for an access token, and a userinfo endpoint returns a profile. The whole point of threading test_id through every leg is identity: it lets each parallel test get its own unique account, so two workers signing in at the same instant don't collide. An early bug did exactly that — codes minted with only a millisecond timestamp overlapped across workers, and one worker's login resolved to another's email — which is why the identity token is now baked into the code itself rather than derived from the clock.

GA4: rewrite the beacons, capture the events

Analytics is trickier because the browser sends it two different ways — a sendBeacon call and a fetch, both aimed at Google's collector. The fixture patches both at the page level so any request recognizably headed for the analytics collector is rewritten to point at the mock's collect endpoint instead, and a route handler catches the vendor domains as a backstop. The mock parses the Measurement Protocol params — event name, page path, the string and numeric custom parameters — and keeps every event in memory, so a test can later read back exactly what analytics would have reported and assert on it. The initial analytics script is allowed through untouched; only the event traffic is diverted.

Email and the OTP loop

The mock accepts Mailgun's send endpoint, stores each message keyed by recipient, and exposes a small custom endpoint for tests to read a recipient's inbox back. This closes a real product loop: a test signs up, the app "sends" a one-time code by email, and a helper (tests/e2e/helpers/mailgun.ts) polls that mock inbox until the message arrives, pulls the six-digit code out of the email body with a set of fallback patterns, and feeds it back into the login flow — the same way a person would read the code off their screen and type it in. The helper can also render the stored email HTML to a screenshot as a test artifact. Stored mail is kept only briefly and swept on a short retention window so the in-memory store doesn't grow without bound.

reCAPTCHA, Sender, and image CDNs

reCAPTCHA is faked at both ends: the mock serves a tiny script that stubs the grecaptcha global so execute() resolves instantly, and its verify endpoint returns success by default — while still recognizing marker tokens that let a test deliberately exercise the invalid-token and low-score branches. The Sender.net newsletter API is mirrored closely enough to add, read, and remove a subscriber. And the image CDNs (chart/QR generation, placeholder photos) are short-circuited to a single 1×1 transparent PNG, so nothing waits on an image host that would otherwise hang the page's load event.

Why it's shaped this way

The design keeps a hard line: tests exercise the real app end to end, and only the outermost third-party edge is replaced. The stand-in isn't a bank of hardcoded answers — it plays back the real request/response shapes, holds real state (emails, analytics events, subscribers) that a test can inspect, and even simulates the failure paths (bad captcha token, invalid OAuth code) so error handling gets covered too. That's what lets a test assert on correct outcomes — the right email arrived, the right analytics event fired — rather than merely that nothing crashed.

Related

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →