React Testing Library (RTL) рендерит компонент в DOM (jsdom в Vitest или Jest) и даёт искать элементы и взаимодействовать с ними, как пользователь: по роли, подписи и тексту. Тестировать поведение значит проверять то, что пользователь видит и может сделать, а не стейт, хуки или внутреннюю структуру.
Что даёт библиотека: render, screen с запросами и @testing-library/user-event для настоящих взаимодействий; @testing-library/jest-dom добавляет матчеры вроде toBeInTheDocument и toBeDisabled.
Чего в ней намеренно нет: доступа к экземплярам компонентов, стейту и shallow-рендера. Enzyme, где всё это было, так и не получил поддержку React 18 и фактически мёртв.
Детали реализации означают то, что рефакторинг может поменять без изменения поведения: имена переменных стейта, какой хук хранит значение, CSS-классы, количество дочерних компонентов.
Почему это важно: тест, привязанный к внутренностям, ломается на безобидном рефакторинге (ложное падение) и при этом проходит, когда видимый UI сломан (ложный успех).
Простое правило: если пользователь не может это увидеть или сделать, это не нужно проверять.
ReactMiddle
Тестирование React
React Testing Library, Vitest и Jest, запросы и user-event, мок сети через MSW, тесты хуков, end-to-end на Playwright
Закрыто вопросов
0 / 21
- 0Повторить
- 9Часто спрашивают
Вопросы
Unit-тест проверяет одну функцию или хук изолированно, интеграционный рендерит дерево компонентов с настоящими детьми и стейтом и подменяет только сеть, а E2E прогоняет всё приложение в браузере. В React основная ценность в интеграционных тестах.
Статические проверки: TypeScript и ESLint бесплатно ловят опечатки и неверные пропсы; это базовый слой.
Unit: чистая логика (форматтеры, редьюсеры, схемы валидации, кастомные хуки через renderHook). Быстро и точно, но ничего не говорит о том, работает ли экран.
Интеграционные: страница или фича, отрендеренная через RTL, с настоящим роутингом и стором, сеть замокана через MSW. Близко к тому, что делает пользователь, и всё ещё выполняется за миллисекунды в jsdom.
End-to-end: Playwright против запущенной сборки: вход, оплата, сценарии, поломка которых стоит денег. Медленные и более хрупкие, поэтому их мало.
Такую форму (статика, unit, толстый слой интеграционных, тонкий E2E) часто называют testing trophy, в отличие от классической пирамиды с unit-тестами в основании.
Разработка через тестирование (test-driven development) строится на коротком цикле: пишете тест на следующий кусок поведения и видите, что он падает (red), пишете минимум кода, чтобы он прошёл (green), затем чистите код, опираясь на тест как на страховку (refactor).
Окупается, когда ожидаемое поведение понятно заранее: чистая логика (форматирование цен, валидация, редьюсеры, работа с датами), кастомные хуки, маппинг ответа API.
Исправление багов: напишите падающий тест, который воспроизводит баг, затем исправьте его. Тест доказывает исправление и не даёт багу вернуться; эту привычку стоит сохранить даже без строгого TDD.
Компоненты тоже: RTL ищет элементы по роли и label, поэтому тест вроде «кнопка неактивна, пока email невалиден» можно написать до того, как появится компонент.
Окупается меньше для прототипов и UI, который ещё проектируется, где тесты пришлось бы переписывать каждый день, и для визуала, который DOM-тест не видит.
Для проекта на Vite выбирают Vitest: он переиспользует конфиг и трансформации Vite, запускает ESM и TypeScript без Babel и сохраняет совместимый с Jest API (describe, it, expect, vi.fn вместо jest.fn). Jest уместен там, где он уже работает, например в React Native или большом legacy-наборе тестов.
Настройка: environment: 'jsdom' (или happy-dom, быстрее, но менее полный), setup-файл с импортом @testing-library/jest-dom/vitest и globals: true, чтобы работала автоматическая очистка RTL после каждого теста.
Миграция с Jest сводится в основном к замене jest.* на vi.*; главные ловушки: правила подъёма (hoisting) vi.mock и фейковые таймеры.
Browser Mode (стабилен с Vitest 4) запускает те же тесты в настоящем Chromium через Playwright, для кода, который jsdom плохо имитирует (layout, IntersectionObserver, canvas).
create-react-app, в котором Jest был настроен из коробки, объявлен устаревшим; новые проекты начинают с Vite или фреймворка.
getBy возвращает элемент или бросает ошибку, если его нет, queryBy возвращает null, если элемента нет, а findBy возвращает промис, который повторяет поиск, пока элемент не появится или не истечёт таймаут (по умолчанию 1000 мс).
**getBy: вариант по умолчанию для всего, что должно быть на экране прямо сейчас; брошенная ошибка печатает текущий DOM, и это лучший отладочный вывод, который у вас есть.
queryBy**: только для проверки отсутствия, expect(screen.queryByText('Error')).not.toBeInTheDocument().
**findBy**: для элементов, которые появляются после чего-то асинхронного (запроса, lazy-компонента, debounce). Это getBy, обёрнутый в waitFor, поэтому его обязательно нужно await.
Все три бросают ошибку, если нашли больше одного совпадения; варианты *AllBy (getAllBy, queryAllBy, findAllBy) возвращают массивы.
Начинайте с getByRole плюс доступное имя, затем getByLabelText для полей формы, затем getByText; getByTestId нужен для элементов, которые пользователь никак иначе не опознает. Запросы по роли и подписи проверяют то, что видят вспомогательные технологии, так что проходящий тест заодно доказывает доступность разметки.
Приоритет из документации RTL: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, затем getByAltText, getByTitle и в самом конце getByTestId.
**getByRole('button', { name: /save/i })** упадёт, если «кнопка» на самом деле кликабельный div или иконка без подписи, а это ровно тот баг, который хочется поймать.
Test id переживает любые изменения разметки. Звучит хорошо, но значит, что тест не заметит, когда кнопка потеряет подпись или станет недоступной с клавиатуры.
**Сужайте область через within**, когда на странице один и тот же текст встречается дважды: within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete' }).
Если не уверены в роли, screen.logTestingPlaygroundURL() или расширение Testing Playground подскажут лучший запрос.
getByText бросает ошибку сразу, как только элемента нет, поэтому до проверки дело не доходит: отсутствие нужно проверять через queryByText, который возвращает null и позволяет not.toBeInTheDocument() пройти.
Ошибку бросает запрос, а не expect; по стеку видно, что она летит со строки с getByText.
queryBy* существует ровно для этого случая и только для него; для проверки присутствия getBy* даёт более понятное сообщение о падении.
Если сообщение исчезает асинхронно (после запроса или анимации), используйте await waitForElementToBeRemoved(() => screen.queryByText(...)).
Плагин eslint-plugin-testing-library ловит этот паттерн (prefer-presence-queries), так что баг не доходит до ревью.
fireEvent отправляет одно синтетическое DOM-событие, а userEvent имитирует всё взаимодействие, которое производит браузер: клик состоит из событий pointer, mouse и focus, ввод даёт keydown, input и keyup на каждый символ. Так ловятся баги, которые fireEvent молча пропускает.
Реалистичность: user.click переводит фокус на элемент и отказывается кликать по элементу под pointer-events: none; fireEvent.click только отправляет click, поэтому фокус не меняется и валидация по blur не запускается.
Ввод: user.type меняет значение по одной клавише, двигает фокус и учитывает maxLength; fireEvent.change просто выставляет итоговое значение.
API (v14): создавайте сессию через userEvent.setup() до render и делайте await у каждого вызова, потому что методы асинхронные.
**fireEvent по-прежнему уместен** для событий, которые userEvent не моделирует: scroll, кастомное событие или ended у медиа-элемента.
Дожидайтесь итогового состояния, которое увидит пользователь, через findBy* или await waitFor(...). Предупреждение про act значит, что обновление стейта произошло уже после того, как тест перестал ждать, обычно из-за запроса или таймера, который завершился позже, и тест проверял промежуточный UI.
**act** заставляет React применить все отложенные рендеры и эффекты до следующей строки. RTL уже оборачивает в него render, fireEvent и userEvent, поэтому вызывать act вручную нужно редко.
**Исправление почти никогда не в том, чтобы обернуть больше кода в act**: нужно дождаться того, что вызвало позднее обновление, например await screen.findByText('Saved').
**waitFor повторяет колбэк, пока он не перестанет бросать ошибку (каждые 50 мс, по умолчанию до 1000 мс). Держите внутри одну проверку и никаких побочных эффектов, потому что колбэк может выполниться много раз.
React 19**: act импортируется из react; старый экспорт из react-dom/test-utils объявлен устаревшим.
Тест, который завершился, пока запрос ещё летит, может протечь в следующий тест; MSW плюс дождавшиеся проверки держат каждый тест закрытым.
waitFor возвращает промис, и его никто не ждёт, поэтому тестовая функция завершается сразу после клика и считается пройденной; падающая проверка выполняется позже, когда тест уже закончился.
waitFor, findBy* и waitForElementToBeRemoved асинхронные; без await они с точки зрения теста ничего не делают.
В лучшем случае отклонённый промис всплывёт как необработанная ошибка, не привязанная ни к какому тесту; в худшем раннер уже завершился и никто её не увидит.
Самое простое исправление здесь findByText, он читается лучше, чем waitFor с getByText внутри.
eslint-plugin-testing-library (await-async-utils, await-async-queries) и правило TypeScript @typescript-eslint/no-floating-promises ловят это на этапе линтинга.
MSW (Mock Service Worker) перехватывает запросы на уровне сети и отвечает из ваших обработчиков, поэтому компонент, его библиотека данных и настоящий fetch работают без изменений. Мок fetch или API-модуля подменяет боевой код и привязывает тесты к тому, как сделан запрос.
Один набор обработчиков работает в Vitest (setupServer из msw/node), в Storybook и в браузере при разработке (setupWorker).
Устойчивость к рефакторингу: переход с fetch на axios или с эффекта на TanStack Query не затрагивает тесты.
Переопределения на один тест через server.use(...) покрывают ошибки, пустые списки и медленные ответы; resetHandlers в afterEach их откатывает.
**onUnhandledRequest: 'error'** роняет любой тест, который сделал неожиданный запрос, вместо того чтобы пустить его на настоящий сервер.
MSW v2 использует типы Fetch API: http.get, HttpResponse.json, delay().
Напишите свою функцию рендера, которая оборачивает UI в те же провайдеры, что и приложение, создавая свежие экземпляры для каждого теста, и используйте её вместо голого render из RTL.
**Новый QueryClient на каждый тест: общий клиент хранит кэш между тестами, и тест проходит или падает в зависимости от того, что запускалось до него.
retry: false для запросов, иначе тест на ошибку ждёт три повтора с backoff и падает по таймауту.
Роутер**: оберните в MemoryRouter с initialEntries или постройте настоящее дерево маршрутов через createMemoryRouter, если компонент использует loaders.
**Возвращайте user** из хелпера, чтобы каждый тест получал сессию userEvent без лишнего кода.
Опция render(ui, { wrapper }) в RTL делает то же для одного провайдера; хелпер становится аккуратным решением, когда провайдеров несколько.
Если хук использует один компонент, тестируйте его через этот компонент. Если хук общий, тестируйте напрямую через renderHook из @testing-library/react: он рендерит крошечный компонент-хост и отдаёт последнее возвращённое значение в result.current.
**Оборачивайте обновления в act**: вызов сеттера из хука вне события React требует act(() => result.current.increment()), иначе React выдаст предупреждение, а result.current будет устаревшим.
**Читайте result.current после обновления, а не значение, деструктурированное до него: деструктурированная копия остаётся снимком старого рендера.
Смена аргументов**: передайте initialProps и вызовите rerender(newProps).
Провайдеры: опция wrapper, как и у render.
@testing-library/react-hooks устарел; renderHook входит в RTL начиная с React 18.
vi.fn создаёт отдельную мок-функцию, vi.spyOn оборачивает существующий метод, чтобы следить за ним или подменить его и потом восстановить, а vi.mock подменяет целый модуль для всех импортов в тестовом файле. Мокайте границы, которыми вы не владеете, а не тот код, который тестируете.
Хорошие цели: сеть (лучше через MSW), время (vi.useFakeTimers), случайность, window.matchMedia, SDK аналитики, тяжёлые сторонние виджеты.
Плохие цели: ваши собственные дочерние компоненты, хуки и утилиты. Мокая их, вы тестируете выдумку: тест проходит, хотя настоящие части уже не стыкуются.
**vi.mock поднимается (hoisting)** выше импортов, поэтому его фабрика не может использовать переменные, объявленные в файле; выход из положения даёт vi.hoisted.
Восстанавливайте моки между тестами: restoreMocks: true (или vi.restoreAllMocks() в afterEach) возвращает шпионов на место, и мок одного теста не протекает в следующий.
vi.mocked(fn) даёт замоканной функции мок-тип в TypeScript без приведения типов.
vi.mock поднимается в начало файла, выше строки const fakeGetUser, поэтому когда фабрика выполняется при импорте ./greet, fakeGetUser ещё находится во временной мёртвой зоне (TDZ). Создавайте мок внутри vi.hoisted, который поднимается вместе с vi.mock.
Vitest переписывает файл так, чтобы каждый vi.mock выполнялся до любого импорта; именно поэтому замоканный модуль подменяет настоящий и для ./greet.
В Jest такой же подъём, но там разрешены переменные с именем на mock; в Vitest такого исключения нет, поэтому код, перенесённый с Jest, часто ломается именно так.
Альтернатива: автомок через vi.mock('./api') и значение на каждый тест через vi.mocked(getUser).mockResolvedValue(...).
Переключитесь на фейковые таймеры через vi.useFakeTimers(), скажите userEvent продвигать их (advanceTimers: vi.advanceTimersByTime), а затем явно сдвигайте часы внутри act. Тест выполняется мгновенно и проверяет точный момент срабатывания debounce.
**Зачем advanceTimers**: userEvent ждёт между нажатиями клавиш через setTimeout; с фейковыми таймерами это ожидание никогда не закончится, если библиотека не может сдвинуть часы.
**Зачем act**: сдвиг часов вызывает колбэк таймера, который меняет стейт; act применяет получившийся рендер до проверки.
Возвращайте настоящие таймеры в afterEach, иначе все следующие тесты в файле работают с замороженными часами.
**findBy и waitFor с фейковыми таймерами**: они тоже опрашивают через таймеры, поэтому в Vitest лучше явно сдвигать время и проверять синхронным getBy.
vi.setSystemTime(date) фиксирует Date.now() для кода, который пишет «5 минут назад».
Снапшоты подходят для небольшого стабильного вывода, где любое изменение стоит просмотреть: сериализатор, текст ошибки, лучше всего как toMatchInlineSnapshot. Большие снапшоты компонентов ломаются на каждой правке разметки, обновляются через -u не глядя и перестают что-либо защищать.
Что они проверяют: что вывод не изменился, а не что он правильный. Первый снапшот записывает всё, что выдал код, включая баги.
Большие DOM-снапшоты привязывают тест к каждому div-обёртке и имени класса, то есть ровно к тем деталям реализации, которых RTL старается избегать.
Цена ревью: дифф снапшота на 400 строк в PR одобряют не читая; явная проверка через getByRole показывает намерение.
Для визуальных изменений сравнение скриншотов в Playwright (expect(page).toHaveScreenshot()) или инструмент визуального ревью ловят то, чего не видит DOM-снапшот: CSS.
Покрытие показывает, какие строки, ветки и функции выполнились во время тестов. Оно говорит, что не протестировано, но не говорит, что протестированный код работает правильно: тест, который рендерит компонент и ничего не проверяет, всё равно его покрывает, поэтому цель в 100% поощряет запуск кода, а не его проверку.
Покрытие веток (обе стороны каждого if, ?:, && и ??) говорит больше, чем покрытие строк или функций, которые лишь считают, что выполнилось.
Цель меняет поведение: тесты пишут ради цифры, и появляются тесты из одних снапшотов, тесты тривиальных геттеров, тесты, где замокано всё, и тесты, привязанные к деталям реализации, из-за которых дорожает каждый рефакторинг.
Убывающая отдача: последние 10 или 20% в основном состоят из веток ошибок для невозможных случаев и связующего кода фреймворка, а их покрытие стоит дороже, чем баги, которые оно поймало бы.
Используйте его как карту и нижнюю планку: ищите непротестированные критичные пути (платежи, права доступа, валидация) и задайте порог, который не пропускает регрессии, строже для критичных папок.
Мутационное тестирование (Stryker) меряет сами тесты: переворачивает условия или удаляет вызовы и проверяет, что какой-то тест упал.
Vitest 4 включает в отчёт только файлы, которые импортировали тесты, если не задан coverage.include, поэтому нетронутые модули выпадают из отчёта, и процент выглядит лучше, чем есть.
Playwright управляет настоящими Chromium, Firefox и WebKit из тест-раннера и проверяет собранное приложение так, как это делает пользователь. Используйте его для немногих сценариев, которые никогда не должны ломаться целиком (вход, оплата, онбординг), а варианты оставьте более дешёвым тестам на RTL.
Почему он стабильнее
Автоожидание: действия вроде click ждут, пока элемент окажется в DOM, станет видимым, стабильным и активным, поэтому ручные паузы не нужны.
Web-first проверки: await expect(locator).toBeVisible() повторяется, пока не пройдёт или не истечёт таймаут, а не проверяет один раз.
Локаторы ленивые и находят элемент заново при каждом использовании, поэтому ререндер не оставляет устаревший handle; getByRole и getByLabel повторяют RTL.
Изоляция: каждый тест получает свежий контекст браузера (cookies, storage), тесты выполняются параллельно в воркерах.
Практическая настройка
Авторизация один раз: войдите в setup-проекте и переиспользуйте storageState, вместо того чтобы вводить логин в каждом тесте.
Контроль сети: page.route подменяет сторонние сервисы или нестабильную зависимость; там, где важен контракт, запускайте настоящий бэкенд.
Разбор падений в CI: trace: 'on-first-retry' записывает трейс со снимками DOM, сетью и консолью; открывается через npx playwright show-trace.
Повторы только в CI (retries: 2), и тест, которому они нужны, остаётся багом, который надо чинить, а не успехом.
Разделите работу по слоям: Server Actions и функции данных тестируйте как обычные async-функции в Vitest, Client Components через RTL, передавая им пропсы и фейковый action, а собранную страницу, где работают async-компоненты и протокол RSC, покрывайте Playwright на продакшен-сборке.
Почему RTL останавливается на странице
RTL рендерит клиентским рендерером, который не поддерживает async-компоненты: React сообщает, что компонент «is an async Client Component», и тест видит только fallback от Suspense. Сам Next.js рекомендует для async Server Components end-to-end тесты.
Синхронный Server Component, который только принимает пропсы, остаётся обычной функцией и рендерится без проблем.
render(await Page({ params: Promise.resolve({ id: '1' }) })) вызывает компонент напрямую и рендерит то, что он вернул. Это работает на один уровень вглубь и ломается, как только дочерний компонент тоже оказывается async или код вызывает cookies() или headers() вне запроса.
Server Actions
Это экспортированные async-функции: соберите FormData, вызовите action, проверьте возвращённое состояние и то, что изменилось в базе.
Мокайте только границы фреймворка: next/cache ради revalidatePath, свой хелпер сессии ради auth() и server-only, который бросает ошибку при импорте вне серверной сборки. redirect() и notFound() работают через исключение, поэтому мокайте next/navigation или ожидайте отклонённый промис.
Тестируйте отказы, гостя и пользователя, которому запись не принадлежит, ведь каждый action является публичным эндпоинтом.
Настоящая тестовая база (контейнер или SQLite) ловит неверные запросы; замоканный ORM доказывает только то, что вы его вызвали.
End to end
Playwright на next build и next start, а не на next dev, который рендерит и кэширует иначе.
Покрывайте то, что видно только в собранном приложении: стриминговые секции появляются, форма отправляется и страница показывает новые данные после ревалидации, гостя перенаправляет на вход.
Случайные падения почти всегда вызваны общим состоянием, реальным временем или асинхронностью без await, а медленность лишним рендерингом в каждом тесте. Сначала воспроизведите (отдельно, в перемешанном порядке, в цикле), затем уберите общую зависимость, а не добавляйте повторы и таймауты.
Типичные причины нестабильных тестов
Состояние протекает между тестами: стор на уровне модуля, общий QueryClient, localStorage, невосстановленные моки. Помогают свежие экземпляры на каждый тест плюс restoreMocks.
**Асинхронность без await**: пропущенный await у findBy или user.*, проверка до завершения запроса. Большую часть ловят правила eslint-plugin-testing-library.
Реальное время: debounce, анимации и Date.now(). Фейковые таймеры и vi.setSystemTime делают их детерминированными.
Реальная сеть: незамоканный запрос, который иногда проходит. MSW с onUnhandledRequest: 'error' превращает его в понятное падение.
Зависимость от порядка: запускайте с --sequence.shuffle, чтобы её выявить.
Как ускорить
Сначала профилируйте: vitest --reporter=verbose печатает длительность каждого теста и показывает, куда уходит время.
Тестируйте логику ниже компонента: редьюсеру или Zod-схеме DOM не нужен; рендерите только то, что нужно для поведения.
**retry: false** в тестовых клиентах запросов и короткие ответы MSW вместо delay().
Окружение: happy-dom может быть быстрее jsdom на простых деревьях; оставляйте isolate включённым, пока не доказали, что набор тестов ничего не протекает.
CI: шардируйте по машинам (vitest --shard=1/4, --shard в Playwright) и кэшируйте зависимости.
Нестабильный тест помещайте в карантин с тикетом, а не позволяйте команде привыкнуть перезапускать CI, пока не станет зелёным.