Setting up mock APIs for frontend component tests

Component tests that reach a live API become non-deterministic: network latency, transient 5xx responses, and auth token expiry all introduce failures that have nothing to do with the component. This page shows how to intercept every outbound HTTP call at the transport layer so your tests are fully in control of the data they receive. It sits under the Mock Boundaries cluster, which covers the broader architecture for isolating components from external dependencies.


Problem statement

You have a component — say a <UserProfile> card — that calls /api/users/:id on mount. In local development the test passes because the dev server responds quickly. In CI the test times out, returns a 401, or receives a completely different data shape because the staging API was updated. The test failure gives you no useful signal about the component.


Root cause

The component’s fetch call is escaping the test sandbox. There is no interceptor at the transport layer, so every test run fires a real HTTP request into an environment the test runner has no control over. This violates the isolation principles that make unit and integration tests repeatable: each test must own every input the component receives, including network responses.

The secondary problem is schema drift. Even when a test does happen to reach a stable endpoint, the payload shape can change between deploys. If no code asserts that the mock matches the production contract, the test quietly validates against stale data.


Minimal reproduction

// UserProfile.tsx — fires an uncontrolled fetch on mount
import { useEffect, useState } from 'react';

export function UserProfile({ id }: { id: string }) {
  const [user, setUser] = useState<{ name: string; role: string } | null>(null);

  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then(setUser);
  }, [id]);

  if (!user) return <p>Loading…</p>;
  return <p>{user.name}{user.role}</p>;
}
// UserProfile.test.tsx — no interceptor; relies on a live /api/users/1
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user name', async () => {
  render(<UserProfile id="1" />);
  // Flaky: depends on whether /api/users/1 is reachable and returns the right shape
  await screen.findByText(/Alice/);
});

When /api/users/1 is slow, unreachable, or returns unexpected JSON this test fails—or worse, passes by accident.


Step-by-step fix

Step 1 — Define a Zod schema that mirrors the production contract

Keep the schema next to your test fixtures so both the mock and any future contract test import the same definition.

// src/test/fixtures/user-schema.ts
import { z } from 'zod';

// Single source of truth: update here when the API contract changes
export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  role: z.enum(['admin', 'editor', 'viewer']),
});

export type User = z.infer<typeof UserSchema>;

export const testUser: User = {
  id: '00000000-0000-0000-0000-000000000001',
  name: 'Alice',
  role: 'viewer',
};

Running UserSchema.parse(testUser) at the point of fixture creation means a schema mismatch throws immediately — not somewhere deep in a component assertion.

Step 2 — Write MSW handlers that validate and return the fixture

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';
import { UserSchema, testUser } from './fixtures/user-schema';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    // Parse validates at runtime; throws ZodError if testUser drifted from schema
    const payload = UserSchema.parse(testUser);
    return HttpResponse.json(payload);
  }),
];

The http.get path uses the same URL pattern as the component — any mismatch in the pattern means the request falls through to the unhandled-request handler, which you will configure to error in the next step.

Step 3 — Wire the server into the test lifecycle

// src/test/setup.ts  (referenced in jest.config.ts → setupFilesAfterEach)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// Runs once before the entire suite
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

// Wipes any per-test overrides so they cannot bleed into the next test
afterEach(() => server.resetHandlers());

// Tears down the interceptor after the suite finishes
afterAll(() => server.close());

onUnhandledRequest: 'error' is the critical line. Any fetch that does not match a handler causes the test to fail with a clear [MSW] Cannot bypass a request without a handler message. This turns invisible network leaks into loud, actionable failures.

Step 4 — Update the component test

// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// server is already configured in setup.ts via setupFilesAfterFramework

test('renders user name and role', async () => {
  render(<UserProfile id="00000000-0000-0000-0000-000000000001" />);

  // findBy* waits for the async fetch+render cycle to complete
  expect(await screen.findByText('Alice — viewer')).toBeInTheDocument();
});

test('can override the handler for a specific error scenario', async () => {
  const { server } = await import('./setup');
  const { http, HttpResponse } = await import('msw');

  // Override only for this test; afterEach resetHandlers restores the default
  server.use(
    http.get('/api/users/:id', () => HttpResponse.json({ error: 'Not found' }, { status: 404 })),
  );

  render(<UserProfile id="00000000-0000-0000-0000-000000000001" />);
  expect(await screen.findByText(/Loading/)).toBeInTheDocument();
});

Verification

Run the suite and confirm the output contains no timeout warnings or unhandled-request errors:

$ npx jest --testPathPattern=UserProfile --verbose

 PASS  src/UserProfile.test.tsx
  ✓ renders user name and role (42 ms)
  ✓ can override the handler for a specific error scenario (18 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Time:        1.284 s

If you see [MSW] Warning: captured a request without a matching request handler, a fetch call is still unintercepted. Add a handler for that URL or set onUnhandledRequest: 'error' so it fails loudly instead of silently.

To confirm no real network traffic is leaving the process:

# Run with Node's built-in network diagnostic; should show zero external TCP connections
NODE_OPTIONS="--dns-result-order=ipv4first" \
  npx jest --testPathPattern=UserProfile 2>&1 | grep -i "fetch\|tcp\|network"

How the request flow changes

The diagram below shows what happens to a fetch('/api/users/1') call before and after MSW is installed.

Fetch interception: live network vs MSW mock Left side shows an unintercepted fetch travelling from the component through the test runner out to the network and back. Right side shows MSW intercepting the same fetch inside the test runner and returning a fixture without any network traffic. WITHOUT MSW WITH MSW Component Network fetch /api/users/1 flaky / timeout / 401 Component MSW Network fetch /api/users/1 { id, name, role } ✓ handler match blocked

Edge cases and caveats

  • Vitest with pool: 'forks' — each worker spawns a new process, so setupServer must be called inside setupFiles (not just globalSetup). If you configure the server in globalSetup, the child processes will not have the interceptors active and requests will escape to the network.

  • React 18 concurrent modeact() wrapping is stricter. If findByText times out even though the handler is in place, wrap the render in act(async () => { ... }) and check that state updates are flushed before the assertion.

  • MSW 2.x handler syntax — the v2 API replaced rest.get with http.get and ctx.json() with HttpResponse.json(). If you are upgrading from MSW 1.x, update every handler file; the two APIs cannot be mixed in the same server instance.


FAQ

Should I use msw/node or msw/browser for component tests?

Use msw/node (setupServer) for Jest and Vitest running in jsdom or a Node environment. Use the browser service worker (setupWorker) for Playwright end-to-end tests or Storybook running in a real browser. Mixing them causes the interceptors to target the wrong runtime.

Why does onUnhandledRequest: 'error' blow up my whole suite?

It is doing its job. A component is firing a fetch that has no matching handler. Add a handler for that URL pattern, or if the request is expected to not fire in that test scenario, fix the component logic. Do not downgrade to 'warn' as a workaround — that hides the leak instead of fixing it.

Do I need to call server.resetHandlers() after every test?

Yes. Without it, a per-test override added with server.use(...) persists into the next test and causes order-dependent failures that are extremely difficult to debug. Always call server.resetHandlers() in afterEach.