Frontend-разработка Тестирование фронтенда: юнит, компонентные, E2E, визуальная регрессия
0%

Тестирование фронтенда: юнит, компонентные, E2E, визуальная регрессия

Тестирование фронтенда: юнит, компонентные, E2E, визуальная регрессия

В предыдущей статье мы разбирались, как измерять скорость интерфейса. Теперь — вопрос, который задают позже и гораздо болезненнее: он вообще работает правильно? И продолжит ли работать правильно после того, как в понедельник кто-то поправит одну строчку в общем компоненте кнопки.

Фронтенд тестируется иначе, чем бэкенд, и не потому что «UI сложно тестировать». Причина в природе контракта. У бэкенда контракт — JSON: подал вход, сравнил выход. У фронтенда контракт — восприятие человека: пользователь не вызывает функцию, он видит кнопку, нажимает и ждёт реакции. Между вашим кодом и этим восприятием лежат три слоя, которых в обычном юнит-тесте просто нет: браузерный движок с раскладкой, асинхронность (сеть, анимации, дебаунсы) и визуальное представление. Отсюда все специфические болезни фронтовых наборов тестов — от «локально зелёное, на CI красное» до трёхсот снапшотов, которые никто не читает.

Общую теорию тестирования — уровни, техники тест-дизайна, критерии покрытия — подробно разбирает трек «Тестирование». Здесь только то, что специфично для браузера и React-экосистемы.

Четыре класса ошибок и четыре инструмента

Прежде чем спорить про пирамиды и трофеи, разложим баги интерфейса по природе. Классов ровно четыре, и у каждого свой ловец:

  1. Логическая ошибка. Скидка считается неверно, редьюсер теряет элемент, форматтер даты падает на 29 февраля. Ловится юнит-тестом чистой функции — микросекунды, полная надёжность.
  2. Ошибка взаимодействия. Кнопка «Отправить» не блокируется на время запроса, форма не показывает ошибку валидации, после удаления строки фокус улетает в <body>. Ловится компонентным тестом.
  3. Ошибка интеграции. Фронтенд ждёт total_price, бэкенд отдаёт totalPrice. Роутер не восстанавливает состояние после перезагрузки. Токен протух, и никто его не обновил. Ловится E2E.
  4. Визуальная ошибка. Логически всё работает, но кнопка уехала за границу карточки, а в тёмной теме текст потерял контраст. Не ловится ничем из перечисленного — нужна визуальная регрессия.

Главный вывод: классы не заменяют друг друга. Сто юнит-тестов не заметят, что кнопка оплаты стала невидимой на мобильном. Двести 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 задаёт явный порядок предпочтений:

  1. getByRole — роль плюс доступное имя: так элемент видят и человек, и скринридер.
  2. getByLabelText — для полей формы.
  3. getByPlaceholderText, getByText, getByDisplayValue — когда роли не хватает.
  4. getByAltText, getByTitle — изображения и подсказки.
  5. 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-схемой, что в проде — как в примере выше.

Асинхронность и флейки

Флейк — тест, который на одном и том же коде иногда красный, иногда зелёный. Это главный убийца доверия к набору: после третьего «перезапусти, оно само пройдёт» люди перестают читать красные сборки.

Правила, которые убирают девять флейков из десяти:

  • Никогда не ждите время — ждите условие. 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 вносит мелкие изменения (>>=, truefalse, удаление вызова) и смотрит, упадут ли тесты. Выжившая мутация — прямое доказательство, что код покрыт формально, но не проверен. На всём проекте это дорого; гоняйте раз в спринт по критичным модулям: расчёты, права доступа, валидация.

Тесты в 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 окупается, когда истории переиспользуются тестами — тогда они не протухают.
  • Покрытие — диагностика, а не цель; качество утверждений измеряет только мутационное тестирование.

Источники

Что дальше

Мы прошли путь от браузера до тестов и научились удерживать качество интерфейса под контролем. Остался вопрос, с которого многие начинают, но честно ответить на который можно только теперь, зная цену рендеринга, состояния, сборки и тестирования: а на чём вообще писать?

Сравнение фреймворков: React, Vue, Svelte, Angular, Solid — что выбрать

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов