How to mock fetch in vitest
Replace the global with vi.stubGlobal('fetch', vi.fn()) and give it back a real Response, not a plain object. A stub shaped by hand passes the test and hides two production failures: a missing ok property, and a body that can be read only once. Undo it with vi.unstubAllGlobals().
Why check this
Run this when a unit test would otherwise make a network call, and again after changing how the code reads a response body. A mocked fetch is the cheapest way to cover error branches, so it carries the 404 and empty-payload paths of the whole client.
The failure it prevents is a green suite over code that throws on the first real response. The module below reads the body twice. Against a hand-written stub that is fine, against a real Response it throws TypeError: Body is unusable, and only one of those two happens in production.
Prerequisites
- Node 22.23.2 with global
fetchandResponse, Vitest 5.0.0, msw 2.15.0. One Windows 11 machine, 2026-09-12. - A base URL on a local port with no listener,
http://127.0.0.1:9681below. A request that reaches it is refused, which is the point: a refusal proves the stub was not used. Confirm the port is free withnetstat -ano | grep 9681. - A stubbed global is not a network test. Nothing here measures a timeout, a TLS handshake or a slow server. See the Vitest
vi.stubGlobalAPI. - The client under test.
cat > src/invoices.js <<'EOF'
const BASE = 'http://127.0.0.1:9681';
export async function loadInvoice(id, log) {
const res = await fetch(`${BASE}/invoices/${id}`, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (!body.total) log(`empty invoice: ${await res.text()}`);
return body;
}
EOF
Steps
- Step 1.
Stub the global with the object most examples start from, and run it.
cat > test/plain.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import { loadInvoice } from '../src/invoices.js'; afterEach(() => vi.unstubAllGlobals()); it('returns the invoice', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => ({ id: 7, total: 120 }) }))); await expect(loadInvoice(7, () => {})).resolves.toEqual({ id: 7, total: 120 }); }); EOFnpx vitest run test/plain.test.jsFAIL test/plain.test.js > returns the invoice AssertionError: promise rejected "Error: HTTP undefined" instead of resolving Caused by: Error: HTTP undefined ❯ loadInvoice src/invoices.js:5:22HTTP undefinedis the shape of the report. The stub has nook, so!res.okis true and the client throws withres.statusinterpolated asundefined. - Step 2.
Add the fields the client reads, and watch both branches pass.
cat > test/shaped.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import { loadInvoice } from '../src/invoices.js'; afterEach(() => vi.unstubAllGlobals()); function plainStub(data) { return vi.fn(async () => ({ ok: true, status: 200, json: async () => data, text: async () => JSON.stringify(data), })); } it('returns a paid invoice', async () => { vi.stubGlobal('fetch', plainStub({ id: 7, total: 120 })); await expect(loadInvoice(7, () => {})).resolves.toEqual({ id: 7, total: 120 }); }); it('logs an empty invoice', async () => { vi.stubGlobal('fetch', plainStub({ id: 8, total: 0 })); const log = vi.fn(); await loadInvoice(8, log); expect(log).toHaveBeenCalledWith('empty invoice: {"id":8,"total":0}'); }); EOFnpx vitest run test/shaped.test.jsTest Files 1 passed (1) Tests 2 passed (2) Start at 11:13:13 Duration 259ms (transform 41%, import 40%, worker 11%, tests 8%, environment 1%)Two green tests over code that cannot work. The stub answers
json()andtext()as often as asked. - Step 3.
Build the same two cases on the real
Responseconstructor.cat > test/response.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import { loadInvoice } from '../src/invoices.js'; afterEach(() => vi.unstubAllGlobals()); function responseStub(data, init = {}) { return vi.fn(async () => new Response(JSON.stringify(data), { status: init.status ?? 200, headers: { 'content-type': 'application/json' }, })); } it('returns a paid invoice', async () => { vi.stubGlobal('fetch', responseStub({ id: 7, total: 120 })); await expect(loadInvoice(7, () => {})).resolves.toEqual({ id: 7, total: 120 }); }); it('logs an empty invoice', async () => { vi.stubGlobal('fetch', responseStub({ id: 8, total: 0 })); const log = vi.fn(); await loadInvoice(8, log); expect(log).toHaveBeenCalledWith('empty invoice: {"id":8,"total":0}'); }); it('throws on a 404', async () => { vi.stubGlobal('fetch', responseStub({ message: 'no such invoice' }, { status: 404 })); await expect(loadInvoice(9, () => {})).rejects.toThrow('HTTP 404'); }); EOFnpx vitest run test/response.test.js❯ test/response.test.js (3 tests | 1 failed) 28ms × logs an empty invoice 4ms FAIL test/response.test.js > logs an empty invoice TypeError: Body is unusable: Body has already been read ❯ loadInvoice src/invoices.js:7:52 … Test Files 1 failed (1) Tests 1 failed | 2 passed (3)Same code, same assertion, different stub, and the bug is visible. A response body is a stream and is consumed once.
Responsealso suppliesok,status,headersandclone(), so there is no shape to keep in step with the client. - Step 4.
Assert the request, not only the answer.
cat > test/request.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import { loadInvoice } from '../src/invoices.js'; afterEach(() => vi.unstubAllGlobals()); function responseStub(data) { return vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })); } it('asks for the invoice by id', async () => { vi.stubGlobal('fetch', responseStub({ id: 7, total: 120 })); await loadInvoice(7, () => {}); expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:9681/invoices/7'); }); it('asks with the accept header', async () => { vi.stubGlobal('fetch', responseStub({ id: 7, total: 120 })); await loadInvoice(7, () => {}); expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:9681/invoices/7', { headers: { accept: 'application/json' }, }); }); EOFnpx vitest run test/request.test.jsFAIL test/request.test.js > asks for the invoice by id AssertionError: expected "vi.fn()" to be called with arguments: [ 'http://127.0.0.1:9681/invoices/7' ] Received: 1st vi.fn() call: [ "http://127.0.0.1:9681/invoices/7", + { + "headers": { + "accept": "application/json", + }, + }, ] Number of calls: 1 Test Files 1 failed (1) Tests 1 failed | 1 passed (2)Two tests, one URL. The first passes the URL alone and fails, because
toHaveBeenCalledWithcompares the whole argument list and the client sends an init object as well. The second lists both arguments and passes. Useexpect.anything()for the second slot when the headers are not what you are testing. - Step 5.
Prove the stub is the thing the code calls.
cat > src/client.js <<'EOF' const send = globalThis.fetch; export async function ping() { const res = await send('http://127.0.0.1:9681/ping'); return res.status; } EOFcat > test/inert.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import { ping } from '../src/client.js'; afterEach(() => vi.unstubAllGlobals()); it('never touches the network', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 }))); await expect(ping()).resolves.toBe(200); expect(fetch).toHaveBeenCalledTimes(1); }); EOFnpx vitest run test/inert.test.jsFAIL test/inert.test.js > never touches the network AssertionError: promise rejected "TypeError: fetch failed" instead of resolving Caused by: TypeError: fetch failed ❯ ping src/client.js:4:15 Caused by: Error: connect ECONNREFUSED 127.0.0.1:9681 Serialized Error: { errno: -4078, code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 9681 }The test stubs the global and the module never reads it.
const send = globalThis.fetchcaptured the original at import time, so the stub sits unused while a real connection is attempted.ECONNREFUSEDis the evidence, and on a machine where something answers that port the test would pass against live data instead. Move the lookup into the function and the same test goes green.cat > src/client-fixed.js <<'EOF' export async function ping() { const res = await globalThis.fetch('http://127.0.0.1:9681/ping'); return res.status; } EOFnpx vitest run test/fixed.test.js --reporter=verbose✓ test/fixed.test.js > uses the stub and asks for the right URL 20ms Test Files 1 passed (1) Tests 1 passed (1)That is the step 5 test with
toHaveBeenCalledWith(url)added, run againstsrc/client-fixed.js. - Step 6.
Try msw on the same pair of modules.
cat > test/msw2.test.js <<'EOF' import { it, expect, beforeAll, afterAll } from 'vitest'; import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; import { loadInvoice } from '../src/invoices.js'; import { ping } from '../src/client.js'; const server = setupServer( http.get('http://127.0.0.1:9681/invoices/7', () => HttpResponse.json({ id: 7, total: 120 })), http.get('http://127.0.0.1:9681/ping', () => HttpResponse.json({ ok: true })) ); beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterAll(() => server.close()); it('intercepts a call made through the global', async () => { await expect(loadInvoice(7, () => {})).resolves.toEqual({ id: 7, total: 120 }); }); it('does not intercept a reference captured at import time', async () => { await expect(ping()).resolves.toBe(200); }); EOFnpx vitest run test/msw2.test.js --reporter=verbose✓ test/msw2.test.js > intercepts a call made through the global 16ms × test/msw2.test.js > does not intercept a reference captured at import time 10ms → promise rejected "TypeError: fetch failed" instead of resolving Caused by: Error: connect ECONNREFUSED 127.0.0.1:9681setupServerwithonUnhandledRequest: 'error'answered the callloadInvoicemakes through the global. That is what msw buys: one handler per URL covers every client that goes throughfetch. It did not reach the captured reference.server.listen()runs inbeforeAll, after the module took its copy, so the blind spot from step 5 remains.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| HTTP undefined | The stub has no ok or status | Return a real Response |
| Body is unusable: Body has already been read | The client reads the body twice | Read once into a variable, or res.clone() |
| Green tests over a hand-shaped object | The stub is more permissive than the contract | Rebuild it on Response and rerun |
| ECONNREFUSED on your base URL | A real request left the process | Find the code path that does not use the global |
| An argument diff with an extra init object | toHaveBeenCalledWith compares every argument | List both, or use expect.anything() |
| The stub still in place in the next file | unstubAllGlobals never ran | Add afterEach, or unstubGlobals: true in the config |
| msw reports an unhandled request | The code called a URL with no handler | Add the handler, or keep the error and fix the URL |
Common mistakes
What to check next
- How to verify a mock was called: the matchers used on the stub here, and what they miss.
- How to reset mocks between tests: a stubbed global outlives the test that set it.
- How to spy on a function in jest: the import-time capture in step 5, in its general form.
- How to test API with curl: the request your stub claims the code sends, sent for real.
FAQ
How do I mock fetch in Jest?
The same shape with different names: jest.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(...)), or an assignment to globalThis.fetch undone in afterEach. Node 22 gives both runners Response, so step 3 carries over.
Should I use msw instead?
Use msw when several modules share a base URL, or when you want to assert on responses rather than on stub shapes. Step 6 shows it answering a call through the global. It intercepts what goes through the global, so the import-time capture is outside its reach too.
How do I assert no request was made?
Stub the global with vi.fn(), run the code, then expect(fetch).not.toHaveBeenCalled(). Keep the base URL pointed at a dead port so a call that escapes the stub fails with ECONNREFUSED rather than reaching a live service.
How do I undo the stub?
vi.unstubAllGlobals() in afterEach, or unstubGlobals: true in the Vitest config. Without it the replacement stays on globalThis for the rest of the file.
Can I return a 500 and a network error?
A status comes from new Response(body, { status: 500 }). A transport failure is vi.fn().mockRejectedValue(new TypeError('fetch failed')), which is what the global throws when a connection fails.
Verified
Verified by Maks Vernynode 22.23.2vitest 5.0.0msw 2.15.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny