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.

TypeScript
Frontend
3 snippets
testing
http
code-template
utility
ezb1981

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.