To mock fetch in the Node test runner, call t.mock.method(globalThis, 'fetch', impl) and return a new Response from impl. It needs no dependencies and restores itself when the test ends. Use undici’s MockAgent or MSW when the real fetch code should run, but check your undici version first.
Every sample below was run on Node 22.23.2, 24.22.0 and 26.13.0 in November 2026, with undici 8.12.3 and MSW 3.0.3.
How do you mock fetch in the Node test runner?
Here is the function under test, followed by a stub.
// src/rates.js
export async function getRate(base, quote) {
const res = await fetch(`https://api.example.com/rates/${base}/${quote}`, {
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`rates API returned ${res.status}`);
const { rate } = await res.json();
return rate;
}
// test/method.test.js
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getRate } from '../src/rates.js';
test('returns the rate from the API', async (t) => {
const fetchMock = t.mock.method(globalThis, 'fetch', async () =>
Response.json({ rate: 1.27 }),
);
assert.equal(await getRate('GBP', 'USD'), 1.27);
assert.equal(fetchMock.mock.callCount(), 1);
const [url, init] = fetchMock.mock.calls[0].arguments;
assert.equal(url, 'https://api.example.com/rates/GBP/USD');
assert.equal(init.headers.accept, 'application/json');
});
Run it with node --test. Passing the folder (node --test test/) worked on Node 26, but on 22 and 24 it died with Cannot find module.
The mock.method docs offer a times option. Skip it here, because once the count is used up the original method comes back and your next call goes to the real network. For a sequence, keep a default implementation and queue the odd one out:
fetchMock.mock.mockImplementationOnce(async () =>
new Response(null, { status: 503 }),
);
Two more traps. Build the Response inside the implementation, because a shared instance fails on its second read with Body is unusable: Body has already been read. And stubbing only catches code that looks up fetch when it is called, so a reference captured before the stub went in (const f = globalThis.fetch) carries on to the network. The basics of t.mock and why it beats the file-wide mock are in our guide to the built-in test runner.
What does a fetch stub hide?
The real fetch never runs, so nothing validates the request. Under the stub, fetch('not a url') and a GET with a body both returned 200. The real thing rejects them with Failed to parse URL from not a url and Request with GET/HEAD method cannot have body.
Your stub also ignores AbortSignal unless you write that yourself, which leaves the deadline logic from our post on fetch timeouts untested.
Then there is coupling. The assertions above read init.headers.accept, so moving to new Headers() or fetch(new Request(url)) breaks the test while the behaviour stays identical. A hand-built Response differs from a fetched one too: url is an empty string and redirected is false, so code that reads either after a redirect sees the wrong answer.
When is MockAgent the better choice?
Use it when the failure paths matter. MockAgent replaces the dispatcher underneath fetch, so request validation, abort handling and body parsing are all real code.
// test/agent.test.js
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici';
import { getRate } from '../src/rates.js';
const realDispatcher = getGlobalDispatcher();
let agent;
beforeEach(() => {
agent = new MockAgent();
agent.disableNetConnect();
setGlobalDispatcher(agent);
});
afterEach(async () => {
agent.assertNoPendingInterceptors();
await agent.close();
setGlobalDispatcher(realDispatcher);
});
test('returns the rate from the API', async () => {
agent
.get('https://api.example.com')
.intercept({
path: '/rates/GBP/USD',
method: 'GET',
headers: { accept: 'application/json' },
})
.reply(200, { rate: 1.27 }, {
headers: { 'content-type': 'application/json' },
});
assert.equal(await getRate('GBP', 'USD'), 1.27);
});
test('surfaces a dropped connection', async () => {
agent
.get('https://api.example.com')
.intercept({ path: '/rates/GBP/USD' })
.replyWithError(new Error('socket hang up'));
await assert.rejects(getRate('GBP', 'USD'), (err) => {
assert.equal(err.message, 'fetch failed');
assert.equal(err.cause.message, 'socket hang up');
return true;
});
});
An interceptor matches once. A second identical call fails with TypeError: fetch failed, and the cause reads Mock dispatch not matched for path '/rates/GBP/USD'. Chain .times(n) or .persist() if you meant to repeat it. The query string counts as part of the path, so /q?a=1 will not match an interceptor on /q.
Timeouts become testable. .reply(200, 'late').delay(500) against AbortSignal.timeout(50) rejected with a real TimeoutError.
Passing an object to .reply() serialises it but sets no content-type, hence the explicit header above.
Why does setGlobalDispatcher not intercept fetch?
Because the global fetch runs on the copy of undici bundled inside Node, and your npm copy has to hand it the dispatcher through a shared global symbol. That hand-off depends on versions. The undici README says the current line stores it under Symbol.for('undici.globalDispatcher.2') and mirrors it to .1 for older fetch code.
This is what global fetch did with setGlobalDispatcher(new MockAgent()):
| npm undici | Node 22.23.2 (bundles 6.28.0) | Node 24.22.0 (bundles 7.29.1) | Node 26.13.0 (bundles 8.12.2) |
|---|---|---|---|
| 6.29.1 | mocked | mocked | real network |
| 7.30.1 | mocked | mocked | mocked |
| 8.0.2 | mocked | mocked | mocked |
| 8.0.3, 8.5.0, 8.10.0 | real network | real network | mocked |
| 8.10.1, 8.12.3 | mocked | mocked | mocked |
The 8.x misses are a known regression, undici issue 5036, fixed by PR 5648 and released in 8.10.1. The undici 6 miss on Node 26 fits the same picture, since 6.29.1 only writes the .1 symbol.
disableNetConnect() does not protect you in a “real network” cell, because the request never reaches the agent. A fake hostname fails with getaddrinfo ENOTFOUND api.example.com. A real hostname gets a real request. So if a supposedly mocked test reports DNS errors, run npm ls undici and node -p process.versions.undici before you debug anything else.
The common workaround, fetch(url, { dispatcher: agent }), is worse on a mismatch. With undici 8.12.3 on Node 22 or 24, and undici 6.29.1 on Node 26, the promise never settled at all. Of the three releases tried this way (6.29.1, 7.30.1, 8.12.3), only 7.30.1 worked both ways on all three Node versions.
When should you use MSW instead?
When the code under test does not only use global fetch. MSW’s Node integration intercepted global fetch, node:https and undici’s request() in the same run, whereas MockAgent let node:https through to DNS.
// test/msw.test.js
import assert from 'node:assert/strict';
import { after, afterEach, before, test } from 'node:test';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { getRate } from '../src/rates.js';
const server = setupServer(
http.get('https://api.example.com/rates/:base/:quote', ({ params }) =>
HttpResponse.json({ rate: params.base === 'GBP' ? 1.27 : 1 }),
),
);
before(() => server.listen({ onUnhandledFrame: 'error' }));
afterEach(() => server.resetHandlers());
after(() => server.close());
test('throws on a 503', async () => {
server.use(
http.get('https://api.example.com/rates/:base/:quote', () =>
new HttpResponse(null, { status: 503 }),
),
);
await assert.rejects(getRate('GBP', 'USD'), /returned 503/);
});
Older tutorials use onUnhandledRequest, but MSW 3 renamed it to onUnhandledFrame. Set it. The default is warn, which prints a warning and performs the request anyway.
MSW has its own hole: the fetch exported by the npm undici package skipped it and hit DNS, even with 'error' set. MSW 3 also needs Node 22.12 or later.
Keep these approaches in separate test files, and leave isolation on. Node’s default gives each file its own process, so one file’s dispatcher cannot reach another. Running these three test files together with --test-isolation=none on Node 24 and 26 failed the MSW test with TypeError: fetch failed.
Which one would I use?
For a thin wrapper where I only care how a response maps to a return value, the stub. It adds no dependency and the stub itself is three lines.
For an API client with retries, timeouts and error handling, MockAgent, pinned to undici@7 if CI covers more than one Node major. If everything runs on Node 26, the latest 8.x is fine.
For an application with several HTTP clients in its dependency tree, MSW, because I would rather not audit which library calls what.
I would not use any of them to test connection reuse or proxy behaviour, since all three replace the part you are trying to observe. Start a local node:http server for that.
Whoooop builds Node.js services and third-party API integrations, and the tests around them are part of that job. Our Node.js development page covers what we take on.