Времена медленных тестов на тяжелом Jest с громоздкими конфигурациями Babel и Webpack ушли в прошлое. В 2026 году золотым стандартом компонентного и интеграционного тестирования стала связка Vitest 4, легковесного виртуального окружения happy-dom, библиотеки React Testing Library и перехватчика сетевых запросов Mock Service Worker (MSW v2). Такой подход гарантирует максимальный коэффициент уверенности (Confidence) при минимальных затратах на поддержку тестов при рефакторинге.
Когда в проекте тестирование выстроено неправильно, тесты приносят больше вреда, чем пользы:
useState, падают при любом малейшем визуальном рефакторинге, даже если бизнес-логика и интерфейс работают идеально.window.fetch:global.fetch = vi.fn() заставляет разработчика вручную описывать заголовки, статусы и структуру ответа в каждом тесте. В результате моки устаревают, расходятся с реальным API бэкенда и пропускают критические баги.act() в React:Warning: An update to Component inside a test was not wrapped in act(...) и нестабильным (flaky) результатам прогона в CI/CD.Тест взаимодействует с компонентом точно так же, как реальный пользователь: видит текст на экране, кликает по кнопкам через @testing-library/user-event и проверяет результат, не зная, какие хуки или стейты используются внутри:
┌────────────────────────────────────────────────────────┐
│ Vitest 4 Runner (happy-dom виртуальное окружение) │
└───────────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ React Testing Library: render(<UserCard id="42" />) │
└───────────────────────────┬────────────────────────────┘
│
▼ Компонент отправляет fetch('/api/user/42')
┌────────────────────────────────────────────────────────┐
│ MSW v2 Node Interceptors (Сетевой перехват) │
│ - Запрос не уходит в интернет │
│ - Срабатывает обработчик http.get('/api/user/:id') │
│ - Возвращает HttpResponse.json({ name: 'Alex' }) │
└───────────────────────────┬────────────────────────────┘
│
▼ Компонент обновляет UI
┌────────────────────────────────────────────────────────┐
│ Assertions: expect(screen.getByRole('heading', │
│ { name: 'Alex' })).toBeInTheDocument() │
└────────────────────────────────────────────────────────┘Установка vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, msw и happy-dom.
Настройка окружения environment: 'happy-dom', подключение файла глобальной инициализации setupFiles и алиасов путей.
Декларация перехватчиков маршрутов API с использованием http.get() и HttpResponse.json() в src/mocks/handlers.ts.
Создание src/test/setup.ts с автоматическим запуском server.listen(), сбросом server.resetHandlers() и очисткой DOM.
Тестирование рендеринга данных, состояний загрузки, ошибок и взаимодействия через userEvent.setup().
Какой селектор React Testing Library имеет наивысший приоритет согласно официальным рекомендациям команды Testing Library и принципам доступности (a11y)?
Vitest распределяет тестовые файлы по параллельным Worker Threads, изолируя контекст оперативной памяти каждого запуска.
Использование happy-dom сокращает время запуска каждого воркера до менее 15 мс.
Перед каждым тестом MSW активирует системный перехватчик в Node.js. Любой вызов нативного fetch моментально маршрутизируется в mock-хэндлеры.
Исходный код компонентов не догадывается, что работает с фиктивными данными.
Пользовательские действия эмулируются полной цепочкой событий браузера: pointerDown -> focus -> click -> pointerUp.
В отличие от устаревшего fireEvent, user-event точно имитирует реальное поведение браузера.
Хук afterEach сбрасывает все временные переопределения сервера (server.resetHandlers()) и очищает виртуальный DOM.
Полное исключение утечек состояния между соседними тестами.
Ниже приведены файлы эталонного окружения тестирования.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
statements: 85,
branches: 80,
functions: 85,
lines: 85,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});import { http, HttpResponse } from 'msw';
export interface UserResponse {
id: string;
name: string;
email: string;
status: 'active' | 'suspended';
}
export const handlers = [
// Перехват запроса информации о пользователе
http.get('https://api.example.com/users/:id', ({ params }) => {
const { id } = params;
if (id === 'error_id') {
return HttpResponse.json(
{ message: 'User not found in database' },
{ status: 404 }
);
}
const mockUser: UserResponse = {
id: String(id),
name: 'Алексей Смирнов',
email: '[email protected]',
status: 'active',
};
return HttpResponse.json(mockUser, { status: 200 });
}),
// Перехват мутации обновления статуса
http.patch('https://api.example.com/users/:id/status', async ({ request }) => {
const body = (await request.json()) as { status: string };
return HttpResponse.json({ success: true, updatedStatus: body.status });
}),
];import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { handlers } from '@/mocks/handlers';
// Инициализация сервера перехвата сетевых вызовов в Node.js
export const server = setupServer(...handlers);
// Запуск сервера перед всеми тестами
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' }); // Блокировать любые немокированные сетевые вызовы
});
// Сброс переопределенных обработчиков после каждого отдельного теста
afterEach(() => {
cleanup();
server.resetHandlers();
});
// Остановка сервера после завершения всего набора тестов
afterAll(() => {
server.close();
});import React, { useEffect, useState } from 'react';
interface UserData {
id: string;
name: string;
email: string;
status: 'active' | 'suspended';
}
export const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<UserData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isCancelled = false;
async function loadUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('Не удалось загрузить пользователя');
}
const data = await response.json();
if (!isCancelled) setUser(data);
} catch (err: unknown) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : 'Сетевая ошибка');
}
} finally {
if (!isCancelled) setLoading(false);
}
}
void loadUser();
return () => {
isCancelled = true;
};
}, [userId]);
if (loading) {
return <div role="status">Загрузка профиля пользователя...</div>;
}
if (error) {
return <div role="alert">Ошибка: {error}</div>;
}
if (!user) return null;
return (
<article className="user-card">
<h2>{user.name}</h2>
<p>{user.email}</p>
<span data-status={user.status}>Статус: {user.status}</span>
</article>
);
};import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/setup';
import { UserProfile } from './UserProfile';
describe('Компонент UserProfile', () => {
it('отображает состояние загрузки при первом монтировании', () => {
render(<UserProfile userId="usr_123" />);
const loadingElement = screen.getByRole('status');
expect(loadingElement).toHaveTextContent(/загрузка профиля/i);
});
it('успешно отображает данные пользователя после разрешения сетевого запроса', async () => {
render(<UserProfile userId="usr_123" />);
// findByRole автоматически ожидает разрешения асинхронного события (waitFor)
const heading = await screen.findByRole('heading', { level: 2 });
expect(heading).toHaveTextContent('Алексей Смирнов');
const email = screen.getByText('[email protected]');
expect(email).toBeInTheDocument();
});
it('корректно отображает блок ошибки при статусе 404 от API', async () => {
// Точечное переопределение обработчика для конкретного теста
server.use(
http.get('https://api.example.com/users/:id', () => {
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
})
);
render(<UserProfile userId="non_existent" />);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent(/не удалось загрузить пользователя/i);
});
});Если в конкретном тесте вы переопределили сетевой ответ через server.use(...) (например, сымитировали ошибку 500), но забыли вызвать server.resetHandlers() в хуке afterEach, этот ошибочный хэндлер продолжит действовать во всех последующих тестах. Это приведет к эффекту каскадных ложных падений (Test Pollution). Всегда настраивайте сброс в глобальном setup.ts.
Метод fireEvent.click(button) просто диспатчит синтетическое DOM-событие 'click'. Он не эмулирует перевод фокуса, события наведения мыши (hover), нажатия пробела или срабатывание валидации формы. Библиотека user-event v14+ воспроизводит точный физический протокол действий пользователя в браузере.
Конструкция await waitFor(() => expect(screen.getByRole('heading')).toBeInTheDocument()) является избыточной и ухудшает читаемость кода. Методы findBy* в React Testing Library уже содержат внутри себя waitFor. Пишите лаконично: expect(await screen.findByRole('heading')).toBeInTheDocument().
Если при запуске server.listen() указать onUnhandledRequest: 'bypass', любые неучтенные запросы пойдут в реальную сеть. В среде CI/CD без доступа к интернету тесты упадут с непонятными таймаутами. Всегда устанавливайте onUnhandledRequest: 'error' — это мгновенно укажет разработчику на забытый mock-эндпоинт.
Команда решила отказаться от MSW и написала в тестах: vi.spyOn(global, 'fetch').mockResolvedValue(new Response('...')). С какими серьезными проблемами они неизбежно столкнутся при масштабировании кодовой базы?