{ "index": 157, "slug": "editorial-2023-08-field-e2e-stability", "title": "Flaky e2e-тест: как найти причину и сохранить сигнал", "excerpt": "Если первый запуск падает, а retry проходит, тест не становится надёжным. Разбираем locator, actionability, готовность интерфейса, данные и trace, чтобы менять только доказанный слой.", "contentHtml": "

В CI тест оформления платежа падает на click, а повторная попытка проходит. На следующий день тот же сценарий становится красным уже на проверке результата. Команда увеличивает timeout и добавляет ещё один retry, но получает только более длинную очередь: причина остаётся неизвестной, а настоящий сбой оплаты может потеряться среди зелёных повторов.

\n

Такой тест называют flaky, когда он при одинаковом заявленном сценарии иногда проходит, а иногда нет. Это описание наблюдения, а не диагноз. Чтобы вернуть тесту ценность, нужно сохранить исходную ошибку, разделить слои отказа и доказать маленьким экспериментом, какой слой меняется между попытками.

\n

Ниже — рабочая схема для Playwright Test. Маршрут, тексты, фикстуры и способ подготовки платежа в примерах условны: их нужно заменить контрактом конкретного приложения. Статья не обещает нулевой flake rate и не разрешает автоматически скрывать дефекты retry-настройкой.

\n

Сначала зафиксируйте симптом

\n

Начните с карточки одного запуска. Запишите commit, проект браузера, worker, номер попытки, входные данные и точное место падения. Не заменяйте initial failure результатом retry. В Playwright Test значение testInfo.retry показывает номер повторной попытки, а testInfo.workerIndex помогает связать запуск с worker.

\n
import { test } from '@playwright/test';\n\ntest('confirms payment', async ({ page }, testInfo) => {\n  console.log({\n    retry: testInfo.retry,\n    worker: testInfo.workerIndex,\n    project: testInfo.project.name,\n  });\n\n  await page.goto('/checkout');\n  await page.getByRole('button', { name: 'Оплатить' }).click();\n});
\n

Лог не заменяет отчёт: в нём должны остаться текст ошибки, URL или идентификатор тестовых данных и ссылка на артефакт именно этой попытки. Если trace был собран только на retry, пометьте initial как evidence: absent, а не делайте вид, что повторный trace объясняет первый сбой.

\n

Что меняется при retry

\n

Retry не продолжает тот же браузер с места ошибки. Когда тест падает, Playwright Test удаляет worker вместе с браузером и запускает новый worker; при включённых retry тест начинается заново в новом процессе. Поэтому между initial и retry могут отличаться cookies, storage, состояние фикстур, worker, порядок подготовки данных и доступность внешней зависимости.

\n

Playwright классифицирует результаты так: passed — первый запуск прошёл; flaky — первый запуск упал, но retry прошёл; failed — упали первый запуск и все retry. Метка flaky полезна для очереди разбора, но не доказывает, что продукт исправен или что причина относится к инфраструктуре.

\n
Как читать пару initial/retry
НаблюдениеЧто уже известноЧего ещё нельзя утверждатьСледующая проверка
Обе попытки падают на одном locatorСбой воспроизводится в этом запускеЧто виноват только selectorПроверить число совпадений и actionability
Initial падает, retry проходит до clickМежду попытками изменилось состояние или времяЧто нужен больший timeoutСравнить DOM, overlay, animation и данные
Click проходит, assertion результата падаетДействие принято браузеромЧто операция завершилась успешноПроверить финальный UI-state и ответ операции
Тест проходит отдельно, но падает в пачкеЕсть зависимость от порядка или общего состоянияЧто проблема в браузереЗапустить с новым id данных и другим порядком
Есть trace только у retryВидна одна повторная попыткаЧто она показывает initialВключить симметричный сбор первого failure
\n

Разделите locator и actionability

\n

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

\n

Сначала проверьте cardinality — количество совпадений. Локатор должен описывать одну пользовательскую цель в нужной области страницы. Предпочтительны роль и доступное имя; цепочка CSS-классов связывает тест с реализацией DOM. getByTestId допустим, если команда поддерживает test id как стабильный технический контракт. Нельзя объявлять locator надёжным только потому, что он зелёный в одном браузере.

\n
const dialog = page.getByRole('dialog', { name: 'Оплата' });\nconst pay = dialog.getByRole('button', { name: 'Оплатить' });\n\nawait expect(dialog).toBeVisible();\nawait expect(pay).toHaveCount(1);\nawait pay.click();
\n

Проверка toHaveCount(1) сама ожидает условие, поэтому не превращается в мгновенный снимок состояния. Но она не доказывает, что платёж принят: она лишь делает цель действия явной. Если элемент появляется после запроса, ищите причину отсутствия или перекрытия, а не маскируйте её глобальным timeout.

\n

Готовность экрана не равна успеху операции

\n

После click нужен бизнес-результат, который видит пользователь: подтверждённый статус, новая запись или переход на согласованный маршрут. Spinner, исчезновение skeleton и завершение отдельного сетевого запроса — промежуточные признаки. Они не заменяют assertion финального состояния.

\n
await pay.click();\nawait expect(page.getByRole('status')).toHaveText('Платёж подтверждён');\nawait expect(page).toHaveURL(/\\/checkout\\/success$/);
\n

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

\n

Случайная задержка waitForTimeout не объясняет, какое событие делает страницу готовой. Ожидание networkidle тоже не является универсальным признаком готовности: аналитика, polling и WebSocket могут не завершаться, а нужный UI уже может быть готов. Выбирайте состояние, принадлежащее пользовательскому сценарию.

\n

Проверьте данные и границы внешних систем

\n

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

\n

Внешний платёжный провайдер, email-шлюз и сторонняя аналитика находятся вне контроля e2e-команды. Полный путь к провайдеру проверяйте отдельным контрактным или интеграционным набором с его политикой доступности. В пользовательском e2e-тесте контролируйте внешний ответ через API-мок, если задача теста — проверить собственный UI. Иначе смена ответа третьей стороны будет ошибочно классифицирована как flaky интерфейса.

\n
Цикл разбора flaky e2e-теста: сравнить initial и retry, проверить locator, готовность интерфейса и evidence, затем внести малый diff с rollback
Порядок triage: сначала карточка двух попыток, затем отдельно selector и readiness, после этого — данные и внешние зависимости. Схема описывает процедуру и не является trace конкретного CI-запуска.
\n

Настройте артефакты так, чтобы они сохраняли сигнал

\n

Для CI разумно собирать trace на первом retry: это даёт подробный контекст повторной попытки и не записывает тяжёлый trace для каждого зелёного теста. Если retry выключены, используйте retain-on-failure, чтобы сохранить trace неудачного запуска. Режим on удобен для локального расследования, но в большом CI увеличивает стоимость и объём артефактов.

\n
import { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n  retries: process.env.CI ? 1 : 0,\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure',\n    screenshot: 'only-on-failure',\n  },\n});
\n

Проверить initial и retry можно явно. В локальной диагностике запустите один тест без повторов и с полным trace, затем откройте отчёт:

\n
pnpm exec playwright test tests/checkout.spec.ts --project=chromium --retries=0 --trace on\npnpm exec playwright show-report\n# Для сохранённого архива: pnpm exec playwright show-trace path/to/trace.zip
\n

В Trace Viewer смотрите один вопрос за раз: какой locator использовался, что было в DOM до и после действия, какие запросы ушли, какой ответ пришёл и какой статус получил assertion. Viewer показывает timeline, snapshots, action log, network и metadata конкретного запуска. Он не сообщает, корректен ли бизнес-контракт, и не восстанавливает отсутствующий initial trace.

\n

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

\n
  1. Сохраните исходный факт. Запишите initial error, результат retry, commit, project, worker, attempt и входные данные.
  2. Разнесите гипотезы. Отдельно назовите locator, actionability, readiness, изоляцию данных и внешнюю зависимость.
  3. Повторите без retry. Используйте тот же тест, свежие данные и trace, чтобы не смешивать диагностику с автоматической маскировкой.
  4. Проверьте цель действия. Убедитесь, что locator находит ровно один control в правильном контейнере.
  5. Проверьте финальный контракт. Assertion должен ждать пользовательский результат, а не случайный промежуточный сигнал.
  6. Сравните окружения. Сверьте worker, cookies, storage, браузер, порядок запуска, fixture setup/cleanup и ответы контролируемых API.
  7. Измените один слой. Исправьте selector, предусловие, assertion, данные или сбор артефактов — только тот слой, для которого есть evidence.
  8. Проверьте отрицательный путь. Отказ операции должен показывать ожидаемую ошибку, а старый status не должен приниматься за новый успех.
  9. Оставьте критерий снятия. Для временного retry укажите owner и срок пересмотра; для постоянного diff — команду проверки и rollback.
\n

Как отличить исправление от маскировки

\n

После правки прогоните тест отдельно и в пачке, с новым идентификатором данных и в поддерживаемых проектах браузера. Сравнивайте не только итоговый exit code, но и место assertion, длительность действия и наличие артефактов для каждой попытки. Один зелёный запуск ничего не доказывает; полезнее серия запусков с одинаковым контрактом и независимым setup.

\n

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

\n

Ограничения применимости

\n

Эта схема рассчитана на Playwright Test и его worker/retry/trace-модель. В Cypress, Selenium Grid или самописном runner жизненный цикл попытки и набор артефактов могут отличаться. Проверки actionability не исправляют серверный ответ, а web-first assertion не делает неверное ожидание правильным.

\n

Мок внешнего сервиса повышает повторяемость собственного UI, но не проверяет реальную интеграцию. Изоляция тестовых данных не устраняет дефекты параллельной обработки в production. Trace может содержать чувствительные URL, заголовки и данные страницы, поэтому задайте срок хранения и права доступа по правилам своей CI-системы.

\n

Разбор можно считать законченным, когда команда отвечает на пять вопросов: что произошло в initial, что изменилось в retry, какой locator выполнял действие, какой финальный результат ожидался и какой evidence относится к каждой попытке. Только после этого retry становится диагностическим инструментом, а не зелёной маской.

\n

Проверяемые источники

\n" }