Тестирование асинхронных React-компонентов неизбежно сталкивается с проблемой изоляции сетевого слоя. Исторически разработчики прибегали к прямому мокированию глобальных функций или сторонних клиентов: vi.spyOn(global, 'fetch') или vi.mock('axios').
Прямое мокирование сетевых клиентов — фундаментальный архитектурный антипаттерн по следующим причинам:
Content-Type, кодирования URL-параметров и парсинга FormData остаются незамеченными.axios на нативный fetch или внедрение GraphQL-клиента ломает всю кодовую базу тестов, даже если контракт API сервера не изменился.Mock Service Worker (MSW) версии 2.x совершила качественный скачок, полностью перейдя на стандартные спецификации W3C Fetch API (Request, Response, Headers). Приложение выполняет аутентичные вызовы fetch() без каких-либо изменений в продакшен-коде, а MSW прозрачно перехватывает их на границе сетевого ввода-вывода.
В тестовом окружении (Node.js с Vitest 4+ и средой happy-dom/jsdom) браузерный Service Worker недоступен. MSW 2.x использует пакет @mswjs/interceptors, который внедряется в низкоуровневые модули Node.js (http, https, глобальный fetch, XMLHttpRequest).
Когда React-компонент, хук TanStack Query или библиотека SWR отправляет запрос, вызов перехватывается до выхода в операционную систему. MSW сопоставляет URL и метод запроса с зарегистрированными обработчиками (Request Handlers). При совпадении формируется аутентичный W3C-ответ через класс HttpResponse.
+-----------------------------------------------------------------------+
| React 19 Component / TanStack Query (fetch('/api/v1/projects')) |
+-----------------------------------------------------------------------+
│
▼ (Вызов стандартного Fetch API)
+-----------------------------------------------------------------------+
| Node.js / happy-dom Environment |
| [@mswjs/interceptors: перехват вызова на уровне сокетов] |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| MSW 2.x Request Matcher Pipeline |
| 1. Проверка локальных переопределений (server.use) |
| 2. Проверка базовых handlers (http.get, http.post) |
+-----------------------------------------------------------------------+
│ │
(Матч найден) (Матч не найден)
▼ ▼
+-------------------------------+ +---------------------------+
| HttpResponse.json(mockData) | | onUnhandledRequest |
| - Генерация стандартного | | - 'error': падение теста |
| объекта Response (W3C) | | - 'bypass': выход в сеть |
+-------------------------------+ +---------------------------+В MSW 2.x был полностью упразднен легаси-синтаксис rest.* и замыкание res(ctx.status(...), ctx.json(...)) в пользу современных веб-стандартов:
| Возможность | Легаси MSW 1.x (Устарело) | Современный MSW 2.x (Стандарт) |
| :--- | :--- | :--- |
| Пространство имен | rest.get(), rest.post() | http.get(), http.post() |
| Конструктор ответа | res(ctx.status(200), ctx.json(data)) | HttpResponse.json(data, { status: 200 }) |
| Параметры запроса | req.params, req.body | { request, params, cookies }, await request.json() |
| Сетевой сбой | res.networkError('Failed') | HttpResponse.error() |
| Задержка ответа | ctx.delay(200) | await delay(200) |
Определение массива базовых обработчиков (http.get, http.post) в изолированном файле mocks/handlers.ts.
Создание инстанса setupServer(...handlers) и запуск server.listen({ onUnhandledRequest: 'error' }).
Использование server.use() в конкретных тест-кейсах для симуляции ошибок 401, 500, delay или сбоев сокетов.
React-компоненты вызывают стандартный fetch, интерцепторы MSW перехватывают трафик на уровне сокетов.
Автоматический вызов server.resetHandlers() в afterEach для предотвращения взаимного влияния тестов.
Почему мокирование сетевого трафика через MSW 2.x архитектурно надежнее прямого шпионажа vi.spyOn(global, 'fetch')?
http#import { http, HttpResponse, delay } from 'msw';
export const handlers = [
// Перехват GET-запроса с URL-параметрами
http.get('/api/users/:userId', ({ params }) => {
const { userId } = params;
return HttpResponse.json({
id: userId,
name: 'Senior Architect',
role: 'staff',
});
}),
// Перехват POST-запроса с извлечением JSON-тела
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' });
}),
// Симуляция аппаратного сбоя сети (DNS / Connection Refused)
http.get('/api/telemetry', () => {
return HttpResponse.error();
}),
];setupFiles)#Управление жизненным циклом сервера перехвата настраивается в файле инициализации Vitest:
import { setupServer } from 'msw/node';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// 1. Старт перехвата перед выполнением сьюта
beforeAll(() => {
server.listen({
// Бросать фатальную ошибку при любом неперехваченном запросе
onUnhandledRequest: 'error',
});
});
// 2. Сброс переопределений server.use() после каждого теста
afterEach(() => {
server.resetHandlers();
});
// 3. Полная остановка сервера и восстановление сетевого стека
afterAll(() => {
server.close();
});Компонент запрашивает данные при монтировании, отображает спиннер, а затем рендерит полученные элементы списка.
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">Загрузка данных проекта...</div>;
}
return (
<section aria-label="Каталог проектов">
<h2>Список проектов</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('отображает индикатор загрузки и рендерит данные из сетевого ответа', async () => {
// Регистрация мок-ответа
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. Проверяем начальное отображение индикатора загрузки
const loader = screen.getByRole('status');
expect(loader).toHaveTextContent('Загрузка данных проекта...');
// 2. Ожидаем исчезновения спиннера из DOM
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 3. Проверяем отрисовку элементов списка
expect(
screen.getByRole('region', { name: 'Каталог проектов' })
).toBeInTheDocument();
expect(screen.getByText('Next.js 15 Starter')).toBeInTheDocument();
expect(screen.getByText('Vitest Benchmark Suite')).toBeInTheDocument();
});
});server.use() (Тестирование ошибок 500 и 404)#Метод server.use() позволяет в изолированном блоке it переопределить стандартное поведение эндпоинта для симуляции граничных состояний: ошибок сервера или сбоев авторизации.
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">Ошибка: {errorMsg}</div>;
}
if (!profile) {
return <p>Инициализация профиля...</p>;
}
return <h1>Профиль: {profile.name}</h1>;
}
describe('UserProfile Error Boundaries and Server Overrides', () => {
it('корректно обрабатывает 500 Internal Server Error через server.use()', async () => {
// Временное переопределение эндпоинта исключительно для этого теста
server.use(
http.get('/api/users/:userId', () => {
return HttpResponse.json(
{ message: 'Database Connection Timeout' },
{ status: 500 }
);
})
);
render(<UserProfile userId="999" />);
// Асинхронно ожидаем появления алерта об ошибке
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent('Ошибка: HTTP Error: 500');
});
it('симулирует физический сбой сетевого сокета (HttpResponse.error)', async () => {
server.use(
http.get('/api/users/:userId', () => {
// Эмулирует сетевой обрыв (Failed to fetch / TypeError)
return HttpResponse.error();
})
);
render(<UserProfile userId="500" />);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toBeInTheDocument();
});
});Обработчик MSW может валидировать передаваемые клиентом данные: заголовки, параметры запроса и тело, отправляя статус 422 Unprocessable Entity при несовпадении контракта.
import { describe, it, expect, vi } 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="Создание инцидента">
<label htmlFor="issue-title">Название задачи</label>
<input
id="issue-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<label htmlFor="issue-sev">Критичность</label>
<select
id="issue-sev"
value={severity}
onChange={(e) => setSeverity(e.target.value as 'low' | 'critical')}
>
<option value="low">Низкая</option>
<option value="critical">Критическая</option>
</select>
<button type="submit">Создать тикет</button>
{successId && <div role="status">Тикет успешно создан: #{successId}</div>}
</form>
);
}
describe('CreateIssueForm Network Mutation', () => {
it('отправляет POST-запрос с корректной сериализацией JSON', 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('Название задачи'), 'Memory Leak in Fiber');
await user.selectOptions(screen.getByLabelText('Критичность'), 'critical');
await user.click(screen.getByRole('button', { name: 'Создать тикет' }));
// Ожидаем отрисовки подтверждения
const statusMsg = await screen.findByRole('status');
expect(statusMsg).toHaveTextContent('Тикет успешно создан: #ISSUE-4096');
// Проверяем тело перехваченного сетевого запроса
expect(capturedBody).toEqual({
title: 'Memory Leak in Fiber',
severity: 'critical',
});
});
});delay) и проверка Race Conditions#При частых кликах или автокомплите важно проверять, что компонент игнорирует устаревшие ответы благодаря AbortController и не допускает состояния гонки (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') {
// Запрос был успешно отменен
return;
}
});
return () => {
controller.abort();
};
}, [query]);
return (
<div>
<input
aria-label="Поисковый запрос"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div role="status">Результат: {result}</div>
</div>
);
}
describe('SearchBox Race Condition Protection', () => {
it('отменяет устаревший медленный запрос в пользу быстрого актуального', 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');
// Первый запрос "slow" задерживается на 200мс
if (q === 'slow') {
await delay(200);
return HttpResponse.json({ text: 'Slow Result' });
}
// Второй запрос "fast" возвращается мгновенно
if (q === 'fast') {
return HttpResponse.json({ text: 'Fast Result' });
}
return HttpResponse.json({ text: 'Default' });
})
);
render(<SearchBox />);
const input = screen.getByLabelText('Поисковый запрос');
// Быстрый последовательный ввод
await user.type(input, 'slow');
await user.clear(input);
await user.type(input, 'fast');
// В результате должен отобразиться 'Fast Result', а 'Slow Result' отменен
const status = await screen.findByText('Результат: Fast Result');
expect(status).toBeInTheDocument();
expect(screen.queryByText('Результат: Slow Result')).not.toBeInTheDocument();
});
});Для чего в MSW 2.x используется связка методов server.use() и server.resetHandlers()?
В MSW 2.x синтаксис rest был полностью удален. Попытка использовать rest.get(url, (req, res, ctx) => res(ctx.json(...))) приведет к ошибке выполнения TypeError: rest is not defined.
// ❌ АНТИПАТТЕРН (MSW 1.x): Устаревший синтаксис
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ id: 1 })));
// ✅ РЕШЕНИЕ (MSW 2.x): Современный веб-стандарт
http.get('/api/user', () => HttpResponse.json({ id: 1 }));Если в одном из тестов используется server.use() для проверки ошибки 500, а в хуке afterEach не вызван server.resetHandlers(), этот переопределенный обработчик утечет в последующие тесты, вызывая их падение.
// ❌ АНТИПАТТЕРН: Отсутствие сброса обработчиков между тестами
afterEach(() => {
cleanup();
});
// ✅ РЕШЕНИЕ: Обязательный сброс временных моков
afterEach(() => {
cleanup();
server.resetHandlers();
});Значение 'bypass' заставляет MSW игнорировать неперехваченные вызовы, позволяя им уходить в реальную сеть (если это допускает тестовая среда) или молча возвращать ошибку Node.js. Это скрывает опечатки в URL-адресах запросов.
// ❌ АНТИПАТТЕРН: Опечатки в путях (/api/useers вместо /api/users) не будут обнаружены
server.listen({ onUnhandledRequest: 'bypass' });
// ✅ РЕШЕНИЕ: Строгий контроль с немедленным падением при неизвестном запросе
server.listen({ onUnhandledRequest: 'error' });В браузере путь fetch('/api/data') автоматически резолвится относительно текущего домена (window.location.origin). В некоторых минималистичных окружениях Node.js fetch без базового URL может упасть с ошибкой TypeError: Invalid URL.
// ❌ ОПАСНОСТЬ: Использование относительного пути без базового origin в чистом Node
fetch('/api/data');
// ✅ РЕШЕНИЕ: Убедитесь, что тестовая среда (happy-dom/jsdom) выставляет window.location.origin
// В handlers рекомендуется использовать относительные пути '/api/data',
// а конфигурацию раннера связывать с http://localhost:3000