Тестирование React: RTL, Vitest и MSW
К этому уроку Echo собран из компонентов, оживлён хуками и защищён границей ошибок. Дальше его придётся менять: добавлять стейт-менеджер, переписывать загрузку, рефакторить по слоям. Каждое изменение это риск сломать то, что вчера работало. Тесты это страховка, которая ловит регрессию до того, как её увидит пользователь. Но тесты бывают полезные и бесполезные, и разница не в количестве, а в том, что они проверяют. Этот урок про то, как писать тесты, которые ловят настоящие баги и не ломаются от каждого переименования переменной.
1. Тестируй сценарий, а не компонент
Самая частая ошибка новичка это тест, который проверяет, что компонент
отрисовался. Такой тест зелёный, пока строка кода существует, и ничего не
доказывает про поведение. Тест MessageInput отрисовался пройдёт, даже если
кнопка не работает, валидация отвалилась, а submit перезагружает страницу.
Сравни две формулировки:
it('MessageInput renders correctly', () => { /* проверили, что есть в DOM */ });
it('пользователь может отправить сообщение', () => { /* проверили поведение */ });
Второй тест описывает то, что делает живой человек, и ломается только когда ломается само поведение. Первый ломается от любого рефактора разметки и при этом пропускает баги. Правило простое: формулируй тест от лица пользователя, не от лица компонента.
Из этого правила растёт и форма всей тестовой стратегии. Классическая пирамида тестов советует строить много мелких unit-тестов. На фронте это часто промах: unit-тест одной функции проверяет внутренности и рвётся при рефакторе. Кент Доддс предложил трофей тестов: основной вес на интеграционных тестах, потому что они ловят реальные баги дёшево.
╱ E2E ╲ мало: критичные пути целиком (Playwright, урок 17)
╱────────╲
╱ визуал ╲ Storybook плюс скриншот-diff (урок 17)
╱─────────────╲
│ интеграция │ больше всего: user flow в реальном DOM (RTL)
├───────────────┤
│ unit │ чистые функции и хуки
├───────────────┤
│ static (TS) │ компилятор и линтер ловят даром
└───────────────┘
Static-слой ты уже закрыл: TypeScript и ESLint ловят опечатки и кривые типы
бесплатно, ещё до запуска. Unit нужен для чистой логики (форматтеры,
редьюсеры, кастомные хуки вроде useDebouncedValue из урока 06). А вот
середину, которую мы и разберём, держат интеграционные тесты на React Testing
Library.
Что взять с собой
- Тест проверяет поведение пользователя, не наличие компонента в DOM.
- Имя теста начинай с того, что делает человек, не с имени компонента.
- На фронте основной вес тестов в интеграционном слое, не в unit.
2. React Testing Library: рендер и поиск
React Testing Library
рендерит компонент и даёт три инструмента: render монтирует дерево в DOM,
screen это вход для запросов по всему документу, userEvent симулирует
действия пользователя. Минимальный тест выглядит так:
import { render, screen } from '@testing-library/react';
import { MessageInput } from './MessageInput';
it('показывает поле ввода и кнопку отправки', () => {
render(<MessageInput onSend={() => {}} />);
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Отправить' })).toBeInTheDocument();
});
Запросы к DOM RTL предлагает выбирать в строгом порядке, от самого близкого к пользователю до самого хрупкого. Это приоритет запросов:
getByRole('button', { name: ... })это первый выбор. Роль это то, как элемент видит скринридер, поэтому такой запрос заодно проверяет доступность. Кнопка, поле, заголовок, ссылка, список: у всех есть роль;getByLabelText('Сообщение')для полей формы, привязанных к подписи;getByText('Отправить')иgetByPlaceholderText(...)для видимого текста;getByTestId('composer')это последнее средство, когда узел нельзя достать иначе. Каждыйdata-testidэто признак, что разметке не хватает семантики.
Заметь побочный эффект: чтобы тест мог найти поле по роли с именем, у поля
должна быть подпись. То есть запрос getByRole('textbox', { name: 'Сообщение' })
заставит тебя добавить aria-label или <label>, и приложение станет
доступнее. Хороший тест тянет за собой хороший интерфейс.
У запросов три семейства, и разница между ними важна:
| Префикс | Нет элемента | Есть один | Несколько | Когда брать |
|---|---|---|---|---|
getBy | бросает ошибку | вернёт | бросает | элемент должен быть сейчас |
queryBy | вернёт null | вернёт | бросает | проверить, что элемента нет |
findBy | ждёт, потом ошибка | вернёт | бросает | элемент появится после асинхронной работы |
queryBy нужен ровно для проверки отсутствия (expect(...).not.toBeInTheDocument()),
потому что getBy в этом случае упал бы с ошибкой раньше проверки. findBy
возвращает промис и сам ждёт появления узла: им проверяют то, что приходит
после загрузки.
Что взять с собой
renderмонтирует,screenищет,userEventдействует.- Ищи по приоритету: роль, потом подпись, потом текст,
testIdв последнюю очередь. getByдля того, что есть сейчас,queryByдля проверки отсутствия,findByдля появления после асинхронщины.
3. userEvent против fireEvent
Кликнуть и напечатать можно двумя способами, и выбор не косметический.
fireEvent
бьёт по DOM одним событием. userEvent имитирует живого человека: на каждый
символ идёт цепочка keydown, keypress, input, keyup, фокус двигается как у
настоящего пользователя, а клик по disabled-кнопке честно ничего не делает.
import userEvent from '@testing-library/user-event';
it('пользователь печатает и отправляет сообщение', async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<MessageInput onSend={onSend} />);
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'привет');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
expect(onSend).toHaveBeenCalledWith('привет');
});
Каждый вызов user это промис, поэтому await. По умолчанию бери userEvent:
он ближе к реальности. fireEvent оставь для редких случаев, когда нужно
выстрелить событием, которого userEvent не умеет (например, scroll или
кастомное событие).
Что взять с собой
userEventимитирует живого пользователя,fireEventшлёт сырое событие.- По умолчанию
userEvent, и не забывайawaitна каждом действии.
4. Окружение: Vitest и jsdom
Тесты запускает Vitest: тот же Vite под капотом, значит те же алиасы и плагины, что у приложения, и быстрый watch. Браузера в тестах нет, его заменяет jsdom: DOM на чистом JS внутри Node. Конфиг минимальный:
// apps/echo-react-rtk/vitest.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true, // describe, it, expect без импорта
setupFiles: ['./vitest.setup.ts'],
},
});
В setup-файле подключаем матчеры jest-dom (toBeInTheDocument,
toBeDisabled, toHaveValue) и поднимаем MSW (про него следующий раздел):
// apps/echo-react-rtk/vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // сбрасываем переопределения после каждого теста
afterAll(() => server.close());
onUnhandledRequest: 'error' это важная строчка: если тест случайно ходит в
сеть мимо моков, он сразу падает, а не висит. resetHandlers после каждого
теста возвращает моки к базовым, чтобы тесты не протекали друг в друга.
Что взять с собой
- Vitest делит конфиг с Vite, jsdom даёт DOM без браузера.
- jsdom не умеет реальный layout и отрисовку, для этого есть E2E.
globals: trueубирает импортыdescribe/it/expect.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.