DarkRiDDeR12 мин

E2E без лишних retry: ждать факт, а не тишину интерфейса

ТестированиеFrontend

Тест нажимает кнопку «Оплатить», получает ошибку на первой попытке и проходит на повторной. В отчёте остаётся статус flaky, а в pull request появляется короткое предложение: поднять timeout и идти дальше. Проблема в том, что этим действием смешиваются четыре разных объекта: selector для действия, условие готовности интерфейса, retry тест-раннера и evidence из конкретного запуска. Пока они смешаны, команда лечит паузу, а не контракт.

Цена такой правки не сводится к лишним секундам CI. Реальная ошибка может стать «шумом»: повторный запуск проходит, скрывает первый отказ и откладывает разбор. Обратная цена тоже заметна: бесконечный trace на каждый тест раздувает артефакты, но не отвечает, какой результат должен увидеть пользователь. Здесь нужен короткий порядок: сначала назвать факт после действия, затем выбрать наблюдение, только потом решать, нужен ли retry и какой evidence сохранять.

Четыре объекта, которые нельзя называть одним ожиданием

Selector отвечает на вопрос «куда направить действие». Для Playwright 1.37.0 locator с role и name — это способ найти пользовательский элемент. Перед click() Playwright проверяет, что элемент attached, visible, stable, receives events и enabled. Эти проверки полезны: они не дают кликнуть в скрытый или перекрытый control. Но они не знают, завершилась ли оплата, сохранился ли профиль или пришёл ли пользовательский статус.

Readiness condition отвечает на другой вопрос: какой наблюдаемый продуктовый факт должен появиться после действия. Это может быть текст в role=status, появление записи в таблице или смена доступного пользователю состояния. Условие не должно быть «страница немного успокоилась» или «кнопка стала disabled», если бизнес-операция ещё не подтверждена. Retry запускает тест повторно после failure. Evidence — trace, snapshot, action log или другой артефакт отдельной попытки. Ни один из них не является синонимом selector или готовности.

Граница каждого слоя в e2e-сценарии
СлойНа какой вопрос отвечаетЧто может подтвердитьЧего не подтверждаетПервое действие
SelectorКак найти control?locator разрешается ровно в один ожидаемый элементчто операция завершиласьиспользовать role/name или явный test id
Readiness conditionКакой факт увидел пользователь после действия?конкретный status, текст, запись или состояниечто locator был уникален до clickсформулировать ожидаемый продуктовый результат
RetryЧто сделал runner после failure?первая попытка не прошла, следующая прошла или нетпочему попытки различаютсясохранить статус первой и повторной попытки
EvidenceЧто можно изучить в одном запуске?действия, snapshots, log и network log, если они записаныкорневую причину без дополнительной проверкисвязать артефакт с номером попытки
TimeoutКаков верхний предел ожидания?когда ожидание остановится ошибкойкакой факт надо дождатьсяне менять, пока не назван readiness contract

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

Практический контракт выглядит короче, чем кажется. После нажатия нужно назвать одно состояние, которое экран обязан показать. Например: «платёж подтверждён», а не «кнопка уже недоступна». Если интерфейс не имеет такого состояния, это не повод ждать произвольную паузу. Это повод вместе с владельцем экрана выбрать доступный сигнал: status region, итоговую строку, новый route или ответственный элемент в UI. Контракт должен быть проверяемым пользователем, а не внутренним CSS-классом без смысла вне реализации.

Ниже — проектный образец. getByRole() выбирает кнопку, а toHaveText() ждёт текст результата. Web-first assertion в Playwright умеет повторять проверку до timeout; это не означает, что любой текст подходит. Значение confirmed здесь специально условное: в своём продукте его заменяют на реальный, устойчивый и доступный пользователю результат. Фрагмент не доказывает, что страница, backend или browser уже проверены.

// Иллюстрация контракта: locator выбирает действие, expect проверяет результат.
import { expect, test } from '@playwright/test';

test('подтверждает оплату', async ({ page }) => {
  await page.getByRole('button', { name: 'Оплатить' }).click();
  await expect(page.getByTestId('payment-state')).toHaveText('confirmed');
});

// getByRole — selector. toHaveText — readiness condition.
// Текст и test id должны соответствовать контракту конкретного продукта.

Sleep или network idle без связи с результатом делают запуск длиннее, но не объясняют, что делать при ошибке экрана после успешного запроса. Actionability делает действие допустимым, readiness делает завершение сценария наблюдаемым. Тогда timeout — параметр договора, а не способ спрятать расхождение.

Учебный fixture: только synthetic попытки в памяти

Пакет содержит исполнимую модель, но не e2e-запуск. Она создаёт две synthetic попытки: первая не достигает payment-state=confirmed, вторая достигает его после retry. Selector в обеих уникален, поэтому fixture отделяет readiness от selector и retry. Он не открывает URL, не читает тест, не запускает Playwright и не производит trace.zip или video.

node web/scripts/upgrade-2023-08.mjs --verify-fixture

# Команда детерминированно создаёт marked synthetic attempts только в памяти.
# Она не открывает браузер, не читает проект, не запускает Playwright,
# не записывает trace/video, не измеряет duration и не проверяет compatibility.
# PASS проверяет разделение selector, readiness, retry и synthetic evidence.
# PASS не означает, что настоящий тест flaky, что причина найдена или что UI готов.

PASS проверяет шестнадцать утверждений: вход помечен как memory-only, браузер не запускается, первая и повторная попытки различаются ровно теми полями, которые заданы в модели, а увеличение timeout отклоняется учебным планом. Отдельная отрицательная ветка делает selector множественным и получает другую классификацию. Это важно: даже одинаковый финальный статус «flaky» не даёт права без проверки переписать locator, readiness condition и retry policy одной правкой.

Схема классификации e2e-сбоя: сначала проверяется уникальность selector, затем отдельное semantic readiness condition, после этого сравниваются initial attempt и retry; trace-shaped evidence остаётся отдельным входом к проверке, а не причиной. Два выхода предлагают уточнить locator либо контракт готовности, а повышение timeout вынесено в отложенное решение.
Схема задаёт порядок разбора. Она не показывает настоящий trace, браузер, длительности, количество флаков или совместимость платформ. Каждый прямоугольник — вопрос к одному слою теста.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Первый запуск failed, retry passed, а отчёт пометил тест flaky. Сохраните номер попытки, исходный текст ошибки и ссылку на evidence, если ваш runner его записал. Не называйте это доказанной причиной.
  2. Причина. Обычно в одном ожидании оказались selector, техническая готовность control и результат операции. Иногда к ним добавляют общий timeout, поэтому ошибка становится длиннее, но не яснее.
  3. Проверка selector. Убедитесь, что locator на обеих попытках разрешается ровно в один пользовательский элемент. Если нет, это отдельная задача: сузить role/name, scope или test id; не менять пока бизнес-assert.
  4. Проверка readiness. Выпишите факт, который обязан увидеть пользователь после действия. Сверьте, что assertion ждёт именно его, а не disabled button, исчезновение spinner или окончание произвольной паузы.
  5. Проверка retry и evidence. Сопоставьте initial и retry как два запуска. Для Playwright 1.37.0 retry выполняется в новом worker; trace с on-first-retry, если он настроен, относится к первой повторной попытке. Он помогает задать вопрос, но не возвращает автоматически evidence первого отказа.
  6. Действие. Внесите минимальную правку в один слой: locator, semantic assertion, изоляцию данных или явный проектный контракт. Не повышайте timeout, пока не можете назвать, что именно должно стать ready.
  7. Rollback. До merge запишите прежний assertion и критерий возврата. Если новый сигнал оказался неверным, откатите только test diff и повторите разбор; не стирайте историю первой неудачной попытки комментарием «пофиксили flaky».

Retry полезен как граница сбора evidence, а не как индульгенция

В Playwright retries выключены по умолчанию. При включении runner повторно запускает упавший тест; документация v1.37.0 называет flaky тот случай, когда первая попытка не прошла, а повторная прошла. Это удобный сигнал для очереди разбора, но не диагноз. Повтор может дать другой worker и чистое состояние, а значит скрыть утечку тестовых данных, зависимость от порядка или неявную готовность экрана. Он не доказывает, что первая ошибка была случайной, внешний сервис был медленным или selector корректен.

Конфигурация trace: on-first-retry привязывает артефакт к первому повтору, а не ко всей истории. Это evidence retry, не объяснение initial failure. Если нужен контекст первого отказа, проекту требуется отдельный способ его сохранить с учётом чувствительности данных и цены артефактов.

// Иллюстрация для Playwright v1.37.0; этот фрагмент не запускается fixture.
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 1 : 0,
  use: { trace: 'on-first-retry' },
});

// retry сохраняет evidence первого повторного запуска.
// Он не заменяет явное условие готовности после click.

Ограничения и следующий проверяемый шаг

Заметка не измеряет flake rate, не сравнивает браузеры, не обещает устойчивость любого getByRole() и не предлагает общий timeout. Trace фиксирует один запуск, а причина может лежать в приложении, сети, окружении или данных. Рамка ограничена Playwright v1.37.0 от 10 августа 2023; другую версию проверяют по её документации.

Следующий шаг — взять один тест со статусом flaky и заполнить короткую карточку из пяти строк: selector, readiness condition, initial outcome, retry outcome и доступный evidence. Затем выбрать один слой для изменения и указать rollback. Если карточку нельзя заполнить без догадок, не увеличивайте timeout. Сначала добавьте недостающий наблюдаемый результат или изоляцию данных. Так retry перестаёт превращать ошибку в шум и становится точкой, где начинается инженерный разбор.

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