HTTP Request Stubbing Techniques
HTTP request stubbing decouples client-side execution from backend volatility so a test asserts on application behaviour rather than on the health of an upstream service. It is the most frequently used technique in the broader discipline of advanced mocking and service isolation, because almost every component, hook, or service layer eventually talks to the network. Done well, stubbing produces fast, deterministic suites that fail only when your code is wrong; done badly, it produces silent passthroughs, leaked handlers, and contract drift that hides real regressions. This guide establishes the architectural boundaries, the exact configuration syntax, the verification protocol, and the failure modes you must plan for when intercepting requests in modern JavaScript test runners.
The default stack here is Vitest as the runner and Mock Service Worker (MSW) v2 as the interception layer, with fetch overrides and axios adapter mocking covered for the cases where a full request lifecycle is unnecessary. Every example uses the MSW v2 resolver signature — http.get('/x', ({ request }) => HttpResponse.json(...)) — never the removed (req, res, ctx) form.
Architectural Scope & Boundaries
Stubbing lives at the integration tier of a deliberate test pyramid strategy. It is the right tool when your unit under test issues real HTTP calls — a data-access module, a React hook backed by a fetcher, a server action that calls a third-party API. It is the wrong tool for pure functions (use plain spies) and for full end-to-end smoke tests against a deployed environment (let real traffic flow). Choosing the correct tier first prevents the two classic anti-patterns: over-stubbing, which fossilises assumptions about a backend that has since changed, and under-stubbing, which lets flaky network conditions leak into the suite.
Over-stubbing is the more insidious of the two because it produces a suite that is confidently, permanently green. When every response is hand-written, the tests encode the backend as it existed the day they were written; the day the provider renames a field or tightens a validation rule, production breaks while the suite stays green, because the stub was never told. The defence is to keep stubs shallow and few, to derive them from the same schema the provider publishes wherever possible, and to reserve exhaustive fixture libraries for the external service simulation layer where they can be validated against a contract. Under-stubbing is the opposite failure and easier to spot: a test that occasionally reaches the real network is slow, order-dependent, and red only when someone else’s service is down, which is precisely when your own build should be unaffected.
Within the integration tier there are three interception layers, shown in the diagram above, and the boundary between them matters:
- Network interceptor (MSW v2) — intercepts at the platform request level, after your client (
fetch,axios,ky) has serialised the request. This is the highest-fidelity option because the request travels through your real client code, including interceptors, retries, and transformers. Prefer it for anything you would call an integration test. fetchoverride — replacesglobalThis.fetchwith a spy. Fast and dependency-free, but it bypasses every layer of client logic above the transport. Use it for narrow unit tests where you only care that a function readsresponse.json()correctly.- axios adapter mock — swaps the axios adapter so requests resolve from a registry. It runs real axios interceptors and transformers, making it the natural choice when the behaviour under test lives in axios configuration itself; this is explored in depth for axios interceptors.
The organising trade-off across those three layers is fidelity versus speed and control. Fidelity is how much of your real client code the request actually travels through before it is answered; speed and control are how cheaply and precisely you can shape the response. A fetch override sits at the fast, low-fidelity end — you replace the transport wholesale, so nothing above it (base URL resolution, header defaults, retry logic, response transformers) ever runs. MSW sits at the high-fidelity end because it answers the request only after your client has fully serialised it, so the assertion reflects what your production code would actually send. The axios adapter is the middle ground: the client pipeline runs, but the network never does. A reliable rule is to pick the lowest-fidelity layer that still exercises the behaviour under test — using MSW to check that JSON.parse runs is wasteful, and using a fetch spy to check that an axios interceptor attaches a header is simply wrong, because the spy bypasses the interceptor entirely.
What this technique explicitly does not cover: WebSocket and Server-Sent Event streams, browser globals such as matchMedia or IntersectionObserver (see DOM and browser API mocking), and contract verification against a live provider. For schema-validated stand-ins that mirror a real GraphQL or REST surface, escalate to external service simulation. The boundary is worth policing deliberately: the moment a suite starts asserting on how the upstream service behaves rather than on how your code reacts to a response, it has drifted out of stubbing territory and into contract testing, where a recorded or schema-generated fixture is a safer source of truth than a hand-written handler that quietly rots as the provider evolves.
Prerequisites
Step-by-Step Implementation
Step 1: Define MSW v2 handlers
Handlers are the contract. Each one matches a method and path and returns a typed HttpResponse. Keep them in one module so they are discoverable and reusable across suites.
// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Ada Lovelace', role: 'admin' });
}),
http.post('/api/users', async ({ request }) => {
const body = (await request.json()) as { name: string };
return HttpResponse.json({ id: 'usr_new', name: body.name }, { status: 201 });
}),
];
Step 2: Bind the server to the runner lifecycle
In Node-based runners use setupServer from msw/node — never setupWorker, which is the browser Service Worker variant and throws on import in Node. The onUnhandledRequest: 'error' option converts any un-stubbed call into a hard failure, which is the single most important guard against silent passthroughs.
// test/setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
The afterEach(() => server.resetHandlers()) call is non-negotiable: it discards any per-test overrides added with server.use(...) so handlers never leak across files. Omitting it is the root cause of the retention problems detailed in mocking fetch and axios without memory leaks. The three hooks form a strict lifecycle contract — start once, reset between each test, close at the end — and the ordering matters: listen must run before any test issues a request, and close must run after the last one so the interceptor releases the global patch it installed on the runtime’s request primitives. Registering the server inside a describe block instead of shared setup is a common mistake that leaves the patch active for sibling files that never asked for it.
Step 3: Override responses per test
Use server.use() inside a test to add a one-off handler for the scenario under test — an error path, a slow response, a different payload. Because of the reset hook in Step 2, the override evaporates at the end of the test.
// users.test.ts
import { expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from './setup';
import { getUser } from '../src/api/users';
it('surfaces a 500 as a thrown error', async () => {
server.use(
http.get('/api/users/:id', () => new HttpResponse(null, { status: 500 })),
);
await expect(getUser('1')).rejects.toThrow(/500/);
});
Step 4: Use a lightweight fetch override when fidelity is not needed
When you only need to confirm a function parses a body correctly, a vi.stubGlobal override is faster and avoids the MSW lifecycle. It registers with Vitest’s teardown registry so vi.unstubAllGlobals() cleans it up.
// parse.test.ts
import { afterEach, expect, it, vi } from 'vitest';
import { readConfig } from '../src/config';
afterEach(() => vi.unstubAllGlobals());
it('reads the theme from the config endpoint', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ theme: 'dark' }), { status: 200 }),
));
await expect(readConfig()).resolves.toEqual({ theme: 'dark' });
});
Step 5: Mock the axios adapter for client-specific behaviour
When the behaviour under test lives in axios itself — base URLs, interceptors, retry logic — stub at the adapter layer with axios-mock-adapter so the real client pipeline still runs. Prefer attaching the adapter to a per-test instance from axios.create() rather than the shared default export; a mock installed on the singleton is visible to every other file in the same worker until it is restored, and a single missed restore() produces order-dependent failures that only appear once the suite is shuffled. The dedicated guide on stubbing axios interceptors walks through the per-instance pattern in full.
// axios.test.ts
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, expect, it } from 'vitest';
let mock: MockAdapter;
beforeEach(() => { mock = new MockAdapter(axios); });
afterEach(() => { mock.reset(); mock.restore(); });
it('returns mocked data through the real axios pipeline', async () => {
mock.onGet('/api/status').reply(200, { healthy: true });
const { data } = await axios.get('/api/status');
expect(data.healthy).toBe(true);
});
Configuration Reference Table
| Option | Layer | Type | Default | Effect |
|---|---|---|---|---|
onUnhandledRequest |
MSW server.listen |
'warn' | 'error' | 'bypass' |
'warn' |
'error' fails any un-stubbed request; use it in CI. |
server.resetHandlers() |
MSW afterEach |
hook call | — | Drops per-test overrides; prevents handler leakage. |
server.use(...) |
MSW per test | runtime call | — | Adds a temporary handler that wins over base handlers. |
vi.stubGlobal('fetch', fn) |
fetch override | call | — | Replaces global fetch; cleaned by vi.unstubAllGlobals(). |
vi.unstubAllGlobals() |
fetch override | hook call | — | Restores all globals stubbed via vi.stubGlobal. |
new MockAdapter(axios) |
axios adapter | constructor | — | Intercepts at the adapter; real interceptors still run. |
mock.restore() |
axios adapter | call | — | Removes the adapter entirely; reset() only clears routes. |
test.isolate |
Vitest config | boolean |
true |
Isolates module state per file; required for clean stubs. |
test.pool |
Vitest config | 'forks' | 'threads' |
'forks' |
'forks' contains leaked interceptors in separate processes. |
Verification & Assertions
A stub is only useful if you can prove it fired. MSW exposes a request lifecycle event you can subscribe to in order to assert that a specific endpoint was actually hit the expected number of times.
import { expect, it, vi } from 'vitest';
import { server } from './setup';
it('calls the audit endpoint exactly once', async () => {
const seen = vi.fn();
server.events.on('request:match', ({ request }) => seen(new URL(request.url).pathname));
// ...trigger the code under test...
expect(seen).toHaveBeenCalledWith('/api/audit');
expect(seen).toHaveBeenCalledTimes(1);
});
For the fetch-override layer, assert directly on the spy: expect(fetch).toHaveBeenCalledWith('/api/data', expect.objectContaining({ method: 'GET' })). For axios, axios-mock-adapter records mock.history.get so you can assert request count and payload shape. Whichever layer you choose, the verification rule is the same — assert both that the right endpoint was called and that nothing unexpected was. Pairing onUnhandledRequest: 'error' with an explicit call-count assertion catches both missing and surplus requests.
The reason the call-count assertion matters as much as the payload assertion is that a stub that never fires can still let a test pass. If the code under test silently swallows an error and returns a cached default, the assertion on the returned value may be satisfied even though no request was ever made — a false green that the count check turns into a real failure. The lifecycle below shows how a matched request surfaces through MSW’s request:match event, which is the hook that makes the count observable, and how an unmatched request is turned into an immediate failure rather than a slow escape to the real network.
A second, often-skipped assertion is on the shape of what left the client, not just that a call happened. Serialisation bugs — a body sent as [object Object] because it was never JSON.stringify-ed, a missing Content-Type, a query parameter dropped by a faulty URL builder — are invisible if you only check the response you handed back. Reading await request.json() inside the handler and asserting on it, or inspecting mock.history.post[0].data, closes that gap and catches the class of regressions where the response is correct but the request that produced it was malformed.
Edge Cases & Failure Modes
Handler bleed across files. Symptom: a test passes in isolation but fails in the full suite, returning data from a previous test’s override. Diagnosis: a server.use() override was never cleared. Fix: ensure afterEach(() => server.resetHandlers()) runs in shared setup, and never register one-off handlers in beforeAll.
Silent passthrough to the real network. Symptom: tests are slow and occasionally flaky; CI fails only when the upstream API is down. Diagnosis: onUnhandledRequest is left at the 'warn' default, so an unmatched URL hits the live network. Fix: set it to 'error' and add the missing handler.
Unconsumed response streams. Symptom: heap grows file-over-file under pool: 'forks'. Diagnosis: a mocked Response body was created but never read, so its ReadableStream retains an event-loop reference. Fix: always consume the body (.json(), .text()) or return HttpResponse.json(...), which manages the stream for you. The full leak taxonomy is covered in the memory-leak guide.
Timing desynchronisation. Symptom: a polling component never advances because its setTimeout never fires under fake clocks. Diagnosis: stubbed latency and faked timers are not coordinated. Fix: align stub delays with time and date control strategies so await delay(...) and vi.advanceTimersByTime(...) resolve in the intended order.
Relative-URL mismatches under jsdom. Symptom: a handler for /api/users never matches even though the path looks identical, and the request escapes as unhandled. Diagnosis: the request resolved against jsdom’s default origin (http://localhost/) and MSW is matching an absolute URL you did not anticipate, or a base URL prepended by an axios instance produced http://api.internal/api/users while the handler is registered for the bare path. Fix: register handlers with the same absolute origin the client actually emits, or set a stable baseURL in the client and mirror it in the handler path so the two strings agree exactly.
Over-specific matchers that silently stop matching. Symptom: a test that pinned a handler to an exact query string (/api/search?q=ada&page=1) starts hitting the unhandled path after an unrelated refactor reorders parameters. Diagnosis: MSW matches the path and lets you inspect the query in the resolver, so encoding the query into the path string makes the match brittle. Fix: match on the path only and assert on new URL(request.url).searchParams inside the resolver, which keeps the match stable while still verifying the parameters.
First-party passthrough leaking into coverage. Symptom: static assets or telemetry beacons issued by a component trip onUnhandledRequest: 'error' and fail otherwise-correct tests. Diagnosis: the error policy is global and does not distinguish the endpoints you care about from incidental traffic. Fix: pass a function to onUnhandledRequest that calls print.error() only for your API origin and returns silently for known-benign hosts, so the guard stays strict where it matters without turning noise into failures.
Performance & CI Impact
MSW interception adds negligible per-request overhead compared with a real round trip, so the dominant performance lever is parallelism. Run suites under pool: 'forks' so each file gets a clean process and any leaked interceptor cannot poison sibling files. Shard across CI runners with --shard=1/3 style flags, and keep isolate: true so module-level stubs do not bleed between files in the same worker.
The biggest CI risk is not speed but flakiness from silent passthroughs. Make onUnhandledRequest: 'error' the global default in CI so any new code path that issues an un-stubbed request fails the build immediately rather than intermittently. Cap the heap with NODE_OPTIONS="--max-old-space-size=2048" and log per-file heap deltas to catch retention regressions early. These guards keep a stubbed suite both fast and trustworthy as it scales toward thousands of tests.
There is a subtler cost that only appears at scale: handler registry size. A single shared handlers.ts that grows to hundreds of entries is matched linearly on every request, and while the per-lookup cost is tiny, it is paid on every call in every test, so a large suite can spend a measurable slice of wall-clock time walking the registry. The remedy is not premature optimisation but organisation — keep the base handler set small and representative of the default happy path, and push scenario-specific responses into per-test server.use(...) overrides that exist only for the duration of the test that needs them. This keeps the common path cheap and the intent of each test local to that test.
Finally, weigh interception against the alternative it displaces. The reason a stubbed suite is worth the setup cost is that the counterfactual — real network calls in CI — is not merely slower but non-deterministic: it couples your build’s green status to the uptime, rate limits, and data state of a service you do not control. Measured against that, the millisecond of interception overhead per request is the cheapest insurance in the suite, and the discipline that makes it pay off is the same across every layer: one interception strategy per file, a hard failure on anything unhandled, and a teardown hook that leaves no state behind.
In-Depth Guides
- Mocking fetch and axios in Vitest without memory leaks — diagnose and eliminate heap retention from stream, spy, and interceptor accumulation across long-running suites.
- Stubbing axios interceptors in Vitest — stub request and response interceptors with
vi.mockandvi.spyOn, mock the adapter, and restore cleanly between tests.
Related
- Advanced Mocking & Service Isolation Patterns — the parent discipline this technique sits within.
- External Service Simulation — schema-validated stand-ins for full REST and GraphQL surfaces.
- Using MSW to mock GraphQL endpoints locally — applying the same interceptor model to GraphQL operations.
- Time & Date Control Strategies — coordinate stubbed latency with fake timers.
- Back to Advanced Mocking & Service Isolation Patterns.
Mocking fetch and axios in Vitest without memory leaks
Diagnose and fix Vitest memory leaks when mocking fetch and axios. Stream retention, global pollution, and interceptor accumulation solved with clean teardown.
Stubbing axios interceptors in Vitest
Stub axios request and response interceptors in Vitest using vi.mock, vi.spyOn, and adapter mocking. Restore interceptors cleanly between tests to avoid bleed.