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.
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.
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.
Prerequisites
fetchandResponseavailable in the runtime)vitest.config.tsnpm install --save-dev msw)axiosand, for adapter-level stubbing,axios-mock-adapterif your code uses axiostest/setup.tsregistered viasetupFilesso lifecycle hooks run once per worker"types": ["vitest/globals"]if you rely on globalexpect
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.
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.
// 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.
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.
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.
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.
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.
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.