Тестирование вглубь: 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 стоят дешевле на ловлю, чем обратное.
Классическая пирамида: дёшево и быстро ловит большинство багов.
Смысл виджета не в точных цифрах (они условные), а в форме графика. 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.