Featured image of post msw-fetch-mock: Undici-Style Fetch Mocking for MSW

msw-fetch-mock: Undici-Style Fetch Mocking for MSW

Compare msw-fetch-mock, MSW, nock, fetch-mock, and 3 other HTTP mocking libraries β€” with architecture analysis and concrete use-case recommendations for each.

When writing frontend or Node.js tests, mocking HTTP requests is practically mandatory. But choosing a mock solution is overwhelming: MSW, nock, fetch-mock, jest-fetch-mock… each has a different API style, interception level, and environment support. If you work on both Cloudflare Workers and Node.js projects, you’ll find the mock APIs are completely different and test code can’t be shared.

msw-fetch-mock solves exactly this: it provides the same API style as undici’s MockAgent and Cloudflare Workers’ fetchMock, with MSW handling network-level interception underneath. One API, every environment.

Why Existing Solutions Fall Short

Here are the 6 mainstream HTTP mock solutions today:

Solutionnpm Weekly DownloadsInterception LevelNode native fetchBrowserMaintenance
MSW~10.5MService Worker / Node internalsβœ…βœ…Active
nock~5.5MNode http module❌❌Active
fetch-mock~1.0MReplaces globalThis.fetchβœ…βœ…Active
jest-fetch-mock~1.3MReplaces global.fetchβœ…βŒAbandoned
vitest-fetch-mock~240KReplaces globalThis.fetchβœ…βŒActive
undici MockAgentBuilt-inundici Dispatcherβœ…βŒNode core

Each has clear limitations:

  • MSW is the most complete, but verbose. Every endpoint needs http.get(url, resolver), and it lacks times(), persist(), assertNoPendingInterceptors() β€” essentials for testing.
  • nock is the Node.js veteran with a clean API, but doesn’t support Node 18+ native fetch. Native fetch uses undici, which bypasses the http module entirely.
  • fetch-mock replaces globalThis.fetch directly β€” it works, but it’s not network-level interception, so behavior may differ from production.
  • jest-fetch-mock hasn’t been updated in 6 years. No URL matching β€” responses are returned by call order only.
  • vitest-fetch-mock is jest-fetch-mock ported to Vitest. Same limitation: no URL matching.
  • undici MockAgent is the native Node.js solution, but doesn’t work in browsers.

Where msw-fetch-mock Fits

msw-fetch-mock doesn’t build a mock engine from scratch. It stands on MSW’s shoulders β€” using MSW for network-level interception (Service Worker in browser, @mswjs/interceptors in Node) β€” and wraps it with an undici-style API.

The architecture has three layers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  FetchMock (User API)               β”‚
β”‚  .get(origin).intercept().reply()   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Adapter (Environment)              β”‚
β”‚  NodeMswAdapter / BrowserMswAdapter β”‚
β”‚  NativeFetchAdapter (no MSW)        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  HandlerFactory                     β”‚
β”‚  MSW v2 / MSW v1 Legacy / Native   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

msw-fetch-mock three-layer architecture

The key is the single catch-all handler. MSW’s standard approach registers one handler per endpoint, but in browser environments this causes Service Worker timing issues (each worker.use() requires SW communication). msw-fetch-mock registers just one http.all('*', ...) catch-all, running all matching logic in the main thread, avoiding Service Worker round-trip latency.

Quick Start API

1
npm install msw-fetch-mock --save-dev

Basic Setup

1
2
3
4
5
6
7
8
import { fetchMock } from 'msw-fetch-mock';

beforeAll(() => fetchMock.activate({ onUnhandledRequest: 'error' }));
afterAll(() => fetchMock.deactivate());
afterEach(() => {
  fetchMock.assertNoPendingInterceptors(); // Unused mocks = broken test
  fetchMock.reset();
});

Chain Builder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const base = 'https://api.example.com';

// GET request
fetchMock.get(base)
  .intercept({ path: '/users' })
  .reply(200, [{ id: 1, name: 'Alice' }]);

// POST + body matching
fetchMock.get(base)
  .intercept({
    path: '/users',
    method: 'POST',
    headers: { Authorization: /^Bearer / },
    body: (b) => JSON.parse(b).role === 'admin',
  })
  .reply(201, { id: 2 });

// Dynamic response
fetchMock.get(base)
  .intercept({ path: '/echo', method: 'POST' })
  .reply(200, ({ body }) => JSON.parse(body));

Behavior Control

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Respond only 3 times
fetchMock.get(base).intercept({ path: '/api' }).reply(200, data).times(3);

// Never consumed (e.g., health check)
fetchMock.get(base).intercept({ path: '/health' }).reply(200, 'ok').persist();

// Simulate latency
fetchMock.get(base).intercept({ path: '/slow' }).reply(200, data).delay(500);

// Simulate network error
fetchMock.get(base).intercept({ path: '/fail' }).replyWithError();

Call History

1
2
3
4
5
6
7
const last = fetchMock.calls.lastCall();
expect(last.method).toBe('POST');
expect(last.json()).toEqual({ name: 'Alice' });

// Filter specific calls
fetchMock.calls.filterCalls({ method: 'POST', path: '/users' });
fetchMock.calls.called({ path: '/users' }); // boolean

Net Connect Control

1
2
3
fetchMock.disableNetConnect();              // Block all unmatched requests
fetchMock.enableNetConnect('localhost');     // Allow localhost
fetchMock.enableNetConnect(/\.internal$/);  // Allow hosts matching regex

