Testing asynchronous React components inevitably confronts the challenge of network layer isolation. Historically, developers resorted to mocking global functions or client libraries directly: vi.spyOn(global, 'fetch') or vi.mock('axios').
Directly mocking network clients is a fundamental architectural anti-pattern for several reasons:
Content-Type headers, URL encoding, and FormData parsing remain undetected.axios with native fetch or introducing a GraphQL client shatters the entire test suite, even if the backend API contract remains unaltered.Mock Service Worker (MSW) version 2.x represents a paradigm shift by transitioning fully to standard W3C Fetch API specifications (Request, Response, Headers). Applications execute authentic fetch() calls without any modifications to production code, while MSW transparently intercepts them at the network boundary.
In a test environment (Node.js with Vitest 4+ and happy-dom/jsdom), browser Service Workers do not exist. MSW 2.x leverages @mswjs/interceptors, hooking into low-level Node.js modules (http, https, global fetch, XMLHttpRequest).
When a React component, TanStack Query hook, or SWR instance initiates a network call, the request is intercepted before touching the operating system network stack. MSW matches the request URL and method against registered Request Handlers. Upon a match, an authentic W3C Response is produced via the HttpResponse class.
+-----------------------------------------------------------------------+
| React 19 Component / TanStack Query (fetch('/api/v1/projects')) |
+-----------------------------------------------------------------------+
│
▼ (Standard Fetch API invocation)
+-----------------------------------------------------------------------+
| Node.js / happy-dom Environment |
| [@mswjs/interceptors: socket-level request interception] |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| MSW 2.x Request Matcher Pipeline |
| 1. Evaluate runtime overrides (server.use) |
| 2. Match base handlers (http.get, http.post) |
+-----------------------------------------------------------------------+
│ │
(Match found) (No match found)
▼ ▼
+-------------------------------+ +---------------------------+
| HttpResponse.json(mockData) | | onUnhandledRequest |
| - Produces standard | | - 'error': test fails |
| W3C Response object | | - 'bypass': hits network |
+-------------------------------+ +---------------------------+MSW 2.x deprecates the legacy rest.* namespace and res(ctx.status(...), ctx.json(...)) closures in favor of modern web standards:
| Feature | Legacy MSW 1.x (Deprecated) | Modern MSW 2.x (Standard) |
| :--- | :--- | :--- |
| Namespace | rest.get(), rest.post() | http.get(), http.post() |
| Response Constructor | res(ctx.status(200), ctx.json(data)) | HttpResponse.json(data, { status: 200 }) |
| Request Inspection | req.params, req.body | { request, params, cookies }, await request.json() |
| Network Error | res.networkError('Failed') | HttpResponse.error() |
| Response Delay | ctx.delay(200) | await delay(200) |
http Object#import { http, HttpResponse, delay } from 'msw';
export const handlers = [
// Intercepting GET request with route parameters
http.get('/api/users/:userId', ({ params }) => {
const { userId } = params;
return HttpResponse.json({
id: userId,
name: 'Senior Architect',
role: 'staff',
});
}),
// Intercepting POST request and extracting JSON body
http.post('/api/auth/login', async ({ request }) => {
const body = (await request.json()) as { email?: string };
if (body.email === '[email protected]') {
return HttpResponse.json(
{ error: 'Account Suspended' },
{ status: 403 }
);
}
return HttpResponse.json({ token: 'mock-jwt-token-2026' });
}),
// Simulating hardware network failure (DNS / Connection Refused)
http.get('/api/telemetry', () => {
return HttpResponse.error();
}),
];setupFiles)#Managing the interception server lifecycle is centralized in a Vitest setup file:
import { setupServer } from 'msw/node';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// 1. Establish request interception before test suite execution
beforeAll(() => {
server.listen({
// Abort test immediately if an unhandled network request is dispatched
onUnhandledRequest: 'error',
});
});
// 2. Clean up runtime overrides from server.use() after each test
afterEach(() => {
server.resetHandlers();
});
// 3. Terminate server and restore original network drivers
afterAll(() => {
server.close();
});The component queries an endpoint upon mounting, renders a loading indicator, and subsequently paints received list records.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import React, { useState, useEffect } from 'react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
interface Project {
readonly id: string;
readonly name: string;
readonly stars: number;
}
function ProjectList() {
const [projects, setProjects] = useState<readonly Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch('/api/v1/projects')
.then((res) => res.json() as Promise<readonly Project[]>)
.then((data) => {
setProjects(data);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div role="status">Loading project data...</div>;
}
return (
<section aria-label="Project catalog">
<h2>Project List</h2>
<ul>
{projects.map((p) => (
<li key={p.id}>
<span>{p.name}</span> — <strong>{p.stars} ★</strong>
</li>
))}
</ul>
</section>
);
}
describe('ProjectList with MSW v2', () => {
it('displays loading state and renders items from network response', async () => {
// Register mock response
server.use(
http.get('/api/v1/projects', () => {
return HttpResponse.json([
{ id: '1', name: 'Next.js 15 Starter', stars: 450 },
{ id: '2', name: 'Vitest Benchmark Suite', stars: 280 },
]);
})
);
render(<ProjectList />);
// 1. Verify loading state is initially present
const loader = screen.getByRole('status');
expect(loader).toHaveTextContent('Loading project data...');
// 2. Await loader removal from the DOM
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 3. Verify rendered list items
expect(
screen.getByRole('region', { name: 'Project catalog' })
).toBeInTheDocument();
expect(screen.getByText('Next.js 15 Starter')).toBeInTheDocument();
expect(screen.getByText('Vitest Benchmark Suite')).toBeInTheDocument();
});
});server.use() (Testing 500 and Network Errors)#The server.use() method allows isolated it blocks to override endpoint responses, simulating boundary conditions like server crashes or unauthorized access.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import React, { useState, useEffect } from 'react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
function UserProfile({ userId }: { readonly userId: string }) {
const [profile, setProfile] = useState<{ readonly name: string } | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(async (res) => {
if (!res.ok) {
throw new Error(`HTTP Error: ${res.status}`);
}
return res.json() as Promise<{ readonly name: string }>;
})
.then((data) => setProfile(data))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to fetch user';
setErrorMsg(msg);
});
}, [userId]);
if (errorMsg) {
return <div role="alert">Error: {errorMsg}</div>;
}
if (!profile) {
return <p>Initializing profile...</p>;
}
return <h1>Profile: {profile.name}</h1>;
}
describe('UserProfile Error Boundaries and Server Overrides', () => {
it('correctly handles 500 Internal Server Error via server.use()', async () => {
// Temporary override scoped exclusively to this test
server.use(
http.get('/api/users/:userId', () => {
return HttpResponse.json(
{ message: 'Database Connection Timeout' },
{ status: 500 }
);
})
);
render(<UserProfile userId="999" />);
// Asynchronously await error alert rendering
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent('Error: HTTP Error: 500');
});
it('simulates socket network failure (HttpResponse.error)', async () => {
server.use(
http.get('/api/users/:userId', () => {
// Emulates physical socket severance (Failed to fetch / TypeError)
return HttpResponse.error();
})
);
render(<UserProfile userId="500" />);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toBeInTheDocument();
});
});MSW request handlers can inspect incoming payload headers, query parameters, and JSON bodies, emitting 422 Unprocessable Entity or 201 Created depending on contract validity.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
interface IssuePayload {
readonly title: string;
readonly severity: 'low' | 'critical';
}
function CreateIssueForm() {
const [title, setTitle] = useState('');
const [severity, setSeverity] = useState<'low' | 'critical'>('low');
const [successId, setSuccessId] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, severity }),
});
if (res.ok) {
const data = (await res.json()) as { id: string };
setSuccessId(data.id);
}
};
return (
<form onSubmit={handleSubmit} aria-label="Create issue form">
<label htmlFor="issue-title">Issue Title</label>
<input
id="issue-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<label htmlFor="issue-sev">Severity</label>
<select
id="issue-sev"
value={severity}
onChange={(e) => setSeverity(e.target.value as 'low' | 'critical')}
>
<option value="low">Low</option>
<option value="critical">Critical</option>
</select>
<button type="submit">Submit Ticket</button>
{successId && <div role="status">Ticket created successfully: #{successId}</div>}
</form>
);
}
describe('CreateIssueForm Network Mutation', () => {
it('dispatches POST request with serialized JSON payload', async () => {
const user = userEvent.setup();
let capturedBody: IssuePayload | null = null;
server.use(
http.post('/api/issues', async ({ request }) => {
capturedBody = (await request.json()) as IssuePayload;
return HttpResponse.json({ id: 'ISSUE-4096' }, { status: 201 });
})
);
render(<CreateIssueForm />);
await user.type(screen.getByLabelText('Issue Title'), 'Memory Leak in Fiber');
await user.selectOptions(screen.getByLabelText('Severity'), 'critical');
await user.click(screen.getByRole('button', { name: 'Submit Ticket' }));
// Await confirmation message
const statusMsg = await screen.findByRole('status');
expect(statusMsg).toHaveTextContent('Ticket created successfully: #ISSUE-4096');
// Verify intercepted network payload
expect(capturedBody).toEqual({
title: 'Memory Leak in Fiber',
severity: 'critical',
});
});
});delay() to Guard Against Race Conditions#When testing autocomplete inputs or rapid user clicking, verifying that out-of-order responses are discarded via AbortController guards against race conditions.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useEffect } from 'react';
import { http, HttpResponse, delay } from 'msw';
import { server } from '../test/mocks/server';
function SearchBox() {
const [query, setQuery] = useState('');
const [result, setResult] = useState<string>('');
useEffect(() => {
if (!query) return;
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => res.json() as Promise<{ text: string }>)
.then((data) => setResult(data.text))
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') {
// Request was aborted intentionally
return;
}
});
return () => {
controller.abort();
};
}, [query]);
return (
<div>
<input
aria-label="Search query"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div role="status">Result: {result}</div>
</div>
);
}
describe('SearchBox Race Condition Protection', () => {
it('discards stale slow query in favor of fresh query', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/search', async ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get('q');
// First query 'slow' experiences 200ms latency
if (q === 'slow') {
await delay(200);
return HttpResponse.json({ text: 'Slow Result' });
}
// Second query 'fast' returns immediately
if (q === 'fast') {
return HttpResponse.json({ text: 'Fast Result' });
}
return HttpResponse.json({ text: 'Default' });
})
);
render(<SearchBox />);
const input = screen.getByLabelText('Search query');
// Rapid successive typing
await user.type(input, 'slow');
await user.clear(input);
await user.type(input, 'fast');
// Should display 'Fast Result', with 'Slow Result' aborted
const status = await screen.findByText('Result: Fast Result');
expect(status).toBeInTheDocument();
expect(screen.queryByText('Result: Slow Result')).not.toBeInTheDocument();
});
});In MSW 2.x, the rest object was removed completely. Attempting to write rest.get(url, (req, res, ctx) => res(ctx.json(...))) will crash at runtime with TypeError: rest is not defined.
// ❌ ANTIPATTERN (MSW 1.x): Deprecated syntax
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ id: 1 })));
// ✅ SOLUTION (MSW 2.x): Modern web standards
http.get('/api/user', () => HttpResponse.json({ id: 1 }));If a test uses server.use() to test a 500 error and server.resetHandlers() is not called in afterEach, that override will leak into downstream tests, causing unexpected cascading failures.
// ❌ ANTIPATTERN: Missing handler reset between tests
afterEach(() => {
cleanup();
});
// ✅ SOLUTION: Mandatory reset of runtime mock overrides
afterEach(() => {
cleanup();
server.resetHandlers();
});Configuring 'bypass' causes MSW to ignore unhandled calls, letting them slip out to the real network (if environment permits) or fail with opaque Node.js errors, concealing route typos.
// ❌ ANTIPATTERN: Typos (/api/useers instead of /api/users) will pass unnoticed
server.listen({ onUnhandledRequest: 'bypass' });
// ✅ SOLUTION: Strict enforcement with immediate failure on unknown requests
server.listen({ onUnhandledRequest: 'error' });In browser environments, fetch('/api/data') resolves automatically against window.location.origin. In minimal Node.js environments, relative URLs can cause TypeError: Invalid URL.
// ❌ DANGER: Relative URLs without origin in pure Node
fetch('/api/data');
// ✅ SOLUTION: Ensure happy-dom/jsdom defines window.location.origin
// In handlers use relative paths '/api/data',
// and link test runner config to http://localhost:3000Define baseline request handlers (http.get, http.post) in an isolated mocks/handlers.ts file.
Create setupServer(...handlers) instance and launch server.listen({ onUnhandledRequest: 'error' }).
Utilize server.use() inside specific test cases to simulate 401, 500, delay, or socket errors.
React components invoke standard fetch, intercepted at the socket boundary by MSW.
Automatically invoke server.resetHandlers() in afterEach to preserve test isolation.