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.
Edge cases and caveats
-
Vitest with
pool: 'forks'— each worker spawns a new process, sosetupServermust be called insidesetupFiles(not justglobalSetup). If you configure the server inglobalSetup, the child processes will not have the interceptors active and requests will escape to the network. -
React 18 concurrent mode —
act()wrapping is stricter. IffindByTexttimes out even though the handler is in place, wrap the render inact(async () => { ... })and check that state updates are flushed before the assertion. -
MSW 2.x handler syntax — the v2 API replaced
rest.getwithhttp.getandctx.json()withHttpResponse.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.
Related
- Mock Boundaries — parent cluster covering the full architecture for isolating network, context, and event dependencies in component tests
- Isolation Principles — the foundational rules for keeping components decoupled from their environment during tests
- Managing component state during automated tests — how to inject deterministic props and context alongside your API mocks
- Storybook addon ecosystems — using the MSW Storybook addon to apply the same handlers in stories as in tests
- Visual regression snapshot strategies — how deterministic API mocks prevent snapshot drift in visual regression pipelines