Видимая цена флакующего теста: время потраченное на повторный запуск CI. Реальная цена: момент когда команда перестаёт считать красные сборки достойными расследования, потому что как только несколько падений становятся «наверное флакующими», реальные баги получают такое же отношение. Здесь разобраны пять корневых причин нестабильных падений в Playwright: асинхронный тайминг, загрязнение тестов, разделяемое состояние, сетевые зависимости и нестабильность селекторов, с диагностическими шагами и конкретными исправлениями для каждой.

Реальная цена флакающих тестов

Очевидная цена: время. Разработчики перезапускают пайплайны, тестировщики расследуют падения оказывающиеся ничем, инженеры тратят пятничный вечер на бисекцию теста который «просто начал чудить». Это накапливается быстро. Консервативная оценка для одного стабильно флакующего теста в занятой команде: 30–60 минут расследования в неделю.

Скрытая цена хуже. Когда падения ненадёжны, каждое падение становится подозрительным. Реальные баги отклоняются. Инстинкт реагировать на красную сборку (именно для этого и создавался CI) разрушается. В итоге тест-сьют зелёный на merge, красный на main, и никто не моргнёт.

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

Исправление начинается с честного взгляда на корневые причины, а не с --retries.

Гонки состояний и асинхронный тайминг: причина номер один

Подавляющее большинство флакующих тестов в Playwright происходит из проблем с таймингом. Тест пытается кликнуть кнопку до того как она готова, или утверждает о тексте до того как завершился сетевой запрос его заполняющий. На быстрой машине работает. На медленном CI-раннере нет.

Инстинкт подсказывает добавить sleep:

// Неправильное исправление: всё равно флакует, просто медленнее
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();

Это делает тест на три секунды медленнее и всё равно падает в плохой день на CI. Одну проблему заменили двумя.

Playwright автоматически ждёт большинство вещей сам. При вызове locator.click() Playwright ждёт пока элемент станет видимым, стабильным и не перекрытым. Тест становится флакующим только когда это поведение обходят или когда ждёшь чего-то чего Playwright не знает: завершения анимации или исчезновения спиннера.

Правильное исправление: ждать конкретного условия которое должно выполниться до действия.

// Ждать исчезновения спиннера загрузки перед взаимодействием
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Save' }).click();

// Ждать сетевого ответа заполняющего страницу перед ассертом
await page.waitForResponse(
  (resp) => resp.url().includes('/api/products') && resp.status() === 200
);
await expect(page.getByRole('table')).toBeVisible();

// Ждать пока кнопка станет активной после валидации формы
const saveButton = page.getByRole('button', { name: 'Save' });
await expect(saveButton).toBeEnabled();
await saveButton.click();

Каждое из этих ожиданий ждёт реального условия, а не угадывает длительность. Playwright опрашивает условие с настраиваемым таймаутом (30 секунд по умолчанию), поэтому тест надёжен и работает так быстро как позволяет приложение.

Если пишешь waitForTimeout больше раза в неделю, это code smell. Каждый такой вызов превращается в флакующий тест под нагрузкой. Заменяй каждый на ожидание по условию.

Порядок тестов и разделяемое состояние

Тесты которые проходят поодиночке но падают при запуске полного сьюта почти всегда оставляют за собой состояние. Один тест создаёт запись, следующий спотыкается о неё. Один тест устанавливает куку, следующий ведёт себя иначе из-за неё. Один тест изменяет настройки пользователя, и все последующие тесты для этого пользователя теперь в неожиданном состоянии.

// Этот тест оставляет "Test Item" в базе каждый раз при запуске
test('add item to inventory', async ({ page }) => {
  await page.goto('/inventory');
  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Name').fill('Test Item');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('Test Item')).toBeVisible();
  // Ничего не очищается
});

// Этот тест падает если предыдущий запустился первым: находит 2 записи вместо 1
test('inventory shows one item', async ({ page }) => {
  await page.goto('/inventory');
  await expect(page.getByRole('row')).toHaveCount(2); // 1 строка данных + 1 заголовок
});

Решение: изоляция. Каждый тест должен настраивать своё состояние и очищать его после. Фикстуры Playwright правильный инструмент: они запускают настройку перед каждым тестом и очистку после, даже если тест упал.

import { test as base } from '@playwright/test';

