Тестування асинхронних 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#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 } 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 синтаксис 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Визначення масиву базових обробників (http.get, http.post) в ізольованому файлі mocks/handlers.ts.
Створення інстансу setupServer(...handlers) та запуск server.listen({ onUnhandledRequest: 'error' }).
Використання server.use() у конкретних тест-кейсах для симуляції помилок 401, 500, delay або збоїв сокетів.
React-компоненти викликають стандартний fetch, інтерцептори MSW перехоплюють трафік на рівні сокетів.
Автоматичний виклик server.resetHandlers() в afterEach для запобігання взаємного впливу тестів.