Why I Stopped Mocking `fetch` and Reached for MSW
The two-handler MSW setup I drop into every Vitest project. Pattern-matched URL routing, typed JSON responses, and per-test overrides without re-importing the server.
By @ezb1981
May 8, 2026
·
Updated August 10, 2026
1,030 views
26
4.4 (9)
// Stage 1: the pattern most engineers reach for first.
// Mock fetch directly in the test file, then live with the consequences.
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
const realFetch = globalThis.fetch;
let mockResponses: Array<{ urlPattern: RegExp; status: number; body: JsonValue }> = [];
function mockFetch() {
globalThis.fetch = (async (url: string | URL, _init?: RequestInit) => {
const target = url.toString();
const hit = mockResponses.find((m) => m.urlPattern.test(target));
if (!hit) throw new Error(`unmocked URL: ${target}`);
return new Response(JSON.stringify(hit.body), {
status: hit.status,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch;
}
function restoreFetch() { globalThis.fetch = realFetch; }
async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`http_${res.status}`);
return res.json();
}
async function run() {
mockFetch();
// 'Test 1' wires its mocks.
mockResponses = [{ urlPattern: /\/api\/users\/7$/, status: 200, body: { id: 7, name: 'Ada' } }];
console.log('test 1 got:', await getUser('7'));
// 'Test 2' forgets to reset; tries an unmocked URL and the test crashes.
try {
await getUser('99');
} catch (e) {
console.log('test 2 surfaced the leak:', (e as Error).message);
}
restoreFetch();
}
run();This is the pattern I see in most codebases: globalThis.fetch = jest.fn(...) and a manual response table. It works for one test, then someone forgets to reset between tests and you get bleed-through that only shows up in CI when the test order shuffles. The deeper issue is that you are mocking the wrong layer: your code uses fetch, axios, ky, or a generated SDK, and each one has slightly different semantics, so the mock has to be re-implemented per library. Stage two shows the pivot.
// Stage 2: the MSW shape rewritten without `import` so the playground runs it.
// In a real project: `import { http, HttpResponse } from 'msw'`.
// `import { setupServer } from 'msw/node'`.
type Handler = {
method: string;
pattern: RegExp;
resolve: (params: { url: string }) => Response | Promise<Response>;
};
function http_get(pattern: RegExp, resolve: Handler['resolve']): Handler {
return { method: 'GET', pattern, resolve };
}
function http_post(pattern: RegExp, resolve: Handler['resolve']): Handler {
return { method: 'POST', pattern, resolve };
}
function setupServer(...handlers: Handler[]) {
const realFetch = globalThis.fetch;
let active = handlers.slice();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : (input as Request).url ?? input.toString();
const method = (init && init.method) || 'GET';
const hit = active.find((h) => h.method === method && h.pattern.test(url));
if (!hit) throw new Error(`MSW: unmatched ${method} ${url}`);
return hit.resolve({ url });
}) as typeof fetch;
return {
listen() { /* called once per test file */ },
use(...extra: Handler[]) { active = [...extra, ...active]; },
resetHandlers() { active = handlers.slice(); },
close() { globalThis.fetch = realFetch; },
};
}
// Define handlers ONCE for the whole test suite.
const server = setupServer(
http_get(/\/api\/users\/(\d+)$/, ({ url }) => {
const id = Number(url.match(/users\/(\d+)/)![1]);
return new Response(JSON.stringify({ id, name: `User ${id}` }), {
status: 200, headers: { 'content-type': 'application/json' },
});
}),
http_post(/\/api\/orders$/, async () =>
new Response(JSON.stringify({ id: 'ord_1', status: 'paid' }), { status: 201 }),
),
);
server.listen();
async function run() {
const u = await fetch('/api/users/7').then((r) => r.json());
const o = await fetch('/api/orders', { method: 'POST', body: '{}' }).then((r) => r.json());
console.log('user:', u);
console.log('order:', o);
}
run().then(() => server.close());MSW's handler-as-data shape is the part that makes it scale. You declare http.get(url, resolver) once per default-happy-path response and let every test inherit them, which removes most of the per-test boilerplate. The setupServer returned object exposes listen, resetHandlers, and use, which slot into Vitest or Jest's beforeAll / afterEach hooks. The fake we built here mirrors that contract closely; in real MSW, the difference is that handlers can also intercept Service Worker traffic in the browser, but the Node API surface is identical. Once you have this you stop writing jest.spyOn(globalThis, 'fetch') and your tests stop interfering with each other.
// Stage 3: override one endpoint for one test only.
// Inlines the mini-MSW from stage 2 so this runs standalone.
type Handler = { method: string; pattern: RegExp; resolve: (p: { url: string }) => Response | Promise<Response> };
function setupServer(...handlers: Handler[]) {
const realFetch = globalThis.fetch;
const defaults = handlers.slice();
let active = handlers.slice();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = (init && init.method) || 'GET';
const hit = active.find((h) => h.method === method && h.pattern.test(url));
if (!hit) throw new Error(`MSW: unmatched ${method} ${url}`);
return hit.resolve({ url });
}) as typeof fetch;
return {
use(...extra: Handler[]) { active = [...extra, ...active]; },
resetHandlers() { active = defaults.slice(); },
close() { globalThis.fetch = realFetch; },
};
}
function http_get(pattern: RegExp, resolve: Handler['resolve']): Handler { return { method: 'GET', pattern, resolve }; }
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
// Default: a happy /api/users response shared by every test.
const server = setupServer(
http_get(/\/api\/users\/\d+$/, () => json({ id: 7, name: 'Ada' })),
);
async function happyPath() {
return fetch('/api/users/7').then((r) => r.json());
}
async function errorPath() {
// Override JUST for this test: simulate a 503.
server.use(http_get(/\/api\/users\/\d+$/, () => json({ code: 'upstream' }, 503)));
try {
const r = await fetch('/api/users/7');
if (!r.ok) throw new Error(`http_${r.status}`);
} catch (e) {
return (e as Error).message;
} finally {
server.resetHandlers();
}
}
async function run() {
console.log('happy 1:', await happyPath());
console.log('error path:', await errorPath());
console.log('happy 2 (defaults restored):', await happyPath());
}
run().then(() => server.close());The ergonomic move is server.use(...) to push an extra handler in front of the defaults, run the test, then server.resetHandlers() in afterEach to restore. This is the pattern that lets you keep your default handler list as the documented happy path while one specific test exercises the 503 branch without polluting the next test. In practice I keep the try/finally shape inside the test so a thrown assertion still triggers the reset; relying on afterEach alone fails if beforeEach did not run because of an earlier error. The result is a test suite where you can grep server.use( and see every override at a glance.
