В современной фронтенд-инженерии ценность тестов измеряется не сухим процентом покрытия строк кода (Code Coverage), а уровнем уверенности (Confidence), который они гарантируют при регулярном рефакторинге и обновлении библиотек.
Исторический подход к тестированию через инспекцию внутренних структур (состояние хуков, пропсы Fiber, проверка вызовов приватных методов) приводил к синдрому «хрупких тестов»:
useState на useReducer, интерфейс для пользователя работает идеально, но 40 тестов падают, требуя ручной починки.Философия React Testing Library (RTL) исключает тестирование деталей реализации (Implementation Details). RTL взаимодействует с виртуальным DOM исключительно так, как это делает реальный человек или ассистивные технологии (скринридеры): через семантические роли ARIA, текстовое содержимое и физические цепочки браузерных событий.
В связке с высокопроизводительным раннером Vitest и сетевым перехватчиком Mock Service Worker (MSW v2) вы получаете эталонный пайплайн тестирования React 19.
Вместо имитации вызовов React-компонента тест запускает изолированную мини-симуляцию браузера:
[Пользователь / Скринридер]
│
▼ (Взаимодействие: Клик, Ввод символов, Нажатие клавиши Tab)
[@testing-library/user-event]
│
▼ (Генерация полного каскада браузерных событий: pointer, focus, mouse, key)
[Виртуальное DOM-окружение: happy-dom / jsdom]
│
▼ (Запуск рендера и планирование микротасок)
[React 19 Concurrent Scheduler (act / async transitions)]
│
▼ (Сетевые запросы перехватываются на уровне сокета без выхода в реальную сеть)
[Mock Service Worker (MSW v2) Network Layer]
│
▼ (Проверка состояния через семантические ARIA-селекторы)
[vitest / @testing-library/jest-dom matchers]Инициализируйте userEvent.setup(), сконфигурируйте сетевые моки MSW и смонтируйте компонент через render().
Используя getByRole и getByLabelText, убедитесь в корректности доступности элементов (дисейбл кнопок, пустые поля).
Воспроизведите реалистичные действия: user.type(), user.click(), user.tab() с генерацией полных цепочек событий.
Дождитесь обновления интерфейса и исчезновения спиннеров через findByRole, findByText или waitForElementToBeRemoved.
Удостоверьтесь в корректности вызова переданных коллбэков, сбросе значений форм или изменении адресной строки.
Какой селектор React Testing Library является наиболее приоритетным и устойчивым к рефакторингу при тестировании кнопки отправки формы?
useActionState), блокировки кнопок, валидации полей и отображения оптимистичных обновлений (useOptimistic).Tab.AbortController.userEvent.setup() инициализирует генератор событий, MSW монтирует сетевые интерцепторы.
Монтирование дерева в happy-dom с провайдерами контекста (QueryClient, Theme).
user.type() заполняет поля с посимвольной генерацией keydown/input/keyup.
Запрос перехватывается на уровне сокета Node.js и возвращает детерминированный JSON.
Ожидание появления нотификации об успехе через findByRole('alert').
Для корректной работы React 19 настраивается глобальный файл инициализации окружения (setupTests.ts), расширяющий матчеры expect:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom', // или 'jsdom'
setupFiles: ['./src/test/setupTests.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';
// Запуск Mock Service Worker перед всеми тестами сьюта
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Сброс динамических моков и очистка виртуального DOM после каждого теста
afterEach(() => {
cleanup();
server.resetHandlers();
});
// Остановка сетевого сервера после завершения сьюта
afterAll(() => server.close());user-event вытеснил fireEvent#Метод fireEvent.click(button) просто отправляет синтетическое событие 'click'. В реальном браузере пользователь не может сделать клик без предшествующих событий:
pointerover $\to$ pointerenter $\to$ mousemovepointerdown $\to$ mousedownfocusin, focus)pointerup $\to$ mouseup $\to$ clickБиблиотека @testing-library/user-event воспроизводит эту полную цепочку, запуская валидацию фокуса, обработчики блюра и всплытие событий:
// ❌ Устаревший подход: синтетический клик без браузерных циклов
fireEvent.click(screen.getByRole('button', { name: /отправить/i }));
// ✅ Современный стандарт: полная цепочка физических взаимодействий
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /отправить/i }));Почему в тестах React Testing Library мокирование через Mock Service Worker (MSW v2) предпочтительнее прямого переопределения global.fetch = vi.fn()?
Ниже представлен полноценный production-тест формы смены пароля, покрывающий валидацию, взаимодействие с пользователем, сетевую ошибку 422 и успешный сабмит:
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { ChangePasswordForm } from '../ui/ChangePasswordForm';
describe('<ChangePasswordForm />', () => {
it('рендерит форму в начальном доступном состоянии', () => {
render(<ChangePasswordForm />);
expect(screen.getByRole('heading', { level: 2, name: /смена пароля/i })).toBeInTheDocument();
expect(screen.getByLabelText(/текущий пароль/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^новый пароль/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /сохранить пароль/i })).toBeEnabled();
});
it('отображает ошибки клиентской валидации при сабмите пустых полей', async () => {
const user = userEvent.setup();
render(<ChangePasswordForm />);
const submitBtn = screen.getByRole('button', { name: /сохранить пароль/i });
await user.click(submitBtn);
// findByText ожидает появления ошибки в DOM в цикле микротасок
const currentPassError = await screen.findByText(/введите текущий пароль/i);
const newPassError = screen.getByText(/пароль должен содержать минимум 8 символов/i);
expect(currentPassError).toBeInTheDocument();
expect(newPassError).toBeInTheDocument();
});
it('обрабатывает ошибку сервера 422 (неверный текущий пароль)', async () => {
// Локальное переопределение обработчика MSW для проверки негативного сценария
server.use(
http.post('/api/auth/change-password', () => {
return HttpResponse.json(
{ message: 'Действующий пароль указан неверно' },
{ status: 422 }
);
})
);
const user = userEvent.setup();
render(<ChangePasswordForm />);
await user.type(screen.getByLabelText(/текущий пароль/i), 'WrongOldPassword123!');
await user.type(screen.getByLabelText(/^новый пароль/i), 'SuperSecurePass2026!');
await user.click(screen.getByRole('button', { name: /сохранить пароль/i }));
// Кнопка переходит в состояние disabled на время отправки
expect(screen.getByRole('button', { name: /сохранение\.\.\./i })).toBeDisabled();
// Проверяем появление баннера ошибки со статусом alert
const serverAlert = await screen.findByRole('alert');
expect(serverAlert).toHaveTextContent(/действующий пароль указан неверно/i);
});
it('успешно сохраняет новый пароль и очищает форму', async () => {
server.use(
http.post('/api/auth/change-password', async ({ request }) => {
const body = (await request.json()) as { oldPass: string; newPass: string };
if (body.newPass === 'SuperSecurePass2026!') {
return HttpResponse.json({ success: true }, { status: 200 });
}
return HttpResponse.json({ success: false }, { status: 400 });
})
);
const user = userEvent.setup();
render(<ChangePasswordForm />);
const oldInput = screen.getByLabelText(/текущий пароль/i);
const newInput = screen.getByLabelText(/^новый пароль/i);
await user.type(oldInput, 'ValidOldPass123!');
await user.type(newInput, 'SuperSecurePass2026!');
await user.click(screen.getByRole('button', { name: /сохранить пароль/i }));
// Ожидаем появление нотификации об успехе
const successNotice = await screen.findByText(/пароль успешно изменен/i);
expect(successNotice).toBeInTheDocument();
// Поля ввода должны быть очищены
expect(oldInput).toHaveValue('');
expect(newInput).toHaveValue('');
});
});Выборка элементов по CSS-селекторам (container.querySelector('.btn-primary')) намертво связывает тест с оформлением и версткой.
// ❌ Антипаттерн: хрупкий селектор, не проверяющий доступность (a11y)
const btn = container.querySelector('.btn-primary');
expect(btn).toBeTruthy();
// ✅ Правильное решение: селектор по семантической роли и доступному имени
const btn = screen.getByRole('button', { name: /отправить/i });
expect(btn).toBeInTheDocument();В RTL методы render, userEvent и waitFor уже обернуты во встроенный вызов act(...). Ручные вызовы act() засоряют код и скрывают нерешенные асинхронные сайд-эффекты.
// ❌ Антипаттерн: избыточный ручной act
await act(async () => {
render(<UserProfile />);
});
// ✅ Правильное решение: RTL автоматически обрабатывает очереди микротасок
render(<UserProfile />);
const profileName = await screen.findByRole('heading', { name: /алексей/i });Использование waitFor(() => expect(syncValue).toBe(true)) при наличии мгновенно доступных элементов создает паразитные тайм-ауты и замедляет выполнение сьюта.
// ❌ Антипаттерн: waitFor для элемента, который уже отрендерен синхронно
await waitFor(() => {
expect(screen.getByText(/заголовок/i)).toBeInTheDocument();
});
// ✅ Правильное решение: прямой синхронный вызов getByText
expect(screen.getByText(/заголовок/i)).toBeInTheDocument();
// Для асинхронных элементов используйте метод findBy:
const asyncItem = await screen.findByText(/динамический элемент/i);