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

Тестирование вглубь: Playwright, visual, mutation, Lighthouse CI

senior~40 мин

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

Тестирование вглубь: Playwright, visual, mutation, Lighthouse CI

В уроке 08 мы заложили интеграционную базу: RTL, Vitest, MSW, поиск по роли. Этого хватает на первый год проекта. Дальше начинается всё, что добивает остальные 20 процентов багов: E2E, который проверяет страницу целиком, визуальная регрессия, которая ловит сдвиги вёрстки, mutation-тестирование, которое ловит слабость самих тестов, и бюджеты производительности в CI, которые не дают релизу тихо просесть. Этот заключительный урок серии 07 это сборка всего пирога вокруг Echo.

1. Пирамида и трапеция: цена решает

Вернёмся к развилке из урока 08. Классическая пирамида много unit, чуть integration, ещё меньше E2E, опирается на простую идею: чем выше слой, тем тест дороже и хрупче, поэтому их меньше. Канон фронта последних лет немного другой, трофей: толстая середина integration, тонкая верхушка E2E. Поиграй с цифрами, и видно почему: 100 unit плюс 50 integration плюс 10 E2E стоят дешевле на ловлю, чем обратное.

Пирамида тестов: цена и ловля
пресет:
всего тестов160CI126.0sусловные ловли багов155CI на ловлю0.81s

Классическая пирамида: дёшево и быстро ловит большинство багов.

Смысл виджета не в точных цифрах (они условные), а в форме графика. E2E дорогой сразу по двум осям: время CI и вероятность флакки. Один E2E ловит больше багов одного unit, но десять E2E ловят меньше, чем десять unit плюс пять integration, потому что забивают CI и роняются на сетевых дёрганиях. Хорошая стратегия не покупает один слой за счёт другого, она расставляет их по цене за пойманную регрессию.

Что взять с собой

  • Пирамида и трапеция это про распределение тестов по слоям с учётом цены.
  • E2E дорогой и флакки, его держат тонким слоем поверх толстого integration.
  • Метрика выбора не количество, а CI на одну ловлю плюс стабильность.

2. Двойники по Meszaros

Когда тестируешь модуль, у него обычно есть соседи. Заменить соседа на контролируемую подделку это тестовый двойник. Жерар Месарош разделил их на пять видов, и Vitest умеет каждый:

  • dummy это объект, который передают, но не используют. В AuthGuard-тесте логгер не вызывается, передадим {} as Logger.
  • stub возвращает заранее заданное значение. vi.fn().mockReturnValue(user).
  • fake это упрощённая, но работающая реализация. In-memory MessagesRepo с Map вместо настоящей БД, та же сигнатура.
  • spy записывает вызовы для проверки факта: vi.spyOn(console, 'error').
  • mock это spy с заранее настроенным ожиданием: expect(send).toHaveBeenCalledWith({ channelId: 'general', text: 'hi' }).
// dummy
const logger = {} as Logger;
render(<AuthGuard logger={logger}>secret</AuthGuard>);

// stub
const getUser = vi.fn().mockReturnValue({ id: 'u1', name: 'Vasya' });

// fake
class InMemoryRepo implements MessagesRepo {
  private store = new Map<string, Message[]>();
  list(channelId: string) { return this.store.get(channelId) ?? []; }
  add(channelId: string, message: Message) {
    this.store.set(channelId, [...this.list(channelId), message]);
  }
}

// spy
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

// mock
const send = vi.fn();
await userEvent.type(input, 'hi');
await userEvent.click(button);
expect(send).toHaveBeenCalledWith({ channelId: 'general', text: 'hi' });

Правило: подменяешь поведение, бери stub или fake. Проверяешь взаимодействие, бери spy или mock. И не подменяй то, что можно протестировать вживую через MSW: моки на уровне сети надёжнее, чем моки клиента.

Что взять с собой

  • Двойники: dummy, stub, fake, spy, mock. Граница по намерению, не API.
  • Поведение подменяй (stub/fake), взаимодействие проверяй (spy/mock).
  • Что можно мокнуть на уровне сети через MSW, мокни там: тест выживет смену HTTP-клиента.

