lenec ru

← все посты

Как написать первый тест на Playwright и не сделать его flaky

13K

Первый 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-запросами этот стейт может не наступить никогда. Используй ожидания конкретных ответов.

Чек-лист первого теста

  1. strict: true в tsconfig.json.
  2. Локаторы только через getByRole, getByLabel, getByText.
  3. Никаких waitForTimeout. Любой sleep — повод поговорить с разработчиком.
  4. Свежие данные на каждый тест через фикстуры и API-хелперы.
  5. trace: 'retain-on-failure' и screenshot: 'only-on-failure' в конфиге.
  6. Retries — только на CI, не больше двух.
  7. Мониторинг flaky-rate на уровне отчёта, а не «глазами по логам».

Дальше копать — в сторону параллельного запуска воркеров без гонок на тестовых данных и контрактных тестов между фронтом и API. Без этого e2e на десяти-двадцати тестах ещё держится, а на сотне начинает разваливаться.

Комментарии 1

  • Ирина Лисицына

    Из практики е-commerce: половина flaky тестов на чекауте у нас были не из-за гонок в UI, а из-за платёжного провайдера. Sandbox 3DS отвечал то 200ms, то 8s, и тест падал по таймауту, хотя UI был готов. Лечение оказалось скучным — мокать sandbox на детерминированный ответ через MSW и оставить один full e2e на ночной прогон. Интересно, как вы режете эту границу для своей команды: где у вас тест-логика бэкенда, а где честный e2e? Это и есть тот момент, где flaky обычно зарождается.

Войдите, чтобы оставить комментарий.