{ "index": 158, "slug": "editorial-2023-08-mechanism-e2e-stability", "title": "Почему e2e-тест проходит на retry: четыре контракта устойчивого сценария", "excerpt": "Timeout в e2e-тесте не объясняет причину. Разбираем locator, actionability, пользовательский результат и retry, чтобы отличать настоящий дефект от замаскированного падения.", "contentHtml": "

В CI тест нажимает «Сохранить», получает timeout, а со второй попытки проходит. В отчёте он получает статус flaky. Команда увеличивает timeout и закрывает задачу. Через неделю тот же сценарий падает уже на другом шаге. Цена ошибки — не только красный pipeline: команда теряет время на повторы, пропускает дефект интерфейса или данных и начинает считать зелёный retry доказательством исправности.

\n

Устойчивость e2e-сценария нельзя свести к одному таймауту. В нём действуют четыре разных контракта: locator выбирает нужный элемент, actionability разрешает действие, assertion подтверждает пользовательский результат, а retry описывает повторный запуск. Если смешать эти уровни, диагностика превращается в перебор чисел. Если разделить их, место отказа становится проверяемым.

\n

Симптом начинается раньше TimeoutError

\n

Одинаковая ошибка ожидания может возникнуть по разным причинам. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может успешно отправить событие, а сервер — вернуть ошибку. Сохранение может завершиться, но тест ждёт исчезновения spinner, который скрывается и при успехе, и при отказе. При retry меняются worker, состояние браузера и иногда подготовленные данные.

\n

Первый вопрос здесь не «какой timeout поставить?», а «какой контракт не выполнен?». Отделите поиск элемента, проверку готовности действия, ожидание результата, подготовку данных и повтор. У каждого слоя должна быть своя ошибка и свой следующий шаг.

\n

Четыре контракта одного теста

\n
КонтрактЧто он проверяетЧего он не доказывает
LocatorТест обращается к нужному пользовательскому элементу и ожидает понятное число совпадений.Что операция завершилась успешно.
ActionabilityЭлемент найден, видим, стабилен, получает события и включён для выбранного действия.Что приложение приняло действие или записало данные.
ReadinessПосле действия появился наблюдаемый terminal state: статус, строка результата или изменённое значение.Что именно этот locator был причиной успеха.
RetryRunner повторил тест и получил новый outcome в другом запуске.Что первая ошибка была случайной, а исправление найдено.
\n

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

\n

После успешного click() библиотека не может сама решить, что считать сохранением. Для профиля это может быть сообщение «Сохранено» и новое значение поля. Для импорта — строка с terminal state и числом обработанных записей. Spinner, enabled-кнопка и отсутствие сетевой активности могут быть промежуточными признаками. Их нельзя выдавать за бизнес-результат без проверки сценария.

\n

Locator должен описывать интерфейс, а не разметку

\n

Хороший locator связан с тем, как пользователь воспринимает control: ролью, доступным именем, label или устойчивым тестовым идентификатором. Селектор по случайному классу или позиции в списке может пройти сегодня и сломаться после перестановки DOM. Но и role-based locator не магический: две кнопки с одним доступным именем — это неоднозначный контракт, а не повод добавить nth(0).

\n

Проверяйте cardinality отдельно, когда она важна. await expect(save).toHaveCount(1) сообщает, что на странице ровно одна кнопка с выбранным locator. После этого click() может всё ещё не пройти: control способен быть disabled или закрыт overlay. Разные отказы оставляют разную подсказку для исправления.

\n

Учебный пример: действие и postcondition

\n

Фрагмент ниже самодостаточен как форма теста, но не подключён к реальному профилю. Пути, label и тексты — проектные значения; в рабочем тесте их нужно заменить на фактический пользовательский контракт. Важен порядок: ввод, проверка уникальности locator, действие, затем ожидание результата.

\n
import { test, expect } from '@playwright/test';\n\ntest('user saves profile', async ({ page }) => {\n  await page.goto('/profile');\n\n  const email = page.getByLabel('Почта');\n  const save = page.getByRole('button', { name: 'Сохранить' });\n  const status = page.getByRole('status');\n\n  await email.fill('user@example.test');\n  await expect(save).toHaveCount(1);\n  await expect(save).toBeEnabled();\n  await save.click();\n\n  await expect(status).toHaveText('Сохранено');\n});
\n

getByLabel() и getByRole() выражают пользовательское представление интерфейса. toHaveCount() ловит неоднозначный locator до действия. click() ждёт техническую готовность кнопки. toHaveText() — web-first assertion: он повторяет проверку, пока условие не выполнено или не истечёт timeout. Если сервер вернул ошибку, тест должен упасть на postcondition, а не пройти потому, что кнопка была кликабельной.

\n

Не подменяйте postcondition ручной паузой. waitForTimeout(2000) иногда маскирует медленный UI, а иногда лишь откладывает отказ. Не ждите исчезновения spinner, если он исчезает и при ошибке. Не используйте force: true, пока не доказано, что перекрытие не является дефектом интерфейса: этот флаг отключает часть проверок actionability и может превратить реальную проблему в зелёный тест.

\n

Retry создаёт новый запуск, а не новое доказательство

\n

По умолчанию Playwright Test не повторяет упавшие тесты. При включённых retry runner запускает тест снова до достижения лимита. Playwright Test работает с worker-процессами. Если тест падает, worker вместе с браузером отбрасывается; повтор начинается в новом worker, где hooks могут выполниться заново. Поэтому retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это объясняет различие условий, но не выбирает причину автоматически.

\n

Категория flaky означает только последовательность «первая попытка упала, повторная прошла». Она не означает «дефект случайный», «сеть была медленной» или «правка сработала». Сохраните initial error, retry outcome, входные данные, worker и cleanup. Иначе отчёт оставит только удобный ярлык.

\n

Для CI удобно записывать trace на первой повторной попытке. Такой trace содержит историю именно записанного запуска: действия, снимки DOM и сетевой контекст. Он полезен для анализа retry, но не восстанавливает отсутствующий trace initial attempt. Если причина могла проявиться только в первой попытке, включите режим, который сохраняет evidence и для неё, либо соберите отдельный воспроизводимый запуск.

\n

Минимальная конфигурация и команды

\n

В конфигурации ниже оставлена одна повторная попытка: этого достаточно, чтобы увидеть разницу между initial и retry, но недостаточно, чтобы объявить тест стабильным. Значение retries — политика запуска, а не лечение теста.

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

Запустите один тест без параллельного шума и сохраните отчёт:

\n
npx playwright test tests/profile.spec.ts --workers=1 --retries=1\nnpx playwright show-report\n# Если trace сохранён в test-results, откройте конкретный архив:\nnpx playwright show-trace test-results/<test-name>/trace.zip
\n

Путь test-results/<test-name>/trace.zip в последней команде условный: имя каталога зависит от проекта и репортера. Команда воспроизводима после подстановки фактического пути из отчёта. Для локального расследования, когда retry не нужен, можно временно запустить npx playwright test tests/profile.spec.ts --workers=1 --trace on, но режим записи каждого запуска дороже по времени и месту.

\n

Номер попытки можно добавить в evidence:

\n
test('user saves profile', async ({ page }, testInfo) => {\n  console.log(JSON.stringify({\n    retry: testInfo.retry,\n    worker: testInfo.workerIndex,\n    title: testInfo.title,\n  }));\n\n  await page.goto('/profile');\n  await page.getByRole('button', { name: 'Сохранить' }).click();\n  await expect(page.getByRole('status')).toHaveText('Сохранено');\n});
\n

Лог не доказывает причину отказа, но связывает наблюдение с попыткой. Это особенно полезно, когда одинаковый тест выполняется в нескольких browser project или на разных shard.

\n

Симптом → причина → проверка → действие

\n
СимптомВерсия причиныПроверкаБезопасное действие
Timeout на clickLocator пустой, множественный, перекрыт или disabled.Проверьте count, видимость, стабильность и получение событий.Уточните scope или исправьте UI/данные, если control недоступен.
Click прошёл, assertion истёкОжидается не тот результат или UI не сообщает terminal state.Назовите факт, который должен увидеть пользователь после операции.Добавьте assertion на этот факт или согласуйте UI-контракт.
Initial failed, retry passedУтечка данных, порядок тестов, внешний сервис или скрытая готовность.Сравните входы, worker, cleanup, browser project и attempt.Изолируйте данные и устраните причину; не увеличивайте retry.
Тест проходит только с waitForTimeoutСценарий не ждёт наблюдаемое событие.Уберите паузу в изолированной ветке и найдите первый terminal state.Замените паузу на locator/assertion с ясным сообщением.
Trace ничего не объясняетАртефакт относится к retry, а вопрос относится к initial.Проверьте attempt, test title, project и момент записи.Соберите evidence обеих попыток или отдельный повтор с нужным trace mode.
\n

Данные и окружение часто меняются вместе с retry

\n

Свежий browser context не делает внешние данные свежими. Если тест меняет одну и ту же учётную запись, записи могут пересекаться между worker и параллельными запусками. Если cleanup выполняется только после успеха, retry стартует с другим состоянием. Если серверная очередь или партнёрский API отвечает асинхронно, DOM может быть готов раньше результата операции.

\n

Сначала зафиксируйте границы изоляции. Для каждого теста задайте уникальный идентификатор сущности или подготовьте её через API; cleanup сделайте идемпотентным; состояние, которое нельзя очищать, проверяйте перед повтором. В лог запишите correlation id, но не секреты и персональные данные. Отдельно сравните локальный запуск, CI worker и browser project: одинаковый код не означает одинаковое окружение.

\n

Если тест зависит от внешнего сервиса, разделите два вопроса. UI-тест проверяет пользовательский контракт на контролируемом ответе, а интеграционный сценарий отдельно проверяет реальное взаимодействие. Один retry не отличает дефект приложения от временной недоступности партнёра.

\n

Порядок разбора

\n
  1. Зафиксируйте симптом. Сохраните test title, browser project, шаг, initial error, retry outcome и ссылку на отчёт. Формулировки «иногда падает» недостаточно.
  2. Определите фазу. Отделите locator, actionability, postcondition, подготовку данных, внешний сервис и retry.
  3. Проверьте locator. Убедитесь, что он выражает пользовательский смысл, работает в нужном scope и возвращает ожидаемое число элементов.
  4. Назовите readiness. Запишите terminal state, который доказывает успех. Сверьте его с тем, что увидит пользователь при ошибке сервера.
  5. Сравните попытки. Сопоставьте данные, worker, cleanup, browser project, конфигурацию и порядок действий. Retry рассматривайте как новый запуск.
  6. Свяжите evidence с attempt. Trace, screenshot, log и network запись должны иметь понятный test title и номер попытки. Отсутствующий artefact не заменяйте предположением.
  7. Сделайте один diff. Меняйте один слой: locator, assertion, изоляцию данных или политику evidence. Зафиксируйте owner и rollback.
  8. Проверьте отрицательный путь. Ошибка сервера, неоднозначный locator и отсутствие readiness должны давать разные понятные отказы.
  9. Повторите на свежих данных. Прогоните сценарий с той же конфигурацией, а затем отдельно проверьте другой browser project или внешний dependency, если они входят в область риска.
\n
\"Схема
Каждый следующий слой проверяет отдельный факт. Evidence привязан к конкретной попытке и не превращает успешный retry в доказательство причины.
\n

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

\n

Модель не делает любой тест стабильным. User-facing locator может быть корректным, но приложение — показывать неверный статус. Web-first assertion может ждать правильный DOM-факт, но серверная запись ещё не завершится. Изоляция browser context не очищает базу, очередь или внешний сервис. Retry помогает обнаружить различие между запусками, но не заменяет расследование.

\n

Примеры используют API Playwright Test и синтетические значения. Они не измеряют flake rate, latency, стоимость CI, совместимость браузеров или качество тестовых данных. Версии Playwright, Node.js, браузеров, project config и runner нужно проверять в своём lockfile и CI-образе. Документация меняется, поэтому для исторического разбора закрепляйте версию пакета и сверяйте поведение с release notes этой версии.

\n

Не объявляйте тест исправленным после одного зелёного повтора. Нужны как минимум повторяемый сценарий, понятный отрицательный путь, evidence initial/retry и проверка того слоя, который был изменён. Если данных для вывода нет, корректный результат расследования — «причина не установлена», а не повышение timeout.

\n

Критерий готовности

\n

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

\n

Для временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry этому критерию не соответствует. Соответствует контракт, в котором тест различает готовое действие, подтверждённый результат, состояние данных и условия повторного запуска.

\n

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

\n" }