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.
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).
mswv2 installed as a dev dependency for HTTP and GraphQL interception.setupFilesentry 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
afterEachand run stateful mutation suites sequentially with--sequence.concurrent=false. - Malformed-response handling. Clients often assume well-formed JSON. Return a truncated body with a
200status 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
- Using MSW to mock GraphQL endpoints locally — intercept GraphQL operations by name, return deterministic data and error payloads, and keep CI free of backend drift.
- MSW v2 vs Nock for Node HTTP mocking — a decision guide comparing interception models, ESM and
fetchsupport, request assertions, and migration paths.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- HTTP Request Stubbing Techniques — lower-level transport interception that pairs with simulation.
- DOM & Browser API Mocking — client-side overrides that run in sync with simulated fetch cycles.
- Time & Date Control Strategies — pin the clock when responses carry timestamps.
- Test Strategy & Pyramid Design — where integration-tier simulation fits in the overall layering.
Using MSW to mock GraphQL endpoints locally
Intercept GraphQL operations with MSW v2 for deterministic local and CI tests. Handlers by operation name, error payloads, pagination, and clean teardown.
MSW v2 vs Nock for Node HTTP mocking
A decision guide comparing MSW v2 and Nock for Node HTTP mocking: interception model, ESM and fetch support, request assertions, and a migration path.