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. Если нужный элемент нельзя найти по роли — часто это сигнал, что разметка недостаточно доступна.
Иерархия запросов по предпочтительности:
getByRole— приоритет всегдаgetByLabelText— для полей формыgetByPlaceholderText— если метки нетgetByText— для статичного текста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 — тест не зависит от сети.
- Покрывать три состояния: загрузка, успех, ошибка.
- Пирамида: юниты логики → компонентные тесты → минимум сквозных.
Что почитать дальше
- Доступность — поиск по роли и доступность связаны напрямую.
- Формы — как тестировать валидацию и отправку форм.
- Управление состоянием — как тестировать компоненты, зависящие от глобального состояния.