3. Playwright: fixtures, storageState, trace

Playwright поднимает настоящий браузер и проигрывает сценарий пользователя. Конфиг минимальный, дальше идут три приёма, на которых держится скорость и надёжность E2E.

Конфиг:

// e2e/playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  use: {
    baseURL: 'http://localhost:4173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  webServer: {
    command: 'pnpm preview',
    port: 4173,
  },
});

Первый приём: fixtures, переиспользуемые pre-conditions. Заводим залогиненную страницу один раз:

// e2e/fixtures.ts
import { test as base } from '@playwright/test';
import type { Page } from '@playwright/test';

type EchoFixtures = { authedPage: Page };

export const test = base.extend<EchoFixtures>({
  authedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('[name=email]', 'alice@example.com');
    await page.click('button[type=submit]');
    await page.waitForURL('/general');
    await use(page);
  },
});

export { expect } from '@playwright/test';

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

// e2e/send-message.spec.ts
import { test, expect } from './fixtures';

test('пользователь отправляет сообщение', async ({ authedPage }) => {
  await authedPage.fill('[aria-label=Сообщение]', 'привет');
  await authedPage.click('button:has-text("Отправить")');
  await expect(authedPage.locator('text=привет').last()).toBeVisible();
});

Второй приём: storageState: залогинились один раз перед всем прогоном, дальше тесты стартуют уже авторизованными.

// e2e/global-setup.ts
import { chromium } from '@playwright/test';

export default async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:4173/login');
  await page.fill('[name=email]', 'alice@example.com');
  await page.click('button[type=submit]');
  await page.waitForURL('**/general');
  await page.context().storageState({ path: 'e2e/.auth.json' });
  await browser.close();
}

В конфиге: use: { storageState: 'e2e/.auth.json' }. Логин случается один раз за весь прогон, не на каждый тест.

Третий приём: чат это сценарий с двумя пользователями. Playwright умеет несколько контекстов в одном тесте, каждый со своими cookie:

test('второй пользователь видит сообщение', async ({ browser }) => {
  const aliceCtx = await browser.newContext({ storageState: 'e2e/.alice.json' });
  const bobCtx = await browser.newContext({ storageState: 'e2e/.bob.json' });
  const alice = await aliceCtx.newPage();
  const bob = await bobCtx.newPage();
  await Promise.all([alice.goto('/general'), bob.goto('/general')]);

  await alice.fill('[aria-label=Сообщение]', 'привет от alice');
  await alice.click('button:has-text("Отправить")');

  await expect(bob.locator('text=привет от alice')).toBeVisible({ timeout: 5000 });
});

И последнее: Trace Viewer. Включён в конфиге (trace: 'on-first-retry'). Когда тест упал, скачиваешь артефакт из CI, открываешь локально:

npx playwright show-trace trace.zip

Получаешь скриншот-ленту каждого шага, состояние DOM, сетевые запросы, консольные ошибки. Большая часть флакки разбирается за две минуты вместо часа гадания.

Концептуально trace-лента выглядит так:

01  navigate /login                ✓  page.html screenshot
02  fill   [name=email]            ✓  alice@example.com
03  click  button[type=submit]     ✓  POST /api/auth/request 200
04  waitForURL /general            ✓  page.html screenshot
05  fill   [aria-label=Сообщение]  ✓  привет
06  click  button[Отправить]       ✓  POST /api/messages 201
07  expect text=привет visible     ✗  timed out after 5000ms
                                       (DOM at fail: <ul/> пустой)

Каждая строка кликабельна и показывает DOM-снимок и сеть на этот момент.

Что взять с собой

  • Конфиг минимальный, дальше держится на fixtures, storageState и trace.
  • fixtures сушат повтор pre-conditions, тесты читаются как сценарии.
  • storageState снимает логин в каждом тесте, экономит минуты на наборе.
  • Чат-сценарии пишут через несколько browser.newContext в одном тесте.
  • trace: 'on-first-retry' плюс show-trace это спасение от флакки.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.