Full Comparison of All 6 Solutions

Feature Comparison

Featuremsw-fetch-mockMSWnockfetch-mockjest-fetch-mockundici MockAgent
URL pattern matchingβœ…βœ…βœ…βœ…βŒPartial
Method matchingβœ…βœ…βœ…βœ…βŒβœ…
Header matchingβœ…βœ…βœ…βœ…βŒβœ…
Body matchingβœ…βœ…βœ…βœ…βŒβœ…
times(n)βœ…βŒβœ…βœ…βŒβœ…
persist()βœ…βŒβœ…βŒβŒβœ…
delay(ms)βœ…βœ…βœ…βœ…βŒβœ…
assertNoPendingInterceptors()βœ…βŒβœ…βŒβŒβŒ
Call history + filteringβœ…βŒβŒβœ…Partial❌
Network error simulationβœ…βœ…βœ…βœ…βŒβœ…
GraphQL supportβŒβœ…βŒβŒβŒβŒ
Record & ReplayβŒβŒβœ…βŒβŒβŒ

Environment Support

Environmentmsw-fetch-mockMSWnockfetch-mockjest-fetch-mockundici MockAgent
Jestβœ…βœ…βœ…βœ…βœ…βœ…
Vitestβœ…βœ…βœ…βœ…βŒβœ…
Node native fetchβœ…βœ…βŒβœ…βœ…βœ…
Node http/axiosβœ…βœ…βœ…βŒβŒβŒ
Browserβœ…βœ…βŒβœ…βŒβŒ
Cloudflare Workers API compatβœ…βŒβŒβŒβŒβŒ

Interception Level

The interception level determines how closely mock behavior matches production:

SolutionInterception LevelDescription
MSW (browser)Service WorkerZero patching, closest to production
MSW (Node)Node internals + undiciExtends ClientRequest, not monkey-patching
msw-fetch-mockSame as MSWMSW under the hood
nockhttp.requestMonkey-patches Node http module
fetch-mockglobalThis.fetchReplaces the fetch function
jest-fetch-mockglobal.fetchReplaces fetch with jest.fn()
undici MockAgentundici DispatcherReplaces undici’s dispatcher

The Real Advantages of msw-fetch-mock

msw-fetch-mock has three concrete advantages over the alternatives:

1. One API, Three Environments

The APIs for undici MockAgent, Cloudflare Workers fetchMock, and msw-fetch-mock are nearly identical:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// undici MockAgent
const pool = mockAgent.get('https://api.example.com');
pool.intercept({ path: '/users', method: 'GET' }).reply(200, []);

// Cloudflare Workers fetchMock (cloudflare:test)
const pool = fetchMock.get('https://api.example.com');
pool.intercept({ path: '/users', method: 'GET' }).reply(200, []);

// msw-fetch-mock
const pool = fetchMock.get('https://api.example.com');
pool.intercept({ path: '/users', method: 'GET' }).reply(200, []);

If your code runs on both Node.js and Cloudflare Workers, your test mocks can share the same patterns β€” just change the import.

2. MSW’s Interception Quality + undici’s API Simplicity

MSW has the highest interception quality available (Service Worker in browser, @mswjs/interceptors in Node), but its API is verbose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Raw MSW: one handler per endpoint
server.use(
  http.get('https://api.example.com/users', () => {
    return HttpResponse.json([{ id: 1 }]);
  }),
  http.post('https://api.example.com/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(body, { status: 201 });
  })
);

// msw-fetch-mock: chain builder, much less code
fetchMock.get('https://api.example.com')
  .intercept({ path: '/users' })
  .reply(200, [{ id: 1 }]);

fetchMock.get('https://api.example.com')
  .intercept({ path: '/users', method: 'POST' })
  .reply(201, ({ body }) => JSON.parse(body));

Plus, the features MSW lacks β€” times(), persist(), assertNoPendingInterceptors(), call history filtering β€” msw-fetch-mock has them all.

3. Works Alongside Existing MSW Setups

If your project already has an MSW server (e.g., for Storybook or integration tests), msw-fetch-mock can plug right in:

1
2
3
4
5
import { setupServer } from 'msw/node';
import { createFetchMock } from 'msw-fetch-mock';

const server = setupServer(/* your existing handlers */);
const fetchMock = createFetchMock(server);

No need to tear down your existing MSW setup. No conflicts.

4. Works Without MSW Too

If you don’t want to install MSW, msw-fetch-mock has a native mode that patches globalThis.fetch directly:

1
import { fetchMock } from 'msw-fetch-mock/native';

Same API, just a different interception level. When you’re ready to migrate to MSW, change the import path.

Recommendations by Use Case

Your NeedRecommended Solution
Cross Node.js + Cloudflare Workers, unified mock APImsw-fetch-mock
Already using MSW, but find the API too verbosemsw-fetch-mock (plug into existing server)
Full-stack project, need browser + Node mockingMSW or msw-fetch-mock
Node.js only + axios/httpnock
Node.js only + native fetch, no MSWundici MockAgent
Vitest simple scenarios, no URL matching neededvitest-fetch-mock
Jest simple scenariosfetch-mock (avoid jest-fetch-mock β€” abandoned)

msw-fetch-mock’s sweet spot: you want MSW’s interception quality with undici/Cloudflare’s clean API, plus times(), persist(), and assertNoPendingInterceptors() for proper test lifecycle management.