External Service Simulation

External service simulation replaces live network calls with deterministic, in-process or proxy-level stand-ins so a test exercises real client code against a fully controlled response surface. It belongs to the Advanced Mocking & Service Isolation Patterns discipline, but it operates one tier higher than unit-level spies: rather than swapping a function, it intercepts the transport itself, preserving request and response shapes while eliminating third-party volatility, rate limits, and shared-environment drift. This guide gives frontend, full-stack, QA, and platform engineers a runnable blueprint for standing up a simulation layer that survives parallel execution, gates merges on contract validity, and behaves identically on a laptop and in CI.

The value of that stand-in is measured by one property above all others: fidelity. A simulation that returns whatever is convenient teaches your tests to pass against a fiction, so the entire discipline here is to keep the mocked response surface honest — the same status codes, the same headers, the same error envelopes, the same pagination shape — while stripping away only the network’s nondeterminism. Everything below treats that fidelity as the goal and CI stability as the payoff rather than the reverse. A layer built the other way round, optimizing for green runs first, produces a suite that is fast, quiet, and quietly wrong.

Architectural Scope & Boundaries

Simulation lives at the integration tier — the seam where your application talks to something it does not own. That includes REST and GraphQL APIs, third-party SDKs, payment gateways, auth providers, and WebSocket endpoints. It deliberately does not cover pure business logic (a reducer, a formatter, a validation function), which belongs under fast unit tests, nor does it replace a true end-to-end smoke test that hits a real staging environment.

The reason to intercept at the transport rather than inside your own modules is a question of what the test is permitted to prove. A stub swapped in for a function asserts only that your code called that function; a transport-level simulation asserts that your code produced a correct HTTP request and then correctly parsed a real-shaped response. The second is a far stronger guarantee, because serialization, header construction, query-string encoding, retry loops, authentication refresh, and error parsing are exactly the code paths that break in production — and exactly the ones that vanish the moment you stub the function sitting above them. Simulation keeps that machinery under test while removing only the part you cannot control.

Different external surfaces are intercepted at slightly different granularities, but all of them resolve to the same transport seam. A REST API is addressed by method and path; a GraphQL API multiplexes every operation onto one URL and is addressed by operation name; a vendor SDK ultimately makes HTTP calls you can intercept beneath its abstraction rather than mocking the SDK’s own surface. Choosing to simulate below the SDK, not above it, is deliberate: it keeps the SDK’s own request-building and response-parsing logic — the parts most likely to encode a subtle bug — inside the tested path.

Drawing the boundary precisely is what makes simulation valuable rather than misleading:

  • Too low — stubbing individual methods inside your service module — couples tests to implementation detail and stops exercising serialization, headers, retries, and error parsing.
  • Too high — letting the real network through — reintroduces flakiness, secrets management, and slow runs.
  • Just right — intercepting at the HTTP/transport layer with a tool like MSW — keeps every byte of client logic under test while the network is fully deterministic.

The diagram below shows where the interception seam sits relative to the rest of the request path.

Interception seam in the request path Application code and its HTTP client sit on the left; a gold interception seam in the middle redirects traffic to deterministic in-memory handlers on the upper right while the real third-party service on the lower right is blocked in tests. App / Component code under test fetch / axios HTTP client interception seam In-memory handlers deterministic responses Real service blocked in tests dashed path never taken while a handler matches
The interception seam redirects client traffic to deterministic handlers and blocks the real service.

Two forces pull teams away from that seam. Pushed too low, the suite ossifies around today’s implementation and every harmless refactor rewrites the mocks even though observable behavior is unchanged. Pushed too high, the suite reacquires every property you were trying to escape — credentials in CI, quota exhaustion under parallelism, and failures that correlate with someone else’s deploy rather than your own change. The seam is the single place where the client code is fully exercised and the network is fully owned, which is why it is worth defending with a strict unhandled-request policy rather than treating interception as a soft default that a stray call can slip past.

Prerequisites

Before wiring a simulation layer, confirm the following are in place:

Step-by-Step Implementation

Step 1: Map which dependencies need simulation

Maintain an explicit registry of external boundaries and the mode each should run in. This keeps the simulation surface auditable and prevents accidental live calls.

// src/test/dependency-registry.ts
export type SimulationMode = 'strict' | 'passthrough' | 'record';

export type SimulationTarget = {
  modulePath: string;
  exportName: string;
  simulationMode: SimulationMode;
};

export const DEPENDENCY_GRAPH: Record<string, SimulationTarget> = {
  '@services/payment': { modulePath: './payment-client', exportName: 'processTransaction', simulationMode: 'strict' },
  '@services/auth': { modulePath: './auth-sdk', exportName: 'refreshToken', simulationMode: 'passthrough' },
  '@services/analytics': { modulePath: './analytics-tracker', exportName: 'trackEvent', simulationMode: 'record' },
};

export function resolveStrictTargets(): SimulationTarget[] {
  return Object.values(DEPENDENCY_GRAPH).filter((t) => t.simulationMode === 'strict');
}

The registry earns its keep the first time someone adds a new integration. Because every boundary is declared in one place with an explicit mode, a reviewer can see at a glance whether a new payment call is being simulated strictly or was accidentally left in passthrough, and a lightweight CI lint can fail the build when a target has no declared mode at all. Treat an undeclared external boundary the way you would treat an unpinned dependency version: a latent source of nondeterminism that will eventually surprise you at the worst possible moment. The registry also becomes the natural place to attach ownership and a contract reference, so the answer to “who maintains this mock and against what schema” is never folklore.

Step 2: Define request handlers with the MSW v2 API

Handlers use the v2 resolver signature — ({ request, params }) => HttpResponse.json(...). The removed (req, res, ctx) form will not work. Keep handlers narrow and assert on the request where it matters.

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/v1/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Test User' });
  }),
  http.post('/api/v1/charges', async ({ request }) => {
    const body = (await request.json()) as { amount: number };
    if (body.amount <= 0) {
      return HttpResponse.json({ error: 'invalid_amount' }, { status: 422 });
    }
    return HttpResponse.json({ id: 'ch_test', status: 'succeeded' }, { status: 201 });
  }),
];

Keep resolvers thin and intention-revealing. A handler that quietly returns a 200 for every shape of input is only marginally better than no test at all, because it never exercises the error branches your client is obliged to handle. The charges handler above earns its place precisely because it branches: a non-positive amount returns a 422 with a machine-readable error code, which forces the calling code to carry a real path for rejected charges rather than assuming every request succeeds. Where a contract source of truth exists — an OpenAPI document or a typed client — build the response object from those generated types so a divergence between mock and contract becomes a compile error instead of a runtime surprise discovered in production.

Step 3: Bind the server to the runner lifecycle

For Node-based runs use setupServer from msw/node. Register it once in a setup file so every test file inherits the same interception contract.

// vitest.setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './src/test/mocks/handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

The onUnhandledRequest: 'error' option is the single most important line: it converts any unmocked request into a hard failure, so a newly added call site can never silently escape to the live network. This complements the lower-level HTTP request stubbing techniques used for finer-grained transport control.

The ordering of these hooks is not incidental, and getting it wrong is the most common cause of a mock that “works alone but fails in the full run.” The diagram below traces the lifecycle each test file inherits.

MSW server lifecycle across the test run A one-time beforeAll arms the interceptor, each test intercepts requests and may register a per-test override, afterEach resets handlers, and a final afterAll closes the server. one-time setup repeats per test teardown beforeAll server.listen(strict) test runs handler intercepts server.use overrides afterEach resetHandlers() afterAll close() loop back for the next test
The lifecycle hooks that arm, reset, and tear down interception around every test.

listen must run before any test issues a request, or the very first call escapes before interception is armed; resetHandlers must run after every test so a one-off override registered with server.use cannot survive into an unrelated case and silently answer a request it was never meant to see; and close must run last so the interceptor is uninstalled cleanly and does not leak into a neighboring suite that shares the same worker process. When those three responsibilities are honored, a single test and the full parallel run produce identical results.

Step 4: Override responses per test for failure scenarios

Use server.use(...) inside a test to layer a one-off handler on top of the defaults, then let resetHandlers() clean it up automatically.

// charge.test.ts
import { http, HttpResponse } from 'msw';
import { expect, it } from 'vitest';
import { server } from './vitest.setup';
import { createCharge } from './src/services/billing';

it('surfaces a gateway outage to the caller', async () => {
  server.use(
    http.post('/api/v1/charges', () => HttpResponse.error()), // simulates ECONNREFUSED
  );

  await expect(createCharge({ amount: 500 })).rejects.toThrow(/network/i);
});

HttpResponse.error() is deliberately distinct from returning a 500 status. A 500 is a well-formed HTTP response and exercises your status-code handling; error() models a transport failure with no response at all — a dropped socket or a refused connection — which is the branch your retry and circuit-breaker logic actually guards. Testing only the 500 path leaves the harder and, in practice, more common production failure completely untested. Layering one-off overrides this way keeps the default handlers describing the happy path while each failure scenario stays local to the test that needs it, so no single handler grows into an unreadable tangle of conditionals trying to serve every case at once.

Step 5: Route browser-driven flows at the network layer

For Playwright component testing and full browser runs, intercept at the context level rather than patching window.fetch, and block the Service Worker so the two interception strategies do not collide.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'http://localhost:3000',
    contextOptions: { serviceWorkers: 'block' },
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

The reason to intercept at the browser context rather than monkey-patching window.fetch is layering discipline. A patched global is invisible to any code that captured a reference to fetch before your patch ran, and it silently diverges from the Service Worker that MSW installs for genuine in-browser runs. Blocking the Service Worker in Playwright makes the interception strategy singular and predictable instead of two mechanisms racing for the same request and disagreeing about who answers it. The same handler definitions can then be shared between the Node suite and the browser suite, which is the portability that makes a simulation layer worth building once rather than three times.

Configuration Reference Table

Option Type Default Effect
onUnhandledRequest 'warn' | 'error' | 'bypass' 'warn' Set to 'error' so any uncovered request fails the test instead of leaking to the network.
SIMULATION_MODE (env) 'strict' | 'passthrough' | 'record' 'strict' Toggles whether unmatched traffic is blocked, forwarded, or recorded for fixture capture.
server.use(...) handler list Prepends per-test overrides; cleared by resetHandlers().
resetHandlers() lifecycle hook Run in afterEach to drop per-test overrides and prevent cross-test bleed.
serviceWorkers (Playwright) 'allow' | 'block' 'allow' Block in browser tests to stop the MSW worker colliding with context routing.
delay(ms) number 0 Injects deterministic latency to exercise retry and timeout logic.
HttpResponse.error() Returns a transport-level failure to test network error branches.

The three simulation modes are not interchangeable conveniences; they answer one specific question — what should happen to a request that no handler matched? — in three different ways, and the correct answer depends on the phase of work you are in.

How each simulation mode treats an unmatched request An unmatched request routes to one of three outcomes: strict mode fails the test, passthrough forwards it to the real network, and record forwards once then captures the response as a fixture. Unmatched request no handler matched Block & fail test strict — the CI default Forward to network passthrough — local only Capture fixture record — then replay strict passthrough record
The three modes each answer the unmatched-request question differently.

strict is the correct default for CI: an unmatched request is a gap in mock coverage and should fail loudly rather than reach the wire. passthrough is a local debugging aid that lets an as-yet-unmocked endpoint reach the real service so you can observe its true shape before writing a handler. record sits between them, forwarding a request once and persisting the response as a fixture you then commit and replay under strict. A suite that ships with passthrough as its default has quietly opted out of the entire guarantee simulation exists to provide, so treat any non-strict default in a committed configuration as a review-blocking smell.

Verification & Assertions

A simulation layer is only trustworthy if you can prove it is intercepting. Assert three things: that the response shape matches the real contract, that the request your code sent is correct, and that no traffic escaped.

// users.test.ts
import { http, HttpResponse } from 'msw';
import { expect, it, vi } from 'vitest';
import { server } from './vitest.setup';
import { fetchUser } from './src/services/users';

it('sends the auth header and parses the user', async () => {
  const seen = vi.fn();
  server.use(
    http.get('/api/v1/users/:id', ({ request, params }) => {
      seen(request.headers.get('authorization'));
      return HttpResponse.json({ id: params.id, name: 'Ada' });
    }),
  );

  const user = await fetchUser('42', 'token-abc');

  expect(user).toEqual({ id: '42', name: 'Ada' });
  expect(seen).toHaveBeenCalledWith('Bearer token-abc');
});

Because onUnhandledRequest: 'error' is active, a passing run is itself proof that every request was matched. To confirm coverage of your handlers, gate the suite with explicit thresholds on the service and handler modules.

Asserting on the outbound request is the half of verification teams most often skip, and it is the half that catches the subtlest bugs. A response-only test proves that your code can parse a payload it was handed; it says nothing about whether your code sent the right thing in the first place. Capturing the request inside the handler — the header, the serialized body, the query string — closes that gap, so a misnamed field or a dropped Authorization header fails the test immediately instead of degrading silently in production, where the server simply ignores the malformed part and returns something plausible. The two assertions together verify both directions of the contract: the request your code built and the response your code consumed. Leaving either out means half the interaction is untested even when the suite is green.

Edge Cases & Failure Modes

  • Silent passthrough on a renamed endpoint. If a route changes and no handler matches, you want a loud failure, not a real call. Keeping onUnhandledRequest: 'error' makes the rename surface immediately.
  • Cross-test state bleed. A handler that mutates shared mock state (a “created” record) leaks into the next test. Reset handlers in afterEach and run stateful mutation suites sequentially with --sequence.concurrent=false.
  • Malformed-response handling. Clients often assume well-formed JSON. Return a truncated body with a 200 status to confirm the parser fails gracefully rather than crashing the suite.
import { http, HttpResponse } from 'msw';

export const malformed = http.get('/api/v1/data', () =>
  new HttpResponse('{"broken": ', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
  • Timezone- and clock-dependent payloads. When a response embeds timestamps, pin the clock alongside the network mock so assertions stay stable; see time and date control strategies.
  • Retry storms inflating expected call counts. A client with built-in retry can fire three requests where the test author pictured one. Assert the exact count with a spy and drive fake timers deterministically rather than assuming a single call, or a later change to the retry policy will quietly keep a test green while it no longer means what its author intended.
  • Content negotiation drift. A handler that always returns JSON will happily satisfy a client that asked for application/xml, masking a real negotiation bug. When the Accept header matters, branch on it inside the resolver and return the matching representation so the test exercises the same decision the real server would make.
  • Authentication refresh loops. A 401 handler that never flips to a 200 after a token refresh sends a resilient client into an infinite retry, hanging the test until it times out. Model the refresh explicitly: the first call returns 401, the refresh call returns a fresh token, and the retried original call succeeds, so the whole re-authentication path is proven rather than assumed.

Performance & CI Impact

In-memory interception adds negligible overhead — handlers resolve synchronously and there is no socket setup — so simulated suites run at full unit-test speed while exercising integration-level code paths. The practical wins compound in CI: no secrets to inject, no third-party rate limits to throttle parallel shards, and no network flakiness to retry around.

Two rules keep that fast path intact. First, never rely on real backend latency; if you need to test timeouts or retries, inject deterministic delay with delay(ms) rather than sleeping. Second, contain stateful suites — those that depend on handler-mutated state — to a single fork so parallel workers do not race on shared mock data. Everything else can run fully concurrent. For teams balancing breadth against runtime, this slots cleanly into a deliberate test pyramid strategy, pushing contract-shaped checks down into fast, parallel integration runs instead of slow browser-level ones.

There is a second-order benefit that is easy to overlook: because the response surface is deterministic, a failure is always attributable to your change. Live-network suites produce failures whose cause is ambiguous — was it the code, the third party, or the shard’s DNS? — and that ambiguity is what erodes trust in a suite until people stop reading its output and start reflexively re-running it. A simulated suite that fails means something concrete broke in the diff under test, which is the property that keeps the merge gate credible over months rather than degrading into a formality people click through. The cost of that credibility is the discipline of keeping fixtures current, which is why the record-and-replay mode and a periodic contract check against the real schema are worth the small maintenance they demand.

In This Topic Area