Vitest, Testing Library, user-event, MSW, Playwright, тестовые границы и устойчивые проверки поведения.
Тест должен доказывать наблюдаемое поведение и контракт границы, а не консервировать внутреннее устройство компонента.
Вы построите быстрые компонентные и интеграционные тесты, перехватите сеть на границе протокола и оставите сквозные тесты для критических пользовательских путей.
Понадобятся API компонентов, асинхронные состояния, маршрутизация и клиенты данных. Итог урока — набор тестов формы и маршрута с MSW и один критический сценарий Playwright.
Чистые функции проверяют модульными тестами, компоненты и их взаимодействие — через DOM и пользовательский сценарий, а сквозные тесты (end-to-end) — через всю систему в браузере. Чем выше уровень, тем дороже и шире сигнал. Большинство логики интерфейса надёжно покрывается интеграционными тестами с перехватом сети, а не тысячей поверхностных тестов.
Проверьте себя. Классифицируйте тесты проекта и удалите дубли.
Частая ошибка. Проверять каждую функцию только через медленный сквозной тест.
Ищите элементы так, как их находит пользователь: по роли и доступному имени, подписи поля или видимому тексту. Это одновременно проверяет часть контракта доступности. Классы CSS и экземпляр компонента — детали реализации. getBy нужен для уже существующего элемента, findBy — для асинхронного появления, queryBy — для проверки отсутствия.
Один и тот же тест можно написать через реализацию и через семантику. Первый вариант падает от смены класса, второй — только от смены поведения:
// ❌ тест знает про вёрстку
const button = container.querySelector('.btn.btn--primary')!;
// ✅ тест знает про контракт: роль и доступное имя
const button = screen.getByRole('button', { name: 'Сохранить заявку' });
const title = screen.getByLabelText('Заголовок');Второй вариант заодно доказывает, что у кнопки есть доступное имя, а у поля — связанная подпись: если разработчик уберёт <label>, тест упадёт, и это правильное падение.
Три семейства запросов отвечают на три разных вопроса, и путать их нельзя:
screen.getByRole('table'); // должен быть здесь сейчас
await screen.findByText('Заявка сохранена'); // появится асинхронно
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // должен отсутствоватьgetBy для отсутствующего элемента выбрасывает ошибку, поэтому проверить отсутствие им нельзя — нужен queryBy, возвращающий null.
Проверьте себя. Найдите кнопку и поле по роли и имени без testid.
Частая ошибка. Выбирать .primary-button:nth-child(2).
user-event вместо прямой диспетчеризацииuser-event моделирует последовательность событий фокуса, ввода и клавиатуры ближе к браузеру и обычно работает асинхронно. fireEvent полезен для одиночного низкоуровневого события, но с ним легко создать невозможный сценарий. Создавайте пользователя для каждого теста и ожидайте завершения взаимодействий.
Присваивание input.value не проходит через фокус, keydown и beforeinput — то есть проверяет сценарий, который в браузере не случается:
// ❌ невозможный сценарий: значение появилось без ввода
fireEvent.change(input, { target: { value: 'Сбой оплаты' } });
// ✅ настоящая последовательность событий
const user = userEvent.setup();
await user.click(input);
await user.type(input, 'Сбой оплаты');
await user.clear(input);
await user.type(input, 'Сбой оплаты{Enter}');await обязателен: user-event продвигает микрозадачи и даёт React завершить обновления. Без него следующая проверка выполнится до перерисовки и тест начнёт падать случайным образом.
Проверьте себя. Введите текст, очистите поле и отправьте форму клавишей Enter через тестового пользователя.
Частая ошибка. Присвоить input.value напрямую и вызвать одно событие change.
MSW перехватывает запрос на сетевой границе, сохраняя настоящую сериализацию клиента, разбор ответа и жизненный цикл компонента. Обработчик задаёт успех, ошибку или задержку и проверяет протокол. Не подменяйте пользовательский хук, если интеграционный тест должен проверить его связь с интерфейсом.
Обработчики описывают протокол, а не готовые данные компонента, поэтому неверный адрес или тело запроса будут замечены:
// src/test/handlers.ts
export const handlers = [
http.get('/api/tickets', () => HttpResponse.json({ items: [] })),
http.post('/api/tickets', async ({ request }) => {
const body = (await request.json()) as { title?: string };
if (!body.title) return HttpResponse.json({ error: 'title required' }, { status: 422 });
return HttpResponse.json({ id: 7, title: body.title }, { status: 201 });
}),
];Отдельные исходы подключают точечно внутри теста — так основной набор обработчиков остаётся описанием «нормальной» системы:
// проверка задержки и сбоя без изменения общего набора
server.use(
http.get('/api/tickets', async () => {
await delay(300);
return HttpResponse.error();
}),
);Если вместо MSW подменить хук useTickets, тест перестанет замечать опечатку в URL, неверный метод и невалидное тело — то есть самые частые дефекты сетевого слоя.
Проверьте себя. Задайте обработчики для задержанного ответа 200, ошибки 500 и неверных данных.
Частая ошибка. Подменить готовые данные прямо в хуке и пропустить неправильный URL или тело запроса.
Ждите наблюдаемое условие через findBy или waitFor, а не фиксированное время. Обратный вызов waitFor должен выбрасывать ошибку проверки до успеха. Управляемые таймеры применяйте для собственной логики времени, а обновления React выполняйте через act. Произвольная задержка делает тест медленным и нестабильным.
Фиксированная пауза одновременно замедляет быструю машину и не спасает медленную:
// ❌ 1000 мс всегда и всё равно нестабильно
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(screen.getByText('Сохранено')).toBeInTheDocument();
// ✅ ждём ровно до появления наблюдаемого результата
expect(await screen.findByText('Сохранено')).toBeInTheDocument();waitFor нужен там, где условие не сводится к появлению элемента, и его обратный вызов обязан выбрасывать ошибку до успеха:
await waitFor(() => {
expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false');
});Проверка вида waitFor(() => { if (!ready) return; }) бесполезна: без выброшенной ошибки первая же попытка считается успешной.
Проверьте себя. Замените setTimeout(1000) ожиданием появившегося статуса.
Частая ошибка. Увеличивать задержку до случайного исчезновения нестабильности.
Отрисуйте конфигурацию маршрутов в маршрутизаторе памяти (memory router) с начальными адресами, загрузчиками, действиями и границами ошибок. Проверяйте навигацию и URL, а не подменяйте useNavigate. Так тест охватывает параметры, формы, повторную загрузку и ошибки. Серверные границы фреймворка могут потребовать более высокого уровня проверки.
Настоящее дерево маршрутов в тесте выглядит почти как в приложении — с загрузчиком, действием и начальным адресом:
// src/routes/tickets/detail.test.tsx
const router = createMemoryRouter(
[
{
path: '/tickets/:ticketId',
loader: detailLoader,
action: detailAction,
element: <TicketDetail />,
ErrorBoundary: DetailErrorBoundary,
},
],
{ initialEntries: ['/tickets/42'] },
);
render(<RouterProvider router={router} />);
expect(await screen.findByRole('heading', { name: /Заявка 42/ })).toBeInTheDocument();Подмена useNavigate дала бы зелёный тест при сломанном пути маршрута, неверном разборе параметра и неработающей границе ошибок — то есть проверяла бы всё, кроме маршрутизации.
Проверьте себя. Откройте прямую ссылку на детали, выполните действие и вернитесь назад.
Частая ошибка. Подменять хуки маршрутизатора так, что дерево маршрутов не участвует.
Сквозной тест проверяет систему как пользователь: настоящий браузер, близкое к рабочему приложение и контролируемые серверные данные. Каждый тест создаёт независимые данные и не зависит от порядка. Ищите элементы по роли и используйте ожидания Playwright; объект страницы (page object) скрывает механику, но не должен превращаться во «всемогущий» класс.
Изоляция достигается созданием собственных данных на подготовительном шаге, а не выбором «какой-нибудь» существующей записи:
// e2e/ticket-edit.spec.ts
test('оператор закрывает свою заявку', async ({ page, request }) => {
const created = await request.post('/api/test/tickets', {
data: { title: `Заявка ${crypto.randomUUID()}` },
});
const { id, title } = await created.json();
await page.goto(`/tickets/${id}`);
await page.getByRole('button', { name: 'Закрыть заявку' }).click();
await expect(page.getByRole('status')).toHaveText('Заявка закрыта');
await expect(page.getByRole('heading', { name: title })).toBeVisible();
});Уникальный заголовок и собственная запись позволяют запускать тест параллельно с другими и в любом порядке. Общий аккаунт и «первая строка таблицы» дают тесты, которые падают от чужого прогона.
Проверьте себя. Создайте уникальную заявку через API подготовки данных и измените её через интерфейс.
Частая ошибка. Один длинный сквозной тест на весь продукт с общим аккаунтом и зависимостью от порядка.
Тест должен падать при продуктовой регрессии и проходить после внутреннего рефакторинга. Проверка порядка хуков, имён компонентов, внутреннего состояния и полного снимка DOM обычно слишком связана с реализацией. Намеренная поломка помогает убедиться, что проверка действительно замечает дефект.
Проверьте два теста одного компонента мысленным экспериментом «заменим useReducer на useState»:
// ❌ упадёт при рефакторинге, хотя поведение не изменилось
expect(reducerSpy).toHaveBeenCalledWith({ type: 'close' });
// ✅ упадёт только если сломалось наблюдаемое поведение
await user.click(screen.getByRole('button', { name: 'Закрыть заявку' }));
expect(await screen.findByText('Заявка закрыта')).toBeInTheDocument();Полный снимок DOM ведёт себя так же плохо: он падает от любой перестановки разметки и обычно обновляется вслепую, после чего перестаёт защищать вообще. Проверяйте наблюдаемый результат — текст, роль, состояние — и намеренно ломайте продуктовый код, чтобы убедиться, что тест это замечает.
Проверьте себя. Поменяйте редьюсер на useState без изменения поведения и сохраните зелёные тесты.
Частая ошибка. Проверять wrapper.state() или внутренний экземпляр функционального компонента.
Протестируйте панель заявок: поиск по семантике, настоящий user-event, проверку полей, задержанный ответ MSW, ошибку 500 с повтором, навигацию и откат предварительного изменения. Сквозной тест проверяет вход, редактирование и сохранение без селекторов реализации.
Работа готова, когда тесты детерминированы, не ждут фиксированное время, не подменяют внутренности React и ловят регрессию поведения.
Далее: Доступные интерфейсы