← назад к разделу

Frontend-тесты часто ломаются не потому, что что-то сломалось в приложении, а потому, что тест был написан не про то. Тест, привязанный к именам CSS-классов или порядку тегов, краснеет при каждом рефакторинге вёрстки — даже когда поведение для пользователя не изменилось.

Главный принцип: тестировать то, что видит пользователь, а не то, как это устроено внутри.

Инструменты: что и зачем

Два инструмента покрывают большинство задач:

  • Vitest — раннер тестов. Быстрый, встроен в экосистему Vite, API совместим с Jest. Запускает .test.ts-файлы, умеет в моки и снапшоты.
  • Testing Library (@testing-library/react) — помогает тестировать компоненты так, как их использует пользователь: рендерит компонент и даёт обращаться к элементам по роли, тексту, метке.

Устанавливаются вместе:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom

В vite.config.ts добавить:

test: {
  environment: "jsdom",
  globals: true,
}

Первый тест компонента

Допустим, есть карточка товара с кнопкой «Открыть»:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("показывает товар и реагирует на клик", async () => {
  const onOpen = vi.fn();
  render(<ProductCard id="1" name="Кофемолка" price={4990} onOpen={onOpen} />);

  expect(screen.getByText("Кофемолка")).toBeInTheDocument();
  await userEvent.click(screen.getByRole("button", { name: "Открыть" }));
  expect(onOpen).toHaveBeenCalledWith("1");
});

Что здесь происходит: render отрисовывает компонент в виртуальный DOM, screen даёт доступ к элементам, userEvent имитирует реальные действия (клик, ввод текста). vi.fn() создаёт фиктивную функцию, чтобы проверить, что её вызвали с нужными аргументами.

Искать элементы по роли, а не по классу

Testing Library намеренно делает неудобным поиск по классам и идентификаторам. Рекомендованный путь — getByRole, getByLabelText, getByText.

// хрупко: тест сломается при переименовании класса
container.querySelector(".btn-primary");

// устойчиво: тест описывает то, что видит пользователь
screen.getByRole("button", { name: "Сохранить" });

Поиск по роли — это стандарт доступности (ARIA). Элементы имеют роли: кнопка — button, ссылка — link, поле ввода — textbox, заголовок — heading. Если нужный элемент нельзя найти по роли — часто это сигнал, что разметка недостаточно доступна.

Иерархия запросов по предпочтительности:

  1. getByRole — приоритет всегда
  2. getByLabelText — для полей формы
  3. getByPlaceholderText — если метки нет
  4. getByText — для статичного текста
  5. getByTestId — крайний случай, когда ничто другое не подходит

Тестировать поведение, а не устройство

Разница принципиальная:

Поведение (тестировать)Реализация (не тестировать)
Компонент показал текст из пропсовСодержимое useState
Форма показала ошибку при пустом полеИмена CSS-классов
Кнопка вызвала колбэкПорядок вызовов хуков
Список отобразил все переданные элементыСтруктура дерева компонентов

Признак хорошего теста — он не меняется при рефакторинге, если поведение для пользователя осталось прежним.

Подмена серверных запросов

Компоненты, которые загружают данные, нельзя тестировать с реальным сервером — тест станет медленным и нестабильным. Запросы подменяют.

Самый простой способ — мок на уровне функции загрузки:

vi.mock("../api/products", () => ({
  fetchProducts: vi.fn().mockResolvedValue([
    { id: "1", name: "Кофемолка" },
  ]),
}));

Для более сложных случаев используют MSW (Mock Service Worker) — он перехватывает fetch-запросы на уровне сети и возвращает заготовленные ответы. Это позволяет тестировать компонент так, как если бы сервер реально отвечал:

const server = setupServer(
  http.get("/api/products", () => {
    return HttpResponse.json([{ id: "1", name: "Кофемолка" }]);
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Тестировать состояния загрузки и ошибок

Компонент редко находится в одном состоянии. Хороший набор тестов покрывает:

  • начальный рендер (скелетон или спиннер);
  • успешный ответ (данные отображены);
  • ошибку (сообщение об ошибке показано).
test("показывает ошибку, если загрузка не удалась", async () => {
  server.use(
    http.get("/api/products", () => HttpResponse.error())
  );

  render(<ProductList />);
  expect(await screen.findByText("Не удалось загрузить данные")).toBeInTheDocument();
});

findBy* (в отличие от getBy*) возвращает промис и ждёт появления элемента — это нужно для асинхронных операций.

Пирамида frontend-тестов

Те же уровни, что в backend, только адаптированные под UI:

Снизу, много — юнит-тесты чистой логики. Хуки с бизнес-логикой, функции валидации, утилиты форматирования. Не требуют рендера компонентов, запускаются мгновенно.

Посередине, основная масса — компонентные тесты. Компонент или экран с замоканными данными. Проверяют поведение: показал нужное, отреагировал на действие, отобразил ошибку. Это то, что проверяет Testing Library.

Сверху, мало — сквозные тесты в реальном браузере. Playwright или Cypress. Медленные, требуют запущенного приложения. Покрывают критичные пользовательские сценарии целиком. Это отдельная специализация — у сквозных тестов собственные инструменты и подходы.

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

Коротко

  • Vitest — раннер, Testing Library — инструмент для тестирования компонентов через пользовательскую точку зрения.
  • Искать элементы по роли (getByRole) и тексту, а не по классам — тест переживёт рефакторинг вёрстки.
  • Тестировать поведение (что показано, что вызвано), а не реализацию (state, имена классов, порядок хуков).
  • Серверные запросы подменять через мок функции или MSW — тест не зависит от сети.
  • Покрывать три состояния: загрузка, успех, ошибка.
  • Пирамида: юниты логики → компонентные тесты → минимум сквозных.

Что почитать дальше

  • Доступность — поиск по роли и доступность связаны напрямую.
  • Формы — как тестировать валидацию и отправку форм.
  • Управление состоянием — как тестировать компоненты, зависящие от глобального состояния.