DarkRiDDeR13 мин

Flaky-пометка: как разобрать retry, trace и rollback без роста timeout

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

В очереди CI появляется e2e-тест: initial attempt failed, first retry passed. До релиза час, и самое быстрое предложение — увеличить timeout или retries. Проблема в том, что policy меняется раньше минимальных фактов: selector, ожидаемый UI-result, событие первой попытки и evidence конкретного запуска.

Цена поспешного «исправления flaky» двойная. Если первая ошибка отражает неверный readiness contract, retry превращает её в нерегулярный шум и оставляет продуктовую границу непроверенной. Если причина в изоляции данных или внешней зависимости, общий timeout увеличивает время обратной связи для всех тестов. Полезный разбор ограничивает утверждения: статус flaky — это результат повторного запуска, trace — evidence попытки, а решение должно быть точечным и иметь rollback. До этих шагов слово «стабилизировали» использовать рано.

Карточка разбора: пять фактов до изменения конфигурации

Начинаем не с видео и не с серии повторов. В карточке достаточно имени теста, initial/retry outcome, selector, readiness и ссылки или отсутствия evidence. Если trace сохранён только на retry, так и пишут: «attempt 1». Карточка удерживает разговор на фактах и не даёт перескочить от красного статуса к глобальной настройке.

Для selector важны форма и область: getByRole('button', { name: 'Оплатить' }) внутри нужного диалога или контейнера — другой контракт, чем CSS-цепочка по классам. Для readiness важен терминальный факт: текст confirmed в status, новая строка с итогом или явный route. Для retry важен номер попытки и policy, а не эмоция «на второй раз повезло». Для evidence важно происхождение: trace, screenshot или log могут сузить вопрос, но должны быть привязаны к одному запуску и не обещать root cause.

Классификация без повышения timeout
Наблюдаемая формаЧто уже известноЧего ещё нетМинимальная проверкаБезопасное действие
0 или несколько locator matchesнарушен selector contractпричина изменения DOM и корректный targetсузить scope, role/name или test idисправить locator; readiness и retry не менять вслепую
click не проходит actionabilitycontrol не готов для действияуспех операции после clickпроверить overlay, disabled, animation или неправильный stateисправить предусловие экрана либо сценарий
click проходит, readiness не достигнутдействие допустимо, ожидаемый факт не наблюдаетсяпочему UI не получил нужное состояниесверить semantic assertion и входные данныеуточнить UI-contract или изоляцию данных
failed → passed на retryдве попытки имеют разные outcomeкорневую причину и воспроизводимостьсравнить данные, worker boundary и evidence по попыткамсоздать triage, не увеличивать timeout автоматически
trace есть только на retryесть контекст повторной попыткиконтекст initial failureподписать attempt и вопрос к артефактусобрать отдельный evidence path, если он нужен

Политика retry должна сохранять сигнал

В Playwright v1.37.0 retries конфигурируются отдельно, а failed test запускается заново в новом worker. Поэтому retry — не «ещё немного подождать в той же странице». Он создаёт новую попытку с новой границей исполнения. Это удобно для независимых тестов и одновременно важно для диагностики: порядок, cookies, storage, server-side cleanup и данные могут вести себя иначе. Если тест зависит от предыдущего случая, повторный запуск иногда скрывает связь, потому что даёт более чистый контекст.

Flaky — маршрут в triage, не разрешение на merge. Временное исключение требует owner, срока, evidence policy и критерия снятия. Универсального числа retries нет: оно зависит от цены очереди, релиза и способности разобрать сигнал. Лучше оставить значение неизвестным, чем назвать его «стандартом».

// Иллюстрация для 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.

Trace помогает ставить вопрос, а не закрывать его

По документации Playwright 1.37.0 Trace Viewer показывает последовательность действий, snapshots, action log, source и network log для записанного trace. Режим on-first-retry связывает запись с первой повторной попыткой. Это полезно, если картинка или log помогают отличить «не тот control» от «после click не появилось нужное состояние». Но такой артефакт не подтверждает фактическое выполнение в другой попытке и не знает, какой бизнес-результат ожидался, пока тест не записал его assertion.

Пока у команды нет файла или ссылки, в карточке стоит evidence: absent. У артефакта сначала сверяют attempt, проект и тест, затем выбирают один вопрос: locator перед click или UI-state перед assertion. Запрос «найди причину по trace» слишком широк и поощряет первую красивую гипотезу.

Цикл triage flaky e2e-теста: карточка фиксирует initial и retry outcome, затем отдельно проверяются selector и readiness; evidence привязывается к номеру попытки, после чего выбирается маленькая правка с owner и rollback. Если доказательств недостаточно, цикл возвращается к сбору одного недостающего факта, а не к увеличению timeout.
Схема показывает процедуру принятия решения. Это не trace, не отчёт CI и не измерение flake rate; в ней нет реальных тестов, браузеров, запросов или длительностей.

Учебный fixture проверяет форму решения, а не качество теста

Чтобы не превратить текст в лозунг, sidecar содержит детерминированный fixture. Он создаёт два marked synthetic event/outcome объекта в памяти. У initial attempt selector уникален, но readiness payment-state=confirmed не достигнут; у retry тот же selector и readiness достигнут. Классификатор возвращает только форму retry-masked-missing-readiness-contract-in-synthetic-input. Он прямо маркирует, что не оценивал real flake, duration и browser compatibility.

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 готов.

Fixture также моделирует множественный selector, расходящуюся retry-policy и evidence, где actualTrace перестало быть not-produced. Так реальный artefact нельзя подложить в учебный пример, а две попытки с разной policy нельзя выдать за сравнимый сценарий. Plan принимает только readiness change с owner, rollback, next check и condition, совпадающим с evidence; timeout или selector без evidence отклоняются. PASS проверяет процедуру, не стабильность настоящего теста.

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

  1. Симптом. Зафиксируйте failed/pass как два outcome с номерами попыток. Не склеивайте их в фразу «иногда падает» и не удаляйте исходный error из обсуждения.
  2. Причина. Сформулируйте конкурирующие классы: selector, actionability, readiness, test-data isolation или внешняя зависимость. Retry сам по себе не выбирает один из них.
  3. Проверка selector. Проверьте cardinality и user-facing смысл locator. Нулевой или множественный match закрывает этот шаг отдельной правкой; не ждите ready state у неопределённой цели.
  4. Проверка readiness. Сравните assertion с пользовательским результатом. Spinner, network idle или disabled state замените на semantic result, согласованный с владельцем UI.
  5. Проверка evidence. Привяжите trace, log или screenshot к attempt. Для on-first-retry в v1.37.0 evidence относится к retry, поэтому initial failure может требовать отдельного способа сохранения контекста.
  6. Действие. Выберите малый diff: locator, semantic assertion, изоляция данных или policy артефактов. Timeout меняют после названного события и доказанной верхней границы.
  7. Rollback и следующий шаг. До merge зафиксируйте прежнее условие, owner и критерий возврата. После следующего запуска смотрите не на один зелёный retry, а на то, что новый contract проверяет нужный пользовательский факт.

Маленький diff и явный rollback лучше широкой настройки

Если причина указывает на readiness, хороший diff меняет assertion и ничего больше: он ждёт терминальный UI-state, а retry/timeouts остаются прежними. Если причина указывает на selector, diff ограничивается locator и тестом его scope. Если нарушена изоляция, изменение может быть в setup/cleanup или фикстуре данных. Эти варианты имеют разный owner и разную цену. Общий timeout объединяет их в один параметр и поэтому редко даёт объяснимый rollback.

Rollback должен быть конкретным. Для assertion — вернуть предыдущую строку теста и снова собрать evidence; для selector — восстановить прошлый locator, если новый контракт не подтвердился; для данных — отменить созданную запись через тот же интерфейс подготовки. Для policy retry — вернуть прошлое значение отдельным config diff и указать, какие тесты это затронет. Нельзя считать rollback успешным, пока команда не понимает, какой слой откатывает. Учебная функция rollbackSyntheticReadinessPlan() специально ничего не применяет и возвращает это явным полем.

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

Материал не содержит реального trace, video, браузерного запуска, CI-лога, оценки flake rate, duration или данных пользователя. Он не утверждает совместимость Playwright v1.37.0 с вашим окружением; version pin не заменяет проверку пакета и project config. Правило переносят только после review данных и рисков контура.

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

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