React Integration Testing: Network Interception with MSW and React Testing Library
Frontend testing often suffers from a frustrating paradox: suites of hundreds of unit tests pass cleanly in continuous integration, yet critical bugs still slip into production. Even worse, simple component refactors—such as replacing fetch with ky or swapping an internal custom hook—often cause dozens of tests to break without changing any user-visible behavior.
This fragility stems from testing implementation details. When developers mock internal modules (jest.mock('./useUserData')) or stub global fetch methods (jest.spyOn(global, 'fetch')), tests become tightly coupled to internal plumbing rather than verifying what the user actually experiences.
To build resilient, confidence-inspiring test suites, modern React engineering pairs React Testing Library with Mock Service Worker (MSW). In this deep dive, we explore network-level request interception, configuring test servers with Vitest and Jest, querying components by accessible semantics, simulating network error conditions, and preventing mock drift.
The Flaws of Module-Level Mocking
Consider the typical anti-pattern of mocking fetch directly in component test files:
// 🚩 Anti-Pattern: Fragile Global Fetch Stubbing
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: '123', name: 'Alice' }),
})
);
While convenient, this approach introduces severe flaws:
- Coupling to Implementation: If you refactor your component to use React 19 Server Functions, an abstraction like TanStack Query, or an alternative fetcher, your mocked fetch setup breaks.
- Missing Real HTTP Semantics: Stubs bypass actual HTTP request lifecycle events, query parameter parsing, headers validation, and status code handling.
- Leaked State: Forgetting to restore mocked implementations between test suites causes silent test pollution across unrelated files.
Network Interception Architecture with Mock Service Worker (MSW)
Instead of monkey-patching JavaScript runtime modules, Mock Service Worker (MSW) intercepts requests at the network layer.
- In the Browser / Storybook: MSW registers a Service Worker that intercepts outgoing
fetchandXMLHttpRequesttraffic before it leaves the browser. - In Node.js (Vitest / Jest): MSW intercepts network sockets using native Node request interceptors (
node:http,node:https, and globalfetch).
From the perspective of your React components, network traffic behaves exactly as it would against a real production API.
Setting Up MSW with Node Test Runners
To configure MSW for integration tests in Vitest or Jest, define modular request handlers and initialize a test server.
1. Define Request Handlers (src/mocks/handlers.ts)
import { http, HttpResponse } from 'msw';
export interface UserProfile {
id: string;
name: string;
email: string;
tier: 'free' | 'pro';
}
export const handlers = [
// Intercept GET /api/user
http.get('/api/user', () => {
return HttpResponse.json<UserProfile>({
id: 'usr_789',
name: 'Sarah Connor',
email: 'sarah@skynet-defense.org',
tier: 'pro',
});
}),
// Intercept POST /api/user/update
http.post('/api/user/update', async ({ request }) => {
const body = (await request.json()) as Partial<UserProfile>;
if (!body.name) {
return HttpResponse.json({ error: 'Name is required' }, { status: 400 });
}
return HttpResponse.json<UserProfile>({
id: 'usr_789',
name: body.name,
email: body.email || 'sarah@skynet-defense.org',
tier: 'pro',
});
}),
];
2. Configure the Test Server Lifecycle (src/mocks/server.ts & Setup)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// In your global test setup file (e.g., setupTests.ts)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Enabling onUnhandledRequest: 'error' ensures that any unmocked network request made during testing immediately fails the test, catching unintended API calls early.
Testing Real User Workflows with React Testing Library
React Testing Library is built around a foundational principle: The more your tests resemble the way your software is used, the more confidence they can give you.
Instead of inspecting internal state or querying arbitrary CSS classes, you locate elements using Accessible Roles (ByRole Queries):
// src/components/UserProfileEditor.tsx
import React, { useState, useEffect } from 'react';
export function UserProfileEditor() {
const [name, setName] = useState('');
const [loading, setLoading] = useState(true);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
useEffect(() => {
fetch('/api/user')
.then((res) => res.json())
.then((data) => {
setName(data.name);
setLoading(false);
});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatusMessage('Saving...');
const res = await fetch('/api/user/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (res.ok) {
setStatusMessage('Profile updated successfully!');
} else {
const err = await res.json();
setStatusMessage(`Error: ${err.error || 'Failed to update'}`);
}
};
if (loading) return <div role="status">Loading user data...</div>;
return (
<form onSubmit={handleSubmit} aria-label="Edit Profile">
<label htmlFor="user-name">Full Name</label>
<input
id="user-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">Save Changes</button>
{statusMessage && <p role="alert">{statusMessage}</p>}
</form>
);
}
Writing Integration Tests with Realistic Network Conditions
Using @testing-library/user-event alongside the React Testing Library API, you can assert the entire user experience from initial fetch to mutation:
// src/components/__tests__/UserProfileEditor.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { UserProfileEditor } from '../UserProfileEditor';
describe('UserProfileEditor Integration', () => {
it('loads and displays initial user profile data', async () => {
render(<UserProfileEditor />);
// 1. Assert loading state
expect(screen.getByRole('status')).toHaveTextContent(/loading user data/i);
// 2. Await async fetch resolution via accessible role
const input = await screen.findByRole('textbox', { name: /full name/i });
expect(input).toHaveValue('Sarah Connor');
});
it('submits updated profile and displays confirmation', async () => {
const user = userEvent.setup();
render(<UserProfileEditor />);
const input = await screen.findByRole('textbox', { name: /full name/i });
const submitBtn = screen.getByRole('button', { name: /save changes/i });
// 3. User interactions: Clear input, type new name, and click save
await user.clear(input);
await user.type(input, 'Sarah Reese');
await user.click(submitBtn);
// 4. Assert optimistic / pending message and final alert
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/profile updated successfully/i);
});
it('handles server errors gracefully without crashing the UI', async () => {
// Override the handler for this specific test case
server.use(
http.post('/api/user/update', () => {
return HttpResponse.json({ error: 'Database timeout' }, { status: 500 });
})
);
const user = userEvent.setup();
render(<UserProfileEditor />);
const input = await screen.findByRole('textbox', { name: /full name/i });
const submitBtn = screen.getByRole('button', { name: /save changes/i });
await user.clear(input);
await user.type(input, 'Sarah Reese');
await user.click(submitBtn);
// Assert error alert displays error payload from server
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/error: database timeout/i);
});
});
Preventing Mock Drift with Contract Testing
One danger of mock-based integration testing is mock drift: your mock handlers return { name: 'Sarah' }, but the backend team updates the API schema to { firstName: 'Sarah', lastName: 'Connor' }. Your frontend tests continue passing despite breaking in production.
To prevent mock drift:
- Shared TypeScript Interfaces: Import request and response interfaces generated directly from backend schemas or OpenAPI specifications.
- Runtime Contract Validation: Validate mock handler payloads using Zod or Valibot schemas to ensure MSW responses strictly conform to production data contracts.
- Automated MSW Handlers from OpenAPI: Tools like
msw-auto-mockor OpenAPI generators can synthesize MSW handlers directly from OpenAPI/Swagger definitions.
Architectural Guidelines and Best Practices
- Query Accessible Roles First: Always prefer
getByRole,findByRole, andgetByLabelText. Avoid querying by raw CSS selectors (querySelector) or arbitrary data-testids unless an element lacks accessible semantics. - Never Mock What You Can Intercept: Intercept network calls with MSW rather than spying on fetch or mocking custom hooks.
- Isolate Test Overrides: When overriding a mock handler for an error test (
server.use(...)), rely onafterEach(() => server.resetHandlers())to avoid polluting adjacent test suites. - Use user-event over fireEvent:
@testing-library/user-eventdispatches all browser companion events (focus, keydown, keypress, keyup, change) rather than firing a synthetic event.
Conclusion
Testing React applications should provide confidence, not maintenance headaches. By moving mocks out of application code and into the network layer with Mock Service Worker, and testing user interactions with React Testing Library, you decouple test suites from internal implementation details. The result is a robust, maintainable integration test suite that verifies real-world application reliability.