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

Почему флакующие тесты хуже отсутствия тестов

Когда тест падает стабильно, его чинят. Когда тест падает случайно, команда начинает игнорировать красные сборки. «Это, наверное, снова флакующий тест логина» становится стандартным ответом на падения в CI. В итоге реальный баг проходит незамеченным, потому что никто не воспринял красную сборку всерьёз.

Флакующие тесты подрывают доверие ко всему тест-сьюту. Именно поэтому их исправление оправдано даже когда сам тест не критичен.

Наиболее частые причины

До отладки нужно знать что искать. Флакующие тесты почти всегда происходят из одного из пяти мест.

Проблемы с таймингом. Самая частая причина с большим отрывом. Тест пытается взаимодействовать с элементом до того как он готов: до появления, до активации, до завершения анимации. Тест проходит когда страница загружается быстро и падает когда загружается медленно. Загрязнение тестов. Один тест оставляет состояние которое ломает следующий. Созданная запись, забытая кука, изменённое значение в localStorage. Тесты которые проходят поодиночке но падают в сьюте: это почти всегда оно. Разделяемые тестовые данные. Два теста запускаются параллельно и оба пытаются использовать или изменить одну и ту же запись. Один выигрывает, другой падает. Сетевые зависимости. Тест делает реальный API-вызов который иногда истекает по таймауту или возвращает неожиданные данные. Нестабильный порядок элементов. Тест предполагает что элементы появляются в определённом порядке (первая строка, вторая кнопка), но порядок не гарантирован.

Начни с Playwright Trace Viewer

До изменения любого кода воспроизведи падение и захвати трассировку. Trace viewer: самый мощный инструмент отладки Playwright. Он записывает каждое действие, сетевой запрос и снимок DOM во время прогона теста.

Включи трассировку в playwright.config.ts:

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

Запусти тесты, затем открой отчёт:

npx playwright test
npx playwright show-report

Кликни на упавший тест. Trace view показывает таймлайн каждого действия со скриншотами до и после. Видно точно какой шаг упал, как выглядела страница в этот момент, и какие сетевые запросы выполнялись.

Это одно разрешает примерно половину расследований флакающих тестов без каких-либо догадок.

Исправление проблем с таймингом

Проблемы с таймингом выглядят так в выводе ошибки:

Error: locator.click: Timeout 30000ms exceeded.
waiting for getByRole('button', { name: 'Submit' })

Или:

Error: expect(locator).toBeVisible()
Received: hidden

Инстинкт подсказывает добавить ожидание. Неправильное исправление:

// Плохо: угадываем сколько ждать
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();

Это делает тест медленнее и всё равно флакующим. Иногда 2 секунд недостаточно.

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

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

// Ждать пока кнопка станет активной
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'visible' });
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();

// Ждать завершения сетевого запроса
await page.waitForResponse(resp =>
  resp.url().includes('/api/items') && resp.status() === 200
);

Встроенное автоожидание Playwright справляется с большинством случаев автоматически. Когда автоожидания недостаточно, жди конкретной вещи, а не фиксированного времени.

Исправление загрязнения тестов

Если тесты проходят поодиночке но падают в сьюте, проблема почти наверняка в утечке состояния между тестами.

Проверь эти источники загрязнения:

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

// Очищаем хранилище перед каждым тестом в файле
test.beforeEach(async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.evaluate(() => {
    localStorage.clear();
    sessionStorage.clear();
  });
});

Состояние базы данных. Если тесты создают записи и не очищают их, последующие тесты видят неожиданные данные.

test.afterEach(async ({ request }) => {
  // Удаляем тестовую запись созданную во время теста
  await request.delete('https://lab.becomeqa.com/api/items/test-item-id');
});

Глобальное состояние тестов. Если для передачи данных между тестами используешь глобальные переменные: не делай этого. Каждый тест должен быть самодостаточным.
Запускай тесты с --repeat-each=3 чтобы проверить их стабильность при повторении. Тест который падает на второй попытке утекает состояние. npx playwright test --repeat-each=3 tests/login.spec.ts

Исправление конфликтов при параллельном выполнении

По умолчанию Playwright запускает тесты параллельно в нескольких воркерах. Если два теста пытаются изменить одну запись или использовать одну учётную запись одновременно, они конфликтуют.

Исправление зависит от ситуации.

Используй уникальные тестовые данные для каждого теста. Вместо постоянного admin@becomeqa.com генерируй уникальный идентификатор для каждого запуска:

const uniqueId = Date.now();
const testEmail = `test-${uniqueId}@example.com`;

Изолируй параллельные тесты. Сгруппируй конфликтующие тесты в один файл и настрой его запуск с одним воркером:

// В начале файла
test.describe.configure({ mode: 'serial' });

Все тесты в файле выполнятся последовательно, конфликты исчезнут.

Используй отдельные тестовые данные для каждого воркера. Playwright передаёт workerIndex в фикстуры:

const workerEmail = `test-worker-${workerInfo.workerIndex}@example.com`;

Правильное использование retry

Playwright поддерживает автоматические retry для флакующих тестов:

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

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

Правило: retry допустимы при нестабильности инфраструктуры. Они недопустимы как замена исправлению реальных проблем с таймингом или изоляцией.

Установить retries: 3 без расследования почему тесты падают: верный способ получить сьют который работает в 3 раза дольше и по-прежнему не вызывает доверия.

Карантин для хронически флакующих тестов

Если тест флакует и починить его прямо сейчас не получается, изолируй его. Не оставляй в основном сьюте падать случайно.

test.skip('checkout flow completes successfully', async ({ page }) => {
  // Флакует из-за таймаутов платёжного API, задача JIRA-1234
  // TODO: замокать ответ платёжного API вместо реального вызова
});

Пропущенный тест с комментарием бесконечно лучше флакующего теста который приучает команду игнорировать красные сборки.

Систематический воркфлоу отладки

Когда встречаешь флакующий тест, работай в этом порядке:

1. Захвати трассировку: запусти с retries: 1 и trace: 'on-first-retry', посмотри на точку падения

2. Запусти 10 раз: npx playwright test --repeat-each=10 tests/your.spec.ts, посмотри как часто падает

3. Запусти в изоляции: npx playwright test tests/your.spec.ts, если проходит один: это загрязнение тестов

4. Запусти в headed-режиме: npx playwright test --headed --slow-mo=500, смотри как падает в замедленном режиме

5. Проверь вкладку Network в трассировке: не падают ли запросы и не истекают ли по таймауту?

6. Добавь явные ожидания конкретного условия которое должно выполниться до падающего действия

7. Проверь разделяемое состояние: что делает предыдущий тест?

Большинство флакующих тестов решаются на шаге 3 или шаге 6.

FAQ

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

Запусти 10 раз на одном коммите. Упал 2 из 10: флакующий. Упал 10 из 10: поймал баг.

Тест падает только в CI, локально никогда. Почему

CI-машины медленнее и имеют меньше памяти. Проблемы с таймингом которые невидимы локально проявляются под нагрузкой. Запусти локально с --slow-mo=500 чтобы симулировать более медленную машину. Также проверь использует ли CI другой базовый URL или переменные окружения.

Что использовать для известных флакующих тестов: test.fixme или test.skip

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

Трассировка показывает что элемент был видимым, но клик всё равно упал. Что произошло

Элемент был видимым, но скорее всего перекрыт другим элементом (модалкой, тултипом, sticky-заголовком). Проверь isVisible() vs isInViewport(). Возможно нужно сначала проскроллить к элементу: await locator.scrollIntoViewIfNeeded().

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