Как написать первый тест на Playwright и не сделать его flaky
Первый e2e-тест на Playwright почти всегда зеленеет на ноуте и красит CI каждые третьи сутки. Через пару недель команда тихо ставит в Slack эмодзи рядом с названием прогона и начинает «перезапускать пайплайн до победного». Это не «нестабильность Playwright», это набор простых ошибок, которые легко избежать на старте.
За семь лет автотестов я разбирала эту картину десятки раз. Симптомы одинаковые: жёсткие таймауты, неустойчивые селекторы, общий тестовый аккаунт на двадцать параллельных воркеров. Дальше — что попробовала, что взлетело, что не зашло.
Базовая настройка проекта
Поставить Playwright проще, чем потом разгребать неудачные конфиги. Минимальный набор:
npm init -y
npm i -D @playwright/test typescript
npx playwright install --with-deps chromium
В tsconfig.json сразу включаю строгий режим — без него типизация локаторов и фикстур деградирует до «оно как-нибудь работает».
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Конфиг Playwright — то место, где половина флакаев лечится одной строкой:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Здесь два важных решения. Во-первых, retries только на CI — локально тебе нужны падения, а не зелёный «второй прогон». Во-вторых, trace: 'retain-on-failure' — без трейсов разбор флакая превращается в гадание по логам.
Локаторы — главное место, где рождаются flaky
Селекторы вида div.card > .btn-primary:nth-child(3) — гарантированный билет в ад. Любой рефакторинг разметки красит тесты, дизайнер передвинул блок — половина прогонов в красном.
Я давно перешла на getByRole, getByLabel и getByText. Они привязаны к семантике, а не к дереву DOM. Тест выглядит так:
import { test, expect } from '@playwright/test';
test('пользователь логинится с валидными кредами', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Пароль').fill('Hunter2!Hunter2');
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('heading', { name: 'Личный кабинет' })).toBeVisible();
});
Если на форме нет label и нет ARIA — это уже баг доступности, и его надо чинить, а не обходить тестом. С таким подходом локаторы становятся документацией: читая их, ты понимаешь, что видит пользователь, а не где CSS-разработчик решил поставить класс.
page.waitForTimeout — анти-паттерн
Самая частая причина flaky первого теста — page.waitForTimeout(2000). Локально страница грузится за 200 мс, в CI — за 2.5 секунды, тест падает. Накручиваешь таймаут до 5 секунд, прогон идёт час, через неделю кто-то опять ловит флакай.
Playwright умеет ждать сам. Вызовы fill, click, expect(...).toBeVisible() ретраются внутри своего таймаута. Если нужно дождаться конкретного события, есть явные ожидания:
// Дождаться ответа API
const responsePromise = page.waitForResponse((res) =>
res.url().includes('/api/orders') && res.status() === 200,
);
await page.getByRole('button', { name: 'Создать заказ' }).click();
const response = await responsePromise;
const body = await response.json() as { id: string };
await expect(page.getByText(`Заказ #${body.id}`)).toBeVisible();
Никаких setTimeout, никакого «подожду на всякий случай». Если для синхронизации действительно нужен sleep, обычно это симптом проблемы в продукте, а не в тесте.
Изоляция тестовых данных
Сценарий из жизни: четыре теста логинятся под одним аккаунтом, параллельный запуск ловит race на корзине. Один тест добавил товар, другой проверил, что корзина пустая. Красное в тестах, зелёное в продукте, продакт-оунер недоволен.
Правило: каждый тест получает свой набор данных. Самый дешёвый способ — фикстуры с генерацией свежего пользователя:
import { test as base } from '@playwright/test';
import { randomUUID } from 'node:crypto';
type Fixtures = {
freshUser: { email: string; password: string };
};
export const test = base.extend<Fixtures>({
freshUser: async ({ request }, use) => {
const email = `qa+${randomUUID()}@example.com`;
const password = 'Hunter2!Hunter2';
await request.post('/api/test/users', {
data: { email, password },
});
await use({ email, password });
await request.delete(`/api/test/users/${encodeURIComponent(email)}`);
},
});
На бекенде заводишь служебный эндпоинт, доступный только в test-окружении (например, по заголовку X-Test-Token). Без таких эндпоинтов параллельный e2e упирается в гонки на тестовом аккаунте, и все «спасательные» retry-стратегии маскируют реальные баги.
Сеть, моки и реальность
Дебаты «мокать ли API в e2e» — старые. Я придерживаюсь компромисса: основной flow — на реальном бекенде в тестовом контуре, отдельные негативные сценарии (500-ка, таймаут) — через page.route:
test('обработка 500 при оплате', async ({ page }) => {
await page.route('**/api/payments', (route) => {
void route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'internal' }),
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Оплатить' }).click();
await expect(
page.getByRole('alert').filter({ hasText: 'попробуйте позже' }),
).toBeVisible();
});
На сценариях, где API замокан целиком, тестируешь не продукт, а собственные моки. На сценариях без моков — попадаешь в зависимость от стабильности тестового бекенда. Поровну распределить — задача без универсального решения, ориентируйся на пирамиду тестов.
Trace Viewer и разбор падений
Если уже включила trace: 'retain-on-failure', разбор красного теста занимает минуты, а не часы. Открываешь:
npx playwright show-trace trace.zip
В трейсе — все действия, скриншоты до/после, сетевые запросы, консоль. По этой картинке сразу видно, чего не дождался тест: элемент скрыт, запрос ушёл с другим телом, фронт показал нотификацию об ошибке. На дашборде Allure я держу отдельную метрику flaky-rate — отношение тестов, которые упали и потом прошли по retry. Если по конкретному тесту flaky-rate выше 2%, он идёт в карантин и переписывается, а не «ну ещё подожду».
Что попробовала и не зашло
- Глобальный
setDefaultTimeout(60_000). Маскирует медленные места продукта. Через месяц прогон длится час, никто не понимает, какой шаг тормозит. - Авто-retry на уровне теста через
test.describe.configure({ retries: 5 }). Превращает flaky в фоновую боль. Лучше один retry в CI и карантин для нестабильных. - Чистка БД скриптом перед всем прогоном. Ломает параллельность: тесты конфликтуют за один и тот же набор данных. Изоляция per-test надёжнее.
- Ожидание по
page.waitForLoadState('networkidle'). На SPA с фоновыми ping-запросами этот стейт может не наступить никогда. Используй ожидания конкретных ответов.
Чек-лист первого теста
strict: trueвtsconfig.json.- Локаторы только через
getByRole,getByLabel,getByText. - Никаких
waitForTimeout. Любой sleep — повод поговорить с разработчиком. - Свежие данные на каждый тест через фикстуры и API-хелперы.
trace: 'retain-on-failure'иscreenshot: 'only-on-failure'в конфиге.- Retries — только на CI, не больше двух.
- Мониторинг flaky-rate на уровне отчёта, а не «глазами по логам».
Дальше копать — в сторону параллельного запуска воркеров без гонок на тестовых данных и контрактных тестов между фронтом и API. Без этого e2e на десяти-двадцати тестах ещё держится, а на сотне начинает разваливаться.