Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Тестирование React-приложений
testing

Тестирование React-приложений

Vitest, Testing Library, user-event, MSW, Playwright, тестовые границы и устойчивые проверки поведения.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Тестирование React-приложений

Тест должен доказывать наблюдаемое поведение и контракт границы, а не консервировать внутреннее устройство компонента.

#Результат урока

Вы построите быстрые компонентные и интеграционные тесты, перехватите сеть на границе протокола и оставите сквозные тесты для критических пользовательских путей.

Понадобятся API компонентов, асинхронные состояния, маршрутизация и клиенты данных. Итог урока — набор тестов формы и маршрута с MSW и один критический сценарий Playwright.

#1. Пирамида и границы

Чистые функции проверяют модульными тестами, компоненты и их взаимодействие — через DOM и пользовательский сценарий, а сквозные тесты (end-to-end) — через всю систему в браузере. Чем выше уровень, тем дороже и шире сигнал. Большинство логики интерфейса надёжно покрывается интеграционными тестами с перехватом сети, а не тысячей поверхностных тестов.

Проверьте себя. Классифицируйте тесты проекта и удалите дубли.

Частая ошибка. Проверять каждую функцию только через медленный сквозной тест.

#2. Поиск элементов по доступной семантике

Ищите элементы так, как их находит пользователь: по роли и доступному имени, подписи поля или видимому тексту. Это одновременно проверяет часть контракта доступности. Классы 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).

#3. 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.

#4. Сеть через MSW

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 или тело запроса.

#5. Асинхронные проверки без задержек по времени

Ждите наблюдаемое условие через 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) ожиданием появившегося статуса.

Частая ошибка. Увеличивать задержку до случайного исчезновения нестабильности.

#6. Интеграционный тест маршрутизатора

Отрисуйте конфигурацию маршрутов в маршрутизаторе памяти (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 дала бы зелёный тест при сломанном пути маршрута, неверном разборе параметра и неработающей границе ошибок — то есть проверяла бы всё, кроме маршрутизации.

Проверьте себя. Откройте прямую ссылку на детали, выполните действие и вернитесь назад.

Частая ошибка. Подменять хуки маршрутизатора так, что дерево маршрутов не участвует.

#7. Критические сквозные сценарии и изоляция

Сквозной тест проверяет систему как пользователь: настоящий браузер, близкое к рабочему приложение и контролируемые серверные данные. Каждый тест создаёт независимые данные и не зависит от порядка. Ищите элементы по роли и используйте ожидания 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 подготовки данных и измените её через интерфейс.

Частая ошибка. Один длинный сквозной тест на весь продукт с общим аккаунтом и зависимостью от порядка.

#8. Контракт теста и рефакторинг

Тест должен падать при продуктовой регрессии и проходить после внутреннего рефакторинга. Проверка порядка хуков, имён компонентов, внутреннего состояния и полного снимка 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 и ловят регрессию поведения.

#Источники для сверки

  • Vitest guide
  • Testing Library guiding principles
  • MSW docs
  • Playwright best practices

Устранение неисправностей

Далее: Доступные интерфейсы