type TestFixtures = {
  testItem: { id: string; name: string };
};

const test = base.extend<TestFixtures>({
  testItem: async ({ request }, use) => {
    // Создаём запись перед тестом
    const response = await request.post('/api/inventory', {
      data: { name: `Test Item ${Date.now()}` },
    });
    const item = await response.json();

    await use(item); // выполняем тест

    // Очищаем после, даже если тест упал
    await request.delete(`/api/inventory/${item.id}`);
  },
});

test('inventory item shows detail page', async ({ page, testItem }) => {
  await page.goto(`/inventory/${testItem.id}`);
  await expect(page.getByRole('heading', { name: testItem.name })).toBeVisible();
});

Фикстуры гарантируют выполнение teardown. Блоки afterEach не запускаются если тест падает во время настройки. Фикстуры запускаются. Это разница между изоляцией которая работает в основном и изоляцией которая работает всегда.

Флакующие тесты зависящие от окружения

Некоторые тесты прекрасно работают на MacBook и падают через раз в GitHub Actions. Разница в окружении делает свою работу. Частые виновники:

Часовой пояс. new Date() возвращает разные значения в зависимости от места запуска теста. Тест утверждающий о форматированной строке даты упадёт в CI если раннер в UTC а локальная машина в UTC+3.

// Флакует: зависит от локального часового пояса машины
const today = new Date().toLocaleDateString('en-GB');
await expect(page.getByTestId('report-date')).toHaveText(today);

// Стабильно: явно фиксируем локаль и часовой пояс
const today = new Date().toLocaleDateString('en-GB', { timeZone: 'UTC' });
await expect(page.getByTestId('report-date')).toHaveText(today);

Случайные тестовые данные. Если генерировать ID или имена без seed, параллельные запуски тестов могут столкнуться на одном значении или получить значения случайно совпадающие с существующими записями.

// Рискованно при параллельном запуске: два воркера могут сгенерировать одинаковый ID в одну миллисекунду
const id = Date.now();

// Лучше: комбинируем временную метку с индексом воркера
const id = `${Date.now()}-${workerInfo.workerIndex}`;

Viewport и разрешение. Элементы видимые при 1920px могут быть скрыты за бургер-меню при viewport по умолчанию в CI. Задай единый viewport в playwright.config.ts и не полагайся на адаптивные breakpoints если не тестируешь их явно.

// playwright.config.ts
export default defineConfig({
  use: {
    viewport: { width: 1280, height: 720 },
  },
});

Нестабильность селекторов

Динамические имена классов и сгенерированные ID: ловушка. Фреймворки вроде Tailwind и CSS Modules генерируют имена классов включающие хеши содержимого. Скомпилированные приложения иногда генерируют ID элементов на основе порядка сборки. Селектор работавший вчера ломается после обновления зависимости.

// Хрупко: это имя класса сгенерировано и изменится
await page.locator('.tw-btn-primary-3af82').click();

// Хрупко: сгенерированный ID, бессмысленный и нестабильный
await page.locator('#ember-423').click();

// Тоже хрупко: nth-child зависит от порядка
await page.locator('ul > li:nth-child(3)').click();

Семантические локаторы Playwright привязывают селектор к тому что элемент делает, а не к тому как он стилизован или структурирован. Они стабильны при рефакторинге потому что отражают семантику видимую пользователю, а не детали реализации.

// Стабильно: роль + доступное имя
await page.getByRole('button', { name: 'Submit' }).click();

// Стабильно: текст лейбла
await page.getByLabel('Email address').fill('user@example.com');

// Стабильно: test ID (добавь data-testid к элементу если нужно)
await page.getByTestId('submit-button').click();

// Стабильно: видимый текст
await page.getByText('Order confirmed').waitFor();

Атрибуты data-testid: намеренный контракт между тестом и приложением. Они переживают CSS-рефакторинг, изменения вёрстки и обновления фреймворка. Если в приложении их ещё нет, начни добавлять к высокоценным интерактивным элементам.

Когда приходится писать CSS или XPath-селектор, ограничивай область видимости и привязывай к стабильному родителю:

// Ограничен именованной секцией, а не всей страницей
const orderSummary = page.getByTestId('order-summary');
await expect(orderSummary.getByRole('cell', { name: 'Total' })).toBeVisible();

