{ "index": 159, "slug": "editorial-2023-08-practice-e2e-stability", "title": "E2E без лишних retry: ждать факт, а не тишину интерфейса", "excerpt": "Почему retry не лечит e2e-падение: отделяем locator, условие готовности, повторный запуск и evidence, а timeout меняем только после проверки контракта.", "contentHtml": "
Тест нажимает кнопку «Оплатить», получает ошибку на первой попытке и проходит на повторной. В отчёте остаётся статус flaky, а в pull request появляется короткое предложение: поднять timeout и идти дальше. Проблема в том, что этим действием смешиваются четыре разных объекта: selector для действия, условие готовности интерфейса, retry тест-раннера и evidence из конкретного запуска. Пока они смешаны, команда лечит паузу, а не контракт.
\nЦена такой правки не сводится к лишним секундам CI. Реальная ошибка может стать «шумом»: повторный запуск проходит, скрывает первый отказ и откладывает разбор. Обратная цена тоже заметна: бесконечный trace на каждый тест раздувает артефакты, но не отвечает, какой результат должен увидеть пользователь. Здесь нужен короткий порядок: сначала назвать факт после действия, затем выбрать наблюдение, только потом решать, нужен ли retry и какой evidence сохранять.
\nSelector отвечает на вопрос «куда направить действие». Для 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 или готовности.
| Слой | На какой вопрос отвечает | Что может подтвердить | Чего не подтверждает | Первое действие |
|---|---|---|---|---|
| Selector | Как найти control? | locator разрешается ровно в один ожидаемый элемент | что операция завершилась | использовать role/name или явный test id |
| Readiness condition | Какой факт увидел пользователь после действия? | конкретный status, текст, запись или состояние | что locator был уникален до click | сформулировать ожидаемый продуктовый результат |
| Retry | Что сделал runner после failure? | первая попытка не прошла, следующая прошла или нет | почему попытки различаются | сохранить статус первой и повторной попытки |
| Evidence | Что можно изучить в одном запуске? | действия, snapshots, log и network log, если они записаны | корневую причину без дополнительной проверки | связать артефакт с номером попытки |
| Timeout | Каков верхний предел ожидания? | когда ожидание остановится ошибкой | какой факт надо дождаться | не менять, пока не назван readiness contract |
Практический контракт выглядит короче, чем кажется. После нажатия нужно назвать одно состояние, которое экран обязан показать. Например: «платёж подтверждён», а не «кнопка уже недоступна». Если интерфейс не имеет такого состояния, это не повод ждать произвольную паузу. Это повод вместе с владельцем экрана выбрать доступный сигнал: status region, итоговую строку, новый route или ответственный элемент в UI. Контракт должен быть проверяемым пользователем, а не внутренним CSS-классом без смысла вне реализации.
\nНиже — проектный образец. getByRole() выбирает кнопку, а toHaveText() ждёт текст результата. Web-first assertion в Playwright умеет повторять проверку до timeout; это не означает, что любой текст подходит. Значение confirmed здесь специально условное: в своём продукте его заменяют на реальный, устойчивый и доступный пользователю результат. Фрагмент не доказывает, что страница, backend или browser уже проверены.
// Иллюстрация контракта: locator выбирает действие, expect проверяет результат.\nimport { expect, test } from '@playwright/test';\n\ntest('подтверждает оплату', async ({ page }) => {\n await page.getByRole('button', { name: 'Оплатить' }).click();\n await expect(page.getByTestId('payment-state')).toHaveText('confirmed');\n});\n\n// getByRole — selector. toHaveText — readiness condition.\n// Текст и test id должны соответствовать контракту конкретного продукта.\nSleep или network idle без связи с результатом делают запуск длиннее, но не объясняют, что делать при ошибке экрана после успешного запроса. Actionability делает действие допустимым, readiness делает завершение сценария наблюдаемым. Тогда timeout — параметр договора, а не способ спрятать расхождение.
\nПакет содержит исполнимую модель, но не 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\n\n# Команда детерминированно создаёт marked synthetic attempts только в памяти.\n# Она не открывает браузер, не читает проект, не запускает Playwright,\n# не записывает trace/video, не измеряет duration и не проверяет compatibility.\n# PASS проверяет разделение selector, readiness, retry и synthetic evidence.\n# PASS не означает, что настоящий тест flaky, что причина найдена или что UI готов.\nPASS проверяет восемнадцать утверждений: вход помечен как memory-only, браузер не запускается, первая и повторная попытки различаются ровно теми полями, которые заданы в модели, а увеличение timeout отклоняется учебным планом. Отдельная отрицательная ветка делает selector множественным и получает другую классификацию. Это важно: даже одинаковый финальный статус «flaky» не даёт права без проверки переписать locator, readiness condition и retry policy одной правкой.
\non-first-retry, если он настроен, относится к первой повторной попытке. Он помогает задать вопрос, но не возвращает автоматически evidence первого отказа.В Playwright retries выключены по умолчанию. При включении runner повторно запускает упавший тест; документация v1.37.0 называет flaky тот случай, когда первая попытка не прошла, а повторная прошла. Это удобный сигнал для очереди разбора, но не диагноз. Повтор может дать другой worker и чистое состояние, а значит скрыть утечку тестовых данных, зависимость от порядка или неявную готовность экрана. Он не доказывает, что первая ошибка была случайной, внешний сервис был медленным или selector корректен.
\nКонфигурация trace: on-first-retry привязывает артефакт к первому повтору, а не ко всей истории. Это evidence retry, не объяснение initial failure. Если нужен контекст первого отказа, проекту требуется отдельный способ его сохранить с учётом чувствительности данных и цены артефактов.
// Иллюстрация для Playwright v1.37.0; этот фрагмент не запускается fixture.\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 1 : 0,\n use: { trace: 'on-first-retry' },\n});\n\n// retry сохраняет evidence первого повторного запуска.\n// Он не заменяет явное условие готовности после click.\nЗаметка не измеряет flake rate, не сравнивает браузеры, не обещает устойчивость любого getByRole() и не предлагает общий timeout. Trace фиксирует один запуск, а причина может лежать в приложении, сети, окружении или данных. Рамка ограничена Playwright v1.37.0 от 10 августа 2023; другую версию проверяют по её документации.
Следующий шаг — взять один тест со статусом flaky и заполнить короткую карточку из пяти строк: selector, readiness condition, initial outcome, retry outcome и доступный evidence. Затем выбрать один слой для изменения и указать rollback. Если карточку нельзя заполнить без догадок, не увеличивайте timeout. Сначала добавьте недостающий наблюдаемый результат или изоляцию данных. Так retry перестаёт превращать ошибку в шум и становится точкой, где начинается инженерный разбор.
\n