Раздел 7 · Браузер и Frontend

Тестирование React: RTL, Vitest и MSW

middle~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Тестирование 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.