Тестирование фронтенда: юнит, компонентные, E2E, визуальная регрессия
В предыдущей статье мы разбирались, как измерять скорость интерфейса. Теперь — вопрос, который задают позже и гораздо болезненнее: он вообще работает правильно? И продолжит ли работать правильно после того, как в понедельник кто-то поправит одну строчку в общем компоненте кнопки.
Фронтенд тестируется иначе, чем бэкенд, и не потому что «UI сложно тестировать». Причина в природе контракта. У бэкенда контракт — JSON: подал вход, сравнил выход. У фронтенда контракт — восприятие человека: пользователь не вызывает функцию, он видит кнопку, нажимает и ждёт реакции. Между вашим кодом и этим восприятием лежат три слоя, которых в обычном юнит-тесте просто нет: браузерный движок с раскладкой, асинхронность (сеть, анимации, дебаунсы) и визуальное представление. Отсюда все специфические болезни фронтовых наборов тестов — от «локально зелёное, на CI красное» до трёхсот снапшотов, которые никто не читает.
Общую теорию тестирования — уровни, техники тест-дизайна, критерии покрытия — подробно разбирает трек «Тестирование». Здесь только то, что специфично для браузера и React-экосистемы.
Четыре класса ошибок и четыре инструмента
Прежде чем спорить про пирамиды и трофеи, разложим баги интерфейса по природе. Классов ровно четыре, и у каждого свой ловец:
- Логическая ошибка. Скидка считается неверно, редьюсер теряет элемент, форматтер даты падает на 29 февраля. Ловится юнит-тестом чистой функции — микросекунды, полная надёжность.
- Ошибка взаимодействия. Кнопка «Отправить» не блокируется на время запроса, форма не показывает ошибку валидации, после удаления строки фокус улетает в
<body>. Ловится компонентным тестом. - Ошибка интеграции. Фронтенд ждёт
total_price, бэкенд отдаётtotalPrice. Роутер не восстанавливает состояние после перезагрузки. Токен протух, и никто его не обновил. Ловится E2E. - Визуальная ошибка. Логически всё работает, но кнопка уехала за границу карточки, а в тёмной теме текст потерял контраст. Не ловится ничем из перечисленного — нужна визуальная регрессия.
Главный вывод: классы не заменяют друг друга. Сто юнит-тестов не заметят, что кнопка оплаты стала невидимой на мобильном. Двести E2E не найдут копеечное расхождение округления в одном случае из тысячи.
Пирамида, трофей и правило самого низкого уровня
Классическая пирамида Фаулера: много юнитов, меньше интеграционных, совсем мало E2E. Кент Доддс возразил «Testing Trophy»: во фронтенде основание надо сдвинуть вверх, к компонентным тестам, потому что ценность сидит там.
Оба правы — просто про разный код. Если в приложении много вычислений (расчёт корзины, машина состояний онбординга, парсинг фильтров из URL), работает пирамида. Если приложение — «показать данные и дать по ним покликать», а таких большинство, работает трофей: юнит-тестировать компонент из трёх полей бессмысленно, а компонентный тест той же формы окупается сразу.
Рабочая формулировка, снимающая спор: тестируйте на самом низком уровне, на котором ошибка ещё может быть обнаружена. Ошибку округления видно в юните — не поднимайте её в E2E. «Спиннер не исчезает» в юните не видно — не пытайтесь.
Обратите внимание на третий квадрант. Снапшот всего дерева выполняется быстро, но уверенности почти не даёт: падает на любом рефакторинге разметки и молчит на настоящих багах. E2E, покрывающий каждую форму, съедает половину времени CI и ловит ровно то же, что компонентные тесты, только в двадцать раз дороже.
Граница теста — самое важное решение
До выбора инструмента ответьте на вопрос: где проходит граница между настоящим кодом и подделкой? От неё зависит и скорость, и ценность.
Правило простое: подменять нужно как можно ниже. Самая частая ошибка — замокать собственный хук:
// Плохо: из-под теста вырезан весь ваш код
vi.mock('./useProducts', () => ({
useProducts: () => ({ data: [{ id: 1, title: 'Кофе' }], isLoading: false }),
}));
Тест зелёный, но проверяет только то, что компонент умеет отрисовать массив, который вы сами же и передали. Он не заметит, что настоящий useProducts неверно разбирает ответ API, что при 500 не показывается сообщение, что при пустом ответе рисуется пустая карточка вместо заглушки.
Правильная граница — сеть. Подменяем HTTP-ответы, оставляем внутри теста и хук, и кэш, и компонент. Как это делается — ниже, в разделе про MSW.
Инструментальный ландшафт
Коротко и без фанатизма о выборе:
- Vitest против Jest. Если проект собирается Vite (см. «Сборка фронтенда»), берите Vitest: тот же конфиг, те же алиасы и плагины, кратно быстрый старт на ESM, штатный запуск в настоящем браузере. Jest оправдан на большой легаси-базе с Babel-трансформами и сотнями кастомных
jest.mock, где миграция дороже выигрыша. API почти совместимы — переход обычно занимает день. - Playwright против Cypress. Playwright быстрее, умеет несколько браузеров, вкладок и контекстов, работает вне цикла событий страницы, имеет отличный трейс-вьювер и штатный шардинг. Cypress приятнее в интерактивной отладке и мягче на входе. Для нового проекта разумнее Playwright; переписывать работающий набор Cypress ради моды — нет.
- Testing Library — не альтернатива раннеру, а слой поверх любого. Её ценность в философии запросов, а не в коде, и она одинаково работает с React, Vue и Svelte. Это важный аргумент в пользу того, чтобы навык был переносимым между фреймворками (сравнение — в следующей статье).
Vitest: конфиг, который не придётся переделывать
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
globals: true, // describe/it/expect без импорта
restoreMocks: true, // авто-restoreAllMocks после каждого теста: убирает протечки моков
setupFiles: ['./src/test/setup.ts'],
css: false, // не гонять CSS через сборщик — в jsdom стили всё равно не работают
projects: [
// Чистая логика — в node: там нет накладных расходов на создание DOM.
{ extends: true, test: { name: 'unit', environment: 'node', include: ['src/**/*.unit.test.ts'] } },
// Компоненты — в jsdom.
{ extends: true, test: { name: 'dom', environment: 'jsdom', include: ['src/**/*.test.tsx'] } },
],
coverage: { provider: 'v8', reporter: ['text', 'lcov'], include: ['src/**/*.{ts,tsx}'] },
},
});
Про css: false: по умолчанию Vitest прогоняет импортированные стили через сборщик, а раскладки в jsdom всё равно нет. На среднем проекте отключение экономит 30–50 % времени прогона. Проверять имена классов из CSS-модулей не стоит вовсе — это деталь реализации (см. «Архитектура стилей»).
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'; // toBeVisible, toHaveAccessibleName и другие матчеры
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './msw/server';
// onUnhandledRequest: 'error' — обязательно: иначе забытый эндпоинт тихо уйдёт
// в настоящую сеть и тест станет недетерминированным.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers(); // откатываем server.use из конкретного теста
cleanup(); // размонтируем всё, что отрендерили
});
afterAll(() => server.close());
// jsdom не реализует matchMedia и ResizeObserver, а они нужны почти любой UI-библиотеке.
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false, media: query, onchange: null,
addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false,
}),
});
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} };
Необходимость полифилов — прямое следствие того, что jsdom это не браузер, а реализация DOM API на JavaScript. Там нет движка раскладки: getBoundingClientRect() возвращает нули, IntersectionObserver не срабатывает, scrollIntoView — заглушка, каскад CSS не вычисляется. Компоненты, которые себя измеряют (виртуальные списки, тултипы, карусели), в jsdom тестируются плохо — их место в browser mode.
Юнит-тесты: что действительно стоит тестировать отдельно
Юнит-тест во фронтенде оправдан там, где есть нетривиальная логика без DOM: расчёты, редьюсеры, нормализация, парсинг, форматирование, машины состояний, схемы валидации.
// src/features/cart/total.ts
export type CartLine = { price: number; qty: number; discountPct?: number };
/** Итог корзины в копейках. Скидка применяется к строке, округление — на каждой строке. */
export function cartTotal(lines: CartLine[]): number {
return lines.reduce((sum, line) => {
const gross = line.price * line.qty;
const discount = gross * ((line.discountPct ?? 0) / 100);
return sum + Math.round(gross - discount);
}, 0);
}
// src/features/cart/total.unit.test.ts
import { describe, expect, it } from 'vitest';
import { cartTotal, type CartLine } from './total';
describe('cartTotal', () => {
// Табличный тест: одна структура, много случаев, включая граничные.
it.each<[string, CartLine[], number]>([
['пустая корзина', [], 0],
['одна позиция без скидки', [{ price: 10_000, qty: 2 }], 20_000],
['скидка 10 процентов', [{ price: 10_000, qty: 1, discountPct: 10 }], 9_000],
['скидка 100 процентов даёт ноль', [{ price: 10_000, qty: 3, discountPct: 100 }], 0],
['округление на нечётной копейке', [{ price: 333, qty: 1, discountPct: 33 }], 223],
])('%s', (_name, lines, expected) => {
expect(cartTotal(lines)).toBe(expected);
});
});
Такой тест исполняется за доли миллисекунды, читается как спецификация и переживает любой рефакторинг UI. Там же, где формулируется инвариант, а не конкретный пример, помогает property-based подход — fast-check генерирует сотни случайных входов и ищет минимальный контрпример:
import fc from 'fast-check';
const line = fc.record({
price: fc.integer({ min: 0, max: 1_000_000 }),
qty: fc.integer({ min: 0, max: 50 }),
discountPct: fc.integer({ min: 0, max: 100 }),
});
it('итог не отрицателен и не превышает сумму без скидок', () => {
fc.assert(fc.property(fc.array(line), lines =>
cartTotal(lines) >= 0 &&
cartTotal(lines) <= lines.reduce((s, l) => s + l.price * l.qty, 0),
));
});
Хуки: логика внутри React
// src/shared/hooks/useDebouncedValue.test.ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('отдаёт последнее значение только после паузы', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'к' } },
);
rerender({ value: 'ко' });
rerender({ value: 'коф' });
expect(result.current).toBe('к'); // ещё не «отпустило»
act(() => { vi.advanceTimersByTime(299); });
expect(result.current).toBe('к');
act(() => { vi.advanceTimersByTime(1); });
expect(result.current).toBe('коф'); // проскочило только последнее значение
});
Два нюанса. act нужен вокруг продвижения таймеров: внутри сработает setState, и React должен успеть применить обновление до проверки. И если в том же тесте используется userEvent, его надо связать с фейковыми таймерами, иначе он зависнет навсегда:
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
Модель хуков подробно разобрана в «Хуки и паттерны React».
Компонентные тесты: проверяем то, что видит пользователь
Философия Testing Library укладывается в одну фразу её автора: «The more your tests resemble the way your software is used, the more confidence they can give you». Практически это значит: не трогайте внутренности компонента. Не читайте state, не вызывайте методы, не ищите по классам. Ищите так, как ищет человек — по видимому тексту, подписи, роли.
Приоритет запросов — не эстетика, а функция
Testing Library задаёт явный порядок предпочтений:
getByRole— роль плюс доступное имя: так элемент видят и человек, и скринридер.getByLabelText— для полей формы.getByPlaceholderText,getByText,getByDisplayValue— когда роли не хватает.getByAltText,getByTitle— изображения и подсказки.getByTestId— последний вариант, для элементов без семантики.
Если getByRole('button', { name: 'Оплатить' }) ничего не нашёл, значит и скринридер не найдёт — тест бесплатно проверил доступность. Поэтому семантическая разметка (см. «Семантический HTML») напрямую упрощает тестирование, а div с onClick его ломает.
Полный пример: реальный хук, реальный кэш, подменённая сеть
// src/features/search/ProductSearch.test.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { expect, it } from 'vitest';
import { server } from '@/test/msw/server';
import { ProductSearch } from './ProductSearch';
/** Рендер с настоящими провайдерами. Никаких моков собственных хуков. */
function renderSearch() {
const queryClient = new QueryClient({
// Ретраи в тестах выключаем: иначе ошибка «отложится» на секунды и тест упадёт по таймауту.
defaultOptions: { queries: { retry: false } },
});
return {
user: userEvent.setup(),
...render(
<QueryClientProvider client={queryClient}>
<ProductSearch />
</QueryClientProvider>,
),
};
}
it('показывает результаты и позволяет добавить товар в корзину', async () => {
const { user } = renderSearch();
await user.type(screen.getByRole('searchbox', { name: /поиск товаров/i }), 'кофе');
// findBy* = getBy* + waitFor: ждём появления результата, а не «просто подождём».
const list = await screen.findByRole('list', { name: /результаты/i });
const items = within(list).getAllByRole('listitem');
expect(items).toHaveLength(2);
await user.click(within(items[0]).getByRole('button', { name: /в корзину/i }));
expect(await screen.findByRole('status')).toHaveTextContent('Добавлено в корзину');
});
it('показывает понятную ошибку, когда API отвечает 500', async () => {
// Переопределяем обработчик только для этого теста — afterEach откатит.
server.use(http.get('/api/products', () => new HttpResponse(null, { status: 500 })));
const { user } = renderSearch();
await user.type(screen.getByRole('searchbox', { name: /поиск товаров/i }), 'кофе');
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/не удалось загрузить/i);
expect(screen.getByRole('button', { name: /повторить/i })).toBeEnabled();
});
Второй тест — ровно то, ради чего компонентные тесты существуют. Он проходит через настоящий хук, настоящий кэш React Query (см. «Работа с данными»), настоящую обработку ошибки и проверяет, что пользователь получил сообщение с ролью alert и способ повторить. Ни одна из этих деталей не проверяется юнитом.
Пять ошибок, которые совершают все
// 1. fireEvent имитирует ровно одно событие; userEvent воспроизводит настоящую цепочку
// pointerover → pointerdown → mousedown → focus → pointerup → mouseup → click
fireEvent.click(button); // плохо
await user.click(button); // хорошо
// 2. Забытый await: проверка выполняется до обновления, тест «проходит» случайно
user.click(button); expect(screen.getByText('Готово')).toBeInTheDocument(); // плохо
await user.click(button); expect(await screen.findByText('Готово')).toBeVisible();
// 3. Ожидание по времени маскирует гонку; ждать надо наблюдаемое условие
await new Promise(r => setTimeout(r, 500)); // плохо
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
// 4. getBy* бросит исключение раньше, чем сработает матчер отсутствия
expect(screen.getByText('Ошибка')).not.toBeInTheDocument(); // плохо
expect(screen.queryByText('Ошибка')).not.toBeInTheDocument(); // хорошо: queryBy* вернёт null
// 5. Деталь реализации против наблюдаемого поведения
expect(wrapper.state('isOpen')).toBe(true); // плохо
expect(screen.getByRole('dialog')).toBeVisible(); // хорошо
Расширенный разбор — у Кента Доддса: Common mistakes with React Testing Library и Testing Implementation Details.
Сеть: почему MSW, а не мок fetch
Подмена global.fetch = vi.fn() кажется дешёвой, но у неё три изъяна. Она не работает, если код ходит через axios поверх XHR. Она не проверяет, что вы сформировали корректный запрос — метод, заголовки, тело. И один и тот же мок приходится писать трижды: для тестов, для Storybook, для локальной разработки без бэкенда.
MSW перехватывает на уровне сетевого слоя: в Node — через патч http/fetch, в браузере — через Service Worker. Ваш код делает настоящий fetch с настоящим Request и получает настоящий Response.
// src/test/msw/handlers.ts
import { delay, http, HttpResponse } from 'msw';
import { productSchema } from '@/entities/product/schema';
const catalogue = [
{ id: 'p1', title: 'Кофе Эфиопия', price: 89_000 },
{ id: 'p2', title: 'Кофе Колумбия', price: 76_000 },
{ id: 'p3', title: 'Чай улун', price: 54_000 },
];
export const handlers = [
http.get('/api/products', async ({ request }) => {
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? '';
await delay(10); // небольшая задержка, чтобы состояние loading реально возникало
const items = catalogue.filter(p => p.title.toLowerCase().includes(q));
// Валидируем мок той же схемой, что и продакшн-код: расхождение контракта
// ломает тест сразу, а не в проде.
items.forEach(item => productSchema.parse(item));
return HttpResponse.json({ items });
}),
http.post('/api/cart/items', async ({ request }) => {
const body = (await request.json()) as { productId: string; qty: number };
return body.productId
? HttpResponse.json({ ok: true, lines: 1 }, { status: 201 })
: HttpResponse.json({ message: 'productId обязателен' }, { status: 422 });
}),
];
// src/test/msw/server.ts
import { setupServer } from 'msw/node';
export const server = setupServer(...handlers);
Тот же набор обработчиков переиспользуется в Storybook и в dev-режиме через setupWorker. Редкий случай, когда «сделать правильно» ещё и экономит работу.
Ловушка. Не описывайте в моках то, чего бэкенд не делает. Обработчик, который всегда отдаёт 200 и идеально причёсанный объект, даёт ложное чувство безопасности. Лечится генерацией обработчиков из OpenAPI (msw-auto-mock, orval) или валидацией ответов той же Zod-схемой, что в проде — как в примере выше.
Асинхронность и флейки
Флейк — тест, который на одном и том же коде иногда красный, иногда зелёный. Это главный убийца доверия к набору: после третьего «перезапусти, оно само пройдёт» люди перестают читать красные сборки.
при повторе 20 раз?"} B -- да --> C{"Есть ожидание
по времени?"} B -- нет --> D{"Падает только на CI?"} C -- "setTimeout, sleep" --> C1["Заменить на ожидание
наблюдаемого условия"] C -- нет --> C2{"Зависит от порядка
тестов?"} C2 -- да --> C3["Утечка состояния:
модульный кэш, стор,
незакрытый обработчик MSW"] C2 -- нет --> C4{"Есть таймеры,
анимации, дебаунс?"} C4 -- да --> C5["useFakeTimers +
advanceTimers у userEvent"] C4 -- нет --> C6["Гонка запросов:
два fetch, победил не тот"] D --> D1{"CI медленнее
в 3-5 раз?"} D1 -- да --> D2["Поднять expect.timeout,
но НЕ общий timeout теста"] D1 -- нет --> D3{"Другие шрифты,
таймзона, локаль?"} D3 -- да --> D4["Зафиксировать образ,
timezoneId, locale, часы"] D3 -- нет --> D5["Параллелизм: тесты делят
одного пользователя
или одну запись в БД"]
Правила, которые убирают девять флейков из десяти:
- Никогда не ждите время — ждите условие.
setTimeout(r, 300)работает на вашем ноутбуке и падает на загруженном раннере. - Изолируйте состояние. Модульные переменные, singleton-сторы и кэш React Query переживают тест. Создавайте
QueryClientзаново в каждом тесте; для Zustand вызывайтеstore.setState(initialState, true)вbeforeEach(см. «Управление состоянием»). - Каждый E2E создаёт свои данные. Тесты на общем
test@example.comломаются ровно в день включения параллелизма. - Не глушите ошибку ретраями.
retries: 2защищает сборку от инфраструктурных сбоев, но каждый ретрай должен попадать в отчёт. Тест, стабильно проходящий со второй попытки, сломан, а не «немного нестабилен». Масштаб проблемы Google описывал в Test Flakiness.
Когда jsdom перестаёт хватать
Симптомы: тултип позиционируется из getBoundingClientRect и «не виден»; виртуальный список рендерит ноль строк; IntersectionObserver молчит; переменная темы не применяется. Всё это — отсутствие раскладки. Решение — browser mode: тот же Vitest и та же Testing Library, но исполнение в настоящем Chromium через Playwright.
// vitest.browser.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
include: ['src/**/*.browser.test.tsx'],
browser: {
enabled: true,
provider: 'playwright',
headless: true,
instances: [{ browser: 'chromium' }],
},
},
});
Цена — примерно порядок величины по времени на тест. Поэтому в browser mode держат только то, где нужны измерения и стили: поповеры, drag-and-drop, виртуализацию, position: sticky. Остальное остаётся в jsdom.
E2E на Playwright
E2E проверяет то, что не проверит ничто другое: связку фронтенда, реального бэкенда, авторизации, роутинга и перезагрузок страницы. Их должно быть мало, и они должны покрывать деньги и вход: регистрация, логин, оформление заказа, оплата, ключевой поиск.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // случайный test.only не попадёт в main
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
timeout: 30_000, // на весь тест
expect: { timeout: 5_000 }, // на одно ожидание — держите небольшим
reporter: process.env.CI
? [['blob'], ['github']] // blob нужен, чтобы склеить отчёты шардов
: [['html', { open: 'on-failure' }]],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:4173',
trace: 'on-first-retry', // трейсы только для упавших, иначе гигабайты артефактов
video: 'retain-on-failure',
locale: 'ru-RU',
timezoneId: 'Europe/Moscow', // фиксируем, иначе даты «поплывут» между машинами
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup'], // логин выполняется один раз на весь прогон
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 14'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
},
});
Логин один раз, а не в каждом тесте, — крупнейшая экономия времени в E2E-наборе:
// e2e/auth.setup.ts
import { expect, test as setup } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('аутентификация', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Электронная почта').fill(process.env.E2E_USER!);
await page.getByLabel('Пароль').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('heading', { name: 'Мои заказы' })).toBeVisible();
await page.context().storageState({ path: authFile }); // cookies + localStorage
});
Сам сценарий — на локаторах и без единого waitForTimeout:
// e2e/checkout.spec.ts
import { expect, test } from '@playwright/test';
test('пользователь добавляет товар и оформляет доставку', async ({ page }) => {
// test.step не влияет на логику, но превращает трейс и отчёт в читаемую историю.
await test.step('добавить товар в корзину', async () => {
await page.goto('/catalog');
// Локатор ленивый: он не ищет элемент сразу, а разрешается перед каждым действием.
const card = page.getByRole('listitem').filter({ hasText: 'Кофе Эфиопия' });
await card.getByRole('button', { name: 'В корзину' }).click();
// Веб-первые проверки ждут сами, пока условие не станет истинным.
await expect(page.getByRole('link', { name: /Корзина/ })).toContainText('1');
});
await page.getByRole('link', { name: /Корзина/ }).click();
await page.getByLabel('Адрес доставки').fill('Москва, Тверская 1');
await page.getByRole('button', { name: 'Оформить' }).click();
await expect(page.getByRole('heading', { name: /Заказ №\d+ принят/ })).toBeVisible();
await expect(page).toHaveURL(/\/orders\/\d+/);
});
Почему Playwright почти не требует ожиданий
невыполненных условий
Каждый локатор перед действием проходит цикл проверок пригодности (actionability). Поэтому sleep не нужен, а сообщение об ошибке выглядит как «element is not visible / element is covered by another element», а не «cannot read property of null». Детали — в Playwright best practices.
Упавший на CI тест разбирается трейсом: npx playwright show-trace trace.zip открывает покадровую запись с DOM-снимками, сетью и консолью на каждом шаге. Это единственный инструмент, который реально закрывает вопрос «падает только на CI». Более широкий взгляд на E2E-автоматизацию — в статье «E2E и UI-тестирование».
Aria-снапшоты вместо снапшотов разметки
Снапшот HTML — плохая идея: он падает на каждом рефакторинге и молчит на багах. А вот снимок дерева доступности стабилен к перестановке div и падает именно тогда, когда меняется смысл интерфейса:
await expect(page.getByRole('main')).toMatchAriaSnapshot(`
- heading "Корзина" [level=1]
- list:
- listitem: Кофе Эфиопия
- button "Оформить"
`);
Это компромисс между хрупкими снапшотами и десятком ручных expect — и заодно бесплатная проверка семантики.
Визуальная регрессия
Визуальные тесты закрывают дыру, которую не видит ни один DOM-тест: разметка корректна, роли на месте, а кнопка визуально уехала под футер. Механика проста — снять скриншот, сравнить с эталоном, показать разницу.
Вся сложность — в детерминизме: скриншот это массив пикселей, и любое отличие среды даёт дифф.
// e2e/visual/cart.spec.ts
import { expect, test } from '@playwright/test';
test('корзина выглядит как эталон', async ({ page }) => {
// 1. Фиксируем время — иначе «сегодня» меняется каждый день.
await page.clock.setFixedTime(new Date('2026-03-01T10:00:00Z'));
// 2. Фиксируем данные — перехватываем API вместо живого бэкенда.
await page.route('**/api/cart', route =>
route.fulfill({ json: { lines: [{ id: 'p1', title: 'Кофе Эфиопия', qty: 2 }] } }),
);
await page.goto('/cart');
await expect(page.getByRole('heading', { name: 'Корзина' })).toBeVisible();
await expect(page).toHaveScreenshot('cart-desktop.png', {
animations: 'disabled', // дождаться завершения CSS-анимаций
caret: 'hide', // мигающий курсор — классический флейк
mask: [page.getByTestId('user-avatar')], // закрыть то, что грузится из CDN
maxDiffPixelRatio: 0.002, // допуск на сглаживание шрифтов
fullPage: true,
});
});
Чек-лист детерминизма, без которого визуальные тесты превращаются в шум:
| Источник дрейфа | Симптом | Лечение |
|---|---|---|
| Шрифты | Дифф по всему тексту сразу | Снимать только в docker-образе mcr.microsoft.com/playwright:vX.Y.Z-noble, эталоны — из него же |
| Время и даты | «Заказ от 12 марта» меняется | page.clock.setFixedTime |
| Случайные данные | Аватары и имена из faker | Фиксированный сид или page.route |
| Анимации | Дифф в 5 % прогонов | animations: 'disabled' плюс prefers-reduced-motion |
| Ленивая загрузка | Половина изображений пустая | Дождаться toBeVisible конкретной картинки перед снимком |
| Скроллбар | Полоса 15 px по краю | Фиксированный viewport, скрытый скроллбар в тестовой теме |
Отдельно про масштаб снимка. Скриншот всей страницы кажется удобным, но у него плохая локальность: правка отступа в шапке ломает сразу двадцать эталонов. Практичнее снимать компоненты, а страницы целиком — только для двух-трёх ключевых экранов. Отсюда естественная связка со Storybook.
Про стоимость. Полный визуальный набор на 300 компонентов в двух темах и двух ширинах — 1200 снимков на каждый PR, а это и время, и деньги в облачных сервисах. Помогает подход, который Chromatic называет TurboSnap: по графу зависимостей сборки определяется, какие истории затронуты изменёнными файлами, и снимаются только они. Руками то же делается через прогон «только затронутого» по карте зависимостей бандлера.
Ключевое правило процесса: эталоны лежат в репозитории и обновляются тем же PR, который меняет вид. Тогда ревьюер видит в диффе не только код, но и картинку. Если эталоны во внешнем сервисе (Chromatic, Percy, Argos), правило то же — апрув визуальных изменений блокирует мерж.
Storybook как единая точка входа
Storybook часто считают витриной для дизайнеров. На деле это самый дешёвый способ получить три вида проверки из одного описания.
// src/shared/ui/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from 'storybook/test';
import { Button } from './Button';
const meta = {
component: Button,
args: { onClick: fn(), children: 'Оплатить' },
parameters: { layout: 'centered' },
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = { args: { variant: 'primary' } };
export const Disabled: Story = { args: { disabled: true } };
export const Loading: Story = { args: { loading: true } };
// play-функция превращает историю в полноценный компонентный тест
export const ClickCallsHandler: Story = {
args: { variant: 'primary' },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Оплатить' }));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};
Из этого набора вы получаете: живую документацию (состояния видно глазами), компонентные тесты (аддон Storybook Test гоняет play-функции в настоящем браузере через Vitest browser mode), визуальные снимки (каждая история — кандидат в эталоны). А сами истории переиспользуются в обычных тестах без дублирования пропсов:
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import * as stories from './Button.stories';
const { Loading } = composeStories(stories); // args и decorators уже применены
it('в состоянии загрузки кнопка недоступна и сообщает об этом', () => {
render(<Loading />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAccessibleName(/загрузка/i);
});
Это снимает главную претензию к Storybook — «ещё один артефакт, который надо поддерживать». Если истории используются тестами, они не протухают: сломанная история ломает сборку.
Доступность и производительность как автотесты
Автоматика находит примерно треть проблем доступности — но эту треть находит надёжно и бесплатно. Ставим axe-core на оба уровня:
// компонентный уровень
import { axe } from 'vitest-axe';
it('форма оформления не имеет нарушений доступности', async () => {
const { container } = render(<CheckoutForm />);
expect(await axe(container)).toHaveNoViolations();
});
// E2E-уровень: вся страница, с реальными стилями и контрастом
import AxeBuilder from '@axe-core/playwright';
test('каталог соответствует WCAG 2.1 AA', async ({ page }) => {
await page.goto('/catalog');
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
expect(violations).toEqual([]);
});
Оставшиеся две трети — логика фокуса, осмысленность подписей, порядок чтения — проверяются руками и продуманной разметкой; подробно это разбирает руководство по доступности.
Производительность тоже регрессирует — и тоже проверяется автоматически. Два бюджета в CI закрывают большую часть деградаций: размер бандла (size-limit или bundlesize на критичные чанки) и лабораторные Core Web Vitals через Lighthouse CI:
{ "ci": {
"collect": { "url": ["http://localhost:4173/catalog"], "numberOfRuns": 3 },
"assert": { "assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 300 }]
} }
} }
Лабораторные метрики шумят, поэтому берите медиану нескольких прогонов и считайте их сигналом тренда, а не приговором; как это соотносится с полевыми данными — в «Производительности фронтенда».
Покрытие, мутации и честные метрики
Покрытие во фронтенде обманчиво. Отрендерив компонент один раз, вы «покрываете» весь его JSX, ничего не проверив: строки исполнились, утверждений не было. Отсюда правила:
- Считайте покрытие инструментом поиска дыр, а не целью. Полезный вопрос — «какие ветки обработки ошибок не покрыты», а не «дотянули ли до 80 %».
- Порог ставьте там, где он не заставляет писать мусор: 60–70 % по строкам для приложения, 90 % для библиотеки общих компонентов и чистой логики.
- Провайдер
v8быстрее,istanbulточнее по веткам JSX. Начинайте сv8.
Проверить, что тесты действительно что-то утверждают, умеет мутационное тестирование: Stryker вносит мелкие изменения (> → >=, true → false, удаление вызова) и смотрит, упадут ли тесты. Выжившая мутация — прямое доказательство, что код покрыт формально, но не проверен. На всём проекте это дорого; гоняйте раз в спринт по критичным модулям: расчёты, права доступа, валидация.
Тесты в CI: как уложиться в бюджет
Ориентиры для среднего продуктового репозитория: юнит и компонентные — до 2 минут, E2E — до 10, визуальные — до 5. Если сборка идёт полчаса, люди начинают мержить не дожидаясь.
# .github/workflows/test.yml
name: test
on: [pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run test -- --coverage
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4] # делим набор на шарды и гоняем параллельно
container:
# Тот же образ, в котором сняты визуальные эталоны, иначе дифф по шрифтам
image: mcr.microsoft.com/playwright:v1.52.0-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: always()
with: { name: blob-report-${{ matrix.shard }}, path: blob-report, retention-days: 7 }
Отдельная задача после матрицы скачивает все blob-report-* и склеивает их в один HTML-отчёт командой npx playwright merge-reports --reporter=html ./all-blobs — иначе четыре шарда дадут четыре несвязанных отчёта.
Что ещё сокращает время:
- Разделение по триггерам. Юнит и компонентные — на каждый пуш; E2E и визуальные — на PR и main; полная матрица браузеров — ночью.
- Прогон только затронутого.
vitest related --changedзапускает тесты, зависящие от изменённых файлов. В монорепо (см. «Архитектура фронтенда») то же делают Turborepo и Nx по графу зависимостей. - Кэш браузеров и
node_modules. Скачивание Chromium — минута на каждый шард без кэша. - Флейкость как первоклассная метрика. Собирайте статистику падений по имени теста: падающий чаще 1 % прогонов уходит в карантин или в починку, но не игнорируется. Организация процесса — в «Тесты в CI» и «Основах CI».
Антипаттерны, которые дорого стоят
| Антипаттерн | Почему плохо | Что делать вместо |
|---|---|---|
| Снапшот всего дерева компонента | Падает на любом изменении разметки, молчит на багах; дифф из 400 строк никто не читает | Проверять конкретные наблюдаемые свойства или aria-снапшот |
data-testid на всём подряд |
Тест расходится с тем, как элемент видит пользователь, и не страхует доступность | getByRole с доступным именем; testid — только для элементов без семантики |
| Мок собственного хука или стора | Из-под теста вырезан весь ваш код | Подмена на границе сети через MSW |
| E2E на каждую форму | Полчаса CI ради ошибок, которые ловятся за 200 мс | Один E2E на критический путь, остальное — компонентные |
await page.waitForTimeout(2000) |
Флейк, гарантированный ростом нагрузки на раннер | Веб-первые проверки expect(locator).toBeVisible() |
| Один общий тестовый пользователь | Ломается при включении параллелизма | Создание данных внутри теста, изоляция по сущностям |
| Порог покрытия 100 % | Порождает тесты без утверждений ради строк | Разумный порог плюс мутационное тестирование на критичном |
| Тест читает состояние компонента | Ломается при любом рефакторинге | Проверять DOM и вызовы наружу |
Мини-итог
- Класс ошибки определяет уровень теста. Логика — юнит, взаимодействие — компонентный, интеграция — E2E, внешний вид — визуальная регрессия; заменять один класс другим не выйдет.
- Граница подмены должна лежать на сети. Мок выше сети (хук, стор, сервис) убивает ценность теста.
getByRoleвместоgetByTestId,userEventвместоfireEvent,findByвместо ожидания по времени. Три правила снимают большую часть боли компонентных тестов.- jsdom — не браузер. Как только тесту нужна раскладка, переносите его в browser mode, а не пишите моки
getBoundingClientRect. - E2E немногочисленны, изолированы по данным и снабжены трейсами. Retry — страховка от инфраструктуры, а не способ жить с флейками.
- Визуальные тесты работают только при полном детерминизме: фиксированный образ, фиксированные часы, отключённые анимации, маски на внешний контент.
- Storybook окупается, когда истории переиспользуются тестами — тогда они не протухают.
- Покрытие — диагностика, а не цель; качество утверждений измеряет только мутационное тестирование.
Источники
- Testing Library: Guiding Principles и приоритет запросов
- Kent C. Dodds. Write tests. Not too many. Mostly integration., Testing Implementation Details
- Playwright: Best Practices, Visual comparisons, Trace viewer
- Vitest: Browser Mode, Test Projects
- Mock Service Worker, Storybook: Testing
- axe-core, StrykerJS, fast-check
- Martin Fowler. The Practical Test Pyramid; Google Testing Blog. Test Flakiness
Что дальше
Мы прошли путь от браузера до тестов и научились удерживать качество интерфейса под контролем. Остался вопрос, с которого многие начинают, но честно ответить на который можно только теперь, зная цену рендеринга, состояния, сборки и тестирования: а на чём вообще писать?
Сравнение фреймворков: React, Vue, Svelte, Angular, Solid — что выбрать