ESC

Type to search the knowledge base.

Mocking fetch in Tests

Stub fetch without lying — MSW vs vi.fn, Response shapes, error paths, and cleanup so network tests stay deterministic.

intermediate3 min read
  • testing
  • mocking-fetch

Frontend features live on HTTP. Tests must control responses without hitting real servers. You can mock global.fetch directly or intercept at the network layer with MSW. Prefer MSW for integration; direct mocks for tiny unit tests of a single client function.

Docs: MSW, Vitest mock functions, Fetch Response.

Direct fetch mock (unit)

import { vi, expect, test, afterEach } from 'vitest';
import { loadUser } from './loadUser';

afterEach(() => {
  vi.unstubAllGlobals();
  vi.restoreAllMocks();
});

test('loadUser parses JSON', async () => {
  vi.stubGlobal(
    'fetch',
    vi.fn().mockResolvedValue(
      new Response(JSON.stringify({ id: '1', name: 'Ada' }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      }),
    ),
  );

  await expect(loadUser('1')).resolves.toEqual({ id: '1', name: 'Ada' });
  expect(fetch).toHaveBeenCalledWith('/api/users/1', expect.anything());
});

Use real Response objects — half-mocked { json: async () => … } objects miss ok, status, headers.

Error paths

vi.stubGlobal(
  'fetch',
  vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
);
await expect(loadUser('missing')).rejects.toThrow(/404/);
vi.stubGlobal(
  'fetch',
  vi.fn().mockRejectedValue(new TypeError('Failed to fetch')),
);

Why MSW scales better

  • Works with any client (fetch, axios, GraphQL).
  • Handlers readable as HTTP contracts.
  • Same handlers in browser (Storybook/dev) and Node tests.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, name: 'Ada' }),
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

See MSW mock service worker.

Asserting request bodies

http.post('/api/login', async ({ request }) => {
  const body = await request.json();
  expect(body).toEqual({ email: 'a@b.com', password: 'secret' });
  return HttpResponse.json({ token: 't' });
});

Or spy on fetch mock calls — MSW keeps tests closer to wire format.

Cleanup rules

  1. Restore fetch after each test if stubbing globals.
  2. resetHandlers so one test’s override doesn’t leak.
  3. Don’t leave mockImplementation from a prior case.

Footguns

  1. Forgetting await on res.json().
  2. Mocking only happy status 200.
  3. Absolute vs relative URL mismatches in handlers.
  4. Parallel tests sharing one mutable mock implementation.

Interview out-loud answer

“For a single client function I stub fetch with real Response objects. For feature tests I use MSW handlers so any HTTP client works. I always cover non-OK and network failure, and I reset mocks/handlers between tests.”

Absolute URLs

Apps that call https://api.example.com/v1/... need handlers for that host or a relative base URL in tests. MSW can match full URLs; fetch mocks must compare the same string the client uses. Centralize API_BASE so tests and app agree.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Notes from real codebases

Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.

Further reading

Related guides