Тесты зависящие от сети

Тесты обращающиеся к реальным внешним сервисам нестабильны по природе. Сторонний API может быть медленным, ограниченным по частоте запросов или временно недоступным. Тест вызывающий живой Stripe API в CI не тестирует твой код. Он тестирует доступен ли Stripe.

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

Для внешних API мокируй на уровне сети:

test('checkout completes with payment confirmation', async ({ page }) => {
  // Перехватываем вызов Stripe API и возвращаем контролируемый ответ
  await page.route('**/api/stripe/charge', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'ch_test_123',
        status: 'succeeded',
        amount: 4999,
      }),
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByText('Payment confirmed')).toBeVisible();
});

Для медленных внутренних эндпоинтов используй page.waitForResponse с увеличенным таймаутом вместо надежды что ответ придёт за стандартный таймаут:

test('large report generates successfully', async ({ page }) => {
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Generate Report' }).click();

  // Ждём до 60 секунд для этого конкретно медленного эндпоинта
  await page.waitForResponse(
    (resp) => resp.url().includes('/api/reports/generate') && resp.status() === 200,
    { timeout: 60_000 }
  );

  await expect(page.getByRole('link', { name: 'Download Report' })).toBeVisible();
});

Если тестируешь против реального API которым владеешь, рассмотри выделенное тестовое окружение которое можно сбрасывать между прогонами. Тестовая база данных с известным состоянием перед каждым прогоном устраняет целый класс флакающих тестов.

Ловушка retry

Playwright поддерживает автоматические retry, и они реально полезны, но при этом самый часто неправильно используемый инструмент в арсенале борьбы с флакующими тестами.

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
});

Эта конфигурация разумна как последняя линия защиты против реальной нестабильности инфраструктуры: мимолётный сетевой сбой в CI, CI-раннер который иногда не запускает браузер. Это не исправление тестов с реальными проблемами.

Суть retry: тест который проходит с третьей попытки всё равно потребляет время первых двух падений. С retries: 2 сьют занимающий 10 минут при чистых прогонах может занять 25–30 минут когда несколько тестов флакуют. Падения скрыты, пайплайн стал хуже.

Retry допустимы когда нестабильность явно связана с инфраструктурой (CI-раннеры, сбои запуска браузера, сетевые таймауты к собственным сервисам в том же датацентре), ты расследовал и подтвердил что в коде теста проблемы нет, или они нужны как временная мера пока готовится более глубокое исправление.

Retry вредят когда это первая реакция на новый флакующий тест, когда они скрывают проблемы с таймингом или изоляцией в коде теста, или когда их количество увеличивается со временем по мере ухудшения сьюта.

// Неправильно: маскировка реальной проблемы с таймингом
export default defineConfig({
  retries: 5, // Тест продолжал падать, просто добавили больше retry
});

// Правильно: retry как страховочная сетка с фиксированным низким лимитом
export default defineConfig({
  retries: process.env.CI ? 1 : 0,
  // Реальные проблемы с таймингом и изоляцией исправлены в коде тестов
});

Если retry увеличивается со временем а не уменьшается, проблема с флакующими тестами ухудшается а не решается. Текущее число retry: метрика здоровья. Оно должно стремиться к нулю.

Систематическое расследование: как диагностировать флакующий тест

Когда тест начинает падать нестабильно, работай по этой последовательности а не угадывай и не подстраивай наугад.

Шаг 1: воспроизведи детерминированно

Запусти тест 20 раз подряд:

npx playwright test tests/checkout.spec.ts --repeat-each=20

Посчитай падения. Тест падающий 1 из 20 раз слабо флакующий. Тест падающий 15 из 20 имеет реальную проблему. Это также говорит сколько усилий стоит исправление: частота сбоев 5% в сьюте запускаемом 50 раз в день всё равно бьёт 2–3 раза в день.

Шаг 2: изолируй

Запусти только этот тестовый файл. Если надёжно проходит в изоляции но падает в полном сьюте, проблема в загрязнении тестами запускающимися перед ним.

# Запуск в изоляции
npx playwright test tests/checkout.spec.ts

# Запуск в порядке сьюта для воспроизведения загрязнения
npx playwright test --workers=1

Шаг 3: захвати трассировку

Включи трассировку для падений и посмотри на точный момент поломки теста:

// playwright.config.ts
export default defineConfig({
  use: {
    trace: 'on-first-retry',
  },
  retries: 1, // один retry чтобы захватить трассировку
});

npx playwright test tests/checkout.spec.ts
npx playwright show-report

Trace viewer показывает таймлайн со скриншотами до/после для каждого действия, все сетевые запросы и консольные логи. В большинстве случаев точка падения в трассировке сразу раскрывает проблему: тайминг, отсутствующий элемент или неожиданный сетевой ответ.

Шаг 4: запусти headed в замедленном режиме

Если трассировка неоднозначна, посмотри как запускается тест:

npx playwright test tests/checkout.spec.ts --headed --slow-mo=500

Замедленный режим добавляет паузу 500мс между действиями. То что выглядит мгновенным при обычном запуске становится видимым, и часто видишь точный момент когда UI не готов к следующему взаимодействию.

Шаг 5: проверь что запускается перед ним

Если тест изоляции выявил загрязнение, найди предшествующий тест:

# Запуск с одним воркером для детерминированного порядка, затем проверь какой тест запустился перед упавшим
npx playwright test --workers=1 --reporter=list

Ищи тесты в предшествующем файле которые создают записи, устанавливают куки или изменяют состояние приложения без очистки.

Шаг 6: применяй правильное исправление

На основе найденного:

  • Проблема с таймингом: замени waitForTimeout на ожидание по условию
  • Загрязнение тестами: добавь очистку в afterEach или преобразуй настройку/очистку в фикстуры
  • Нестабильность селекторов: перейди на getByRole, getByLabel или getByTestId
  • Сетевая зависимость: замокай внешний вызов через page.route
  • Разница в окружении: зафикси часовой пояс, viewport и любые значения варьирующиеся между машинами

Большинство флакующих тестов решается на шаге 2 (изоляция) или шаге 3 (trace viewer). Расследование редко доходит до шага 6.

FAQ

Как понять флакующий тест или он поймал реальный баг?

Запусти 10 раз на одном коммите без изменений кода. Упал 1–3 раза из 10: флакующий. Упал стабильно (7 и более из 10): скорее всего поймал реальную регрессию. Различие важно: флакующие тесты требуют расследования, стабильные падения требуют исправления бага.

Тест падает только в CI, локально нет. Что отличается?

CI-раннеры обычно медленнее, работают в headless-режиме и в другом часовом поясе. Самые частые причины специфичные для CI: проблемы с таймингом которые маскирует локальное железо (страница грузится достаточно быстро чтобы гонка состояний никогда не срабатывала), различия headless-рендеринга для анимаций, и несоответствия часовых поясов в ассертах о датах. Запусти локально с --slow-mo=500 для симуляции медленной машины, и перепроверь форматирование дат на предположения о часовом поясе.

Использовать test.skip или test.fixme для известного флакующего теста?

test.skip исключает его полностью. test.fixme помечает как ожидаемо падающий: тест всё равно запускается, ожидается что он упадёт, и становится видимым оповещением если начнёт проходить (что может означать изменение underlying-проблемы). Для реально флакующего теста без немедленного исправления test.skip с комментарием объясняющим почему и ссылкой на задачу отслеживания лучший выбор. Необъяснённый test.fixme просто создаёт путаницу.

Добавил data-testid но тест всё равно флакует. Что ещё проверить?

Стабильный селектор не гарантирует стабильный тест. После исправления селектора проверь: элемент обрабатывается до готовности (тайминг), есть ли конфликтующее состояние из другого теста (изоляция), проходит ли тест в изоляции но падает в сьюте (загрязнение). Стабильность селекторов и изоляция тестов: разные проблемы.

У нас 40 флакующих тестов. С чего начать?

Сортируй по частоте падений, а не по тому насколько раздражают. Исправляй тесты которые падают чаще всего первыми: они больше всего деградируют надёжность CI. По мере исправления проявятся паттерны: если 15 из них имеют одну корневую причину (спиннер который должен исчезнуть перед взаимодействиями), единый паттерн исправления применяется ко всем.

→ See also: Отладка нестабильных тестов: практическое руководство | Стратегии ожидания в Playwright: без sleep() | Изоляция тестов: почему каждый тест Playwright должен быть stateless