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.

HTTP request stubbing interception layers A horizontal flow showing a test calling application code, which issues a request that is intercepted at one of three layers — fetch override, axios adapter, or MSW network interceptor — before a stubbed response returns. Test + App code fetch override axios adapter MSW interceptor Stubbed response assert

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.
  • fetch override — replaces globalThis.fetch with 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 reads response.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

  • fetch and Response available in the runtime)
  • vitest.config.ts
  • npm install --save-dev msw)
  • axios and, for adapter-level stubbing, axios-mock-adapter if your code uses axios
  • test/setup.ts registered via setupFiles so lifecycle hooks run once per worker
  • "types": ["vitest/globals"] if you rely on global expect

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