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.

Three HTTP interception layers between a test and its stubbed response Application code under test issues a request that can be intercepted at the fetch override, axios adapter, or MSW network layer before a stubbed response returns for assertion. Test + app code fetch override lowest fidelity axios adapter runs client pipeline MSW interceptor highest fidelity Stubbed response pick one layer per suite — do not mix in a file
The three interception layers, ordered by fidelity, between the code under test and its stubbed response.

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.
  • 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.

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.

Decision tree for selecting an interception layer Starting from what the test actually exercises, the diagram routes body-parsing checks to a fetch override, axios-specific behaviour to the adapter, and integration paths to MSW. What is under test? choose the lowest sufficient layer fetch override fast, no client logic axios adapter real interceptors run MSW interceptor full request lifecycle body parsing only axios config integration path
A quick decision path from what a test exercises to the interception layer that fits it.

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.

Verifying a stub fired through the MSW request lifecycle A matched request emits a request:match event a spy records so the test can assert an exact call count, while an unmatched request fails immediately under the error policy. Code under test MSW matches a handler request:match event fires assert called exactly once no match hard failure onUnhandledRequest: 'error'
The lifecycle that lets a test prove a stub fired — and fail loudly when a request is unmatched.

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