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

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

  1. 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 });
    });
    EOF
    
    npx vitest run test/plain.test.js
    
     FAIL  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:22

    HTTP undefined is the shape of the report. The stub has no ok, so !res.ok is true and the client throws with res.status interpolated as undefined.

  2. 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}');
    });
    EOF
    
    npx vitest run test/shaped.test.js
    
     Test 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() and text() as often as asked.

  3. Step 3.

    Build the same two cases on the real Response constructor.

    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');
    });
    EOF
    
    npx 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. Response also supplies ok, status, headers and clone(), so there is no shape to keep in step with the client.

  4. 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' },
      });
    });
    EOF
    
    npx vitest run test/request.test.js
    
     FAIL  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 toHaveBeenCalledWith compares the whole argument list and the client sends an init object as well. The second lists both arguments and passes. Use expect.anything() for the second slot when the headers are not what you are testing.

  5. 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;
    }
    EOF
    
    cat > 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);
    });
    EOF
    
    npx vitest run test/inert.test.js
    
     FAIL  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.fetch captured the original at import time, so the stub sits unused while a real connection is attempted. ECONNREFUSED is 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;
    }
    EOF
    
    npx 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 against src/client-fixed.js.

  6. 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);
    });
    EOF
    
    npx 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:9681

    setupServer with onUnhandledRequest: 'error' answered the call loadInvoice makes through the global. That is what msw buys: one handler per URL covers every client that goes through fetch. It did not reach the captured reference. server.listen() runs in beforeAll, 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

Sign: A fetch test passes and the same code throws on the first real call.Cause: A plain object answers json() and text() any number of times. A real Response body is a stream and is consumed once. Step 2 and step 3 run identical assertions, and only the Response version reports the double read.
Sign: The stub is never called and the test still passes.Cause: The module took its own reference to fetch at import time, so the global swap happens too late. The request goes out for real. Pointing the base URL at a dead local port turns that into a visible ECONNREFUSED instead of a silent live call.
Sign: msw is added and one client is still not intercepted.Cause: setupServer replaces the global when listen() runs in beforeAll, which is after module import. A module that captured fetch at import time keeps the original, and msw reports nothing because it never sees the request.
Sign: An argument assertion on the URL fails although the URL is right.Cause: fetch is called with two arguments and toHaveBeenCalledWith compares the whole list. The diff in step 4 marks the init object with a plus sign, which reads as a wrong URL until you notice the second entry.

What to check next

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.

intermediate12 minpublished updated Maks Verny