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.

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.

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 HTTP client sit on the left, the interception seam sits in the middle redirecting traffic to in-memory handlers instead of the real third-party service on the right. App / Component code under test fetch / axios HTTP client interception seam In-memory handlers deterministic Real service blocked in tests

Prerequisites

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

  • Vitest 1.x or 2.x as the primary runner (Jest 29+ works with the same hooks).
  • msw v2 installed as a dev dependency for HTTP and GraphQL interception.
  • setupFiles entry reserved exclusively for mock lifecycle wiring (no infrastructure bootstrapping mixed in).
  • SIMULATION_MODE) so the same suite can run in strict, passthrough, or record modes.

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');
}

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 });
  }),
];

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.

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);
});

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,
  },
});

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.

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.

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.

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.

In-Depth Guides