{ "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-теста нельзя свести к одному timeout. В сценарии действуют отдельные контракты. Locator должен выбрать нужный элемент. Actionability должна разрешить действие. Assertion должен дождаться пользовательского результата. Retry должен описать факт повторного запуска, но не объяснить его причину.
\nОдин текст ошибки скрывает разные события. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может пройти, но сервер вернёт ошибку. Сохранение может завершиться, а тест будет ждать исчезновения spinner, который не связан с итоговым состоянием. Retry может запуститься в новом worker с другим состоянием данных.
\nПервый вопрос звучит не «какой timeout поставить?», а «какой контракт не выполнен?». Разделите фазу поиска locator, фазу действия, фазу ожидания результата и фазу retry. Это сразу сужает область правки.
\n| Контракт | Что он гарантирует | Что он не гарантирует |
|---|---|---|
| Locator | Тест обращается к нужному пользовательскому элементу и ожидает понятную cardinality. | Что операция завершилась успешно. |
| Actionability | Элемент допустимо использовать: он найден, видим, стабилен, принимает события и включён. | Что приложение приняло действие или сохранило данные. |
| Readiness | После действия появился наблюдаемый пользовательский результат. | Что причина результата находится в DOM. |
| Retry | Тест повторился и получил новый outcome. | Что первая ошибка была случайной или устранена. |
Playwright автоматически ждёт actionability перед действиями. Для click() он проверяет, что locator разрешается ровно в один элемент, элемент видим, стабилен, получает события и включён. Это защищает от клика по исчезнувшему или перекрытому control. Но проверка заканчивается, когда click допустим. Библиотека не знает, должен ли после него появиться статус «Сохранено», новая строка или ошибка.
Readiness принадлежит пользовательскому сценарию. Для профиля это status с текстом «Сохранено» и новое значение поля. Для импорта — строка с terminal state. Spinner, enabled-кнопка и network idle могут быть промежуточными признаками. Они не заменяют бизнес-результат.
\nФрагмент ниже учебный. Он не утверждает, что такой locator или текст существуют в вашем приложении. Он показывает разделение действия и постусловия.
\nimport { 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).toBeEnabled();\n await save.click();\n\n await expect(status).toHaveText('Сохранено');\n});\nВ примере getByLabel() и getByRole() описывают пользовательский интерфейс. click() ждёт техническую готовность кнопки. toHaveText() ждёт наблюдаемый итог. Если сервер отвечает ошибкой, тест должен упасть на постусловии. Если locator стал неоднозначным, ошибка должна указывать на выбор элемента. Эти отказы требуют разных исправлений.
Не подменяйте постусловие ручной паузой. waitForTimeout(2000) иногда скрывает медленный UI, а иногда просто откладывает отказ. Не подменяйте результат исчезновением spinner, если spinner исчезает и при ошибке. Не используйте force: true, чтобы обойти перекрытие, пока не доказано, что перекрытие не является дефектом интерфейса.
В Playwright retry выключен по умолчанию. Если он включён, упавший тест запускается снова. Runner работает с worker-процессами. После отказа worker может быть отброшен, а повтор начнётся в новом процессе. Retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это наблюдение о двух запусках, а не доказательство случайности.
\nСтатус flaky полезен как сигнал: первая попытка не прошла, повторная прошла. Он не отвечает на вопросы «почему упало», «исправили ли причину» и «будет ли проходить другой браузер». Trace, записанный через on-first-retry, относится к повторной попытке. Он показывает конкретный запуск, но не восстанавливает контекст первой ошибки.
Отрицательный путь важен не меньше зелёного. Если click прошёл, но readiness не наступил, тест должен закончиться понятной ошибкой на assertion результата. Если retry затем проходит, сохраняйте обе попытки. Нельзя заменить историю фразой «flaky исчез» без проверки данных, состояния и UI-сигнала.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Timeout на click | Locator пустой, множественный, перекрыт или disabled. | Проверьте cardinality, видимость, стабильность и получение событий. | Уточните scope и locator; исправьте UI или данные, если control недоступен. |
| Click прошёл, тест ждёт до timeout | Assertion ждёт не тот результат или UI не сообщает terminal state. | Назовите факт, который должен увидеть пользователь. | Добавьте web-first assertion на этот факт или согласуйте UI-контракт. |
| Первый запуск failed, retry passed | Утечка данных, порядок тестов, внешний сервис или скрытая готовность. | Сравните входы, worker, cleanup, номер попытки и evidence. | Изолируйте данные или исправьте ожидание. Не увеличивайте retry. |
| Тест проходит только с waitForTimeout | Сценарий не ждёт наблюдаемое событие. | Уберите паузу в учебной ветке и найдите первый пользовательский факт. | Замените паузу на locator/assertion с ясным сообщением. |
| Trace ничего не объясняет | Артефакт относится к retry, а вопрос слишком широк. | Проверьте attempt, тест, проект и момент записи. | Используйте trace как evidence одного запуска и соберите контекст initial failure. |
Модель не делает любой тест стабильным. Locator с role/name может быть корректным, но приложение может показывать неверное состояние. Assertion может быть семантическим, но тестовые данные могут пересекаться. Retry помогает обнаружить flake, но не заменяет изоляцию и диагностику. Trace фиксирует только записанный запуск. Отдельно проверяйте версии Playwright, браузеры, сеть и внешний сервис.
\nУчебный код не запускает реальный профиль, не измеряет flake rate и не доказывает production-результаты. Для рабочего теста подставьте реальные роли, данные и terminal state, затем проверьте их на целевой конфигурации.
\nРазбор готов, когда для одного сценария записаны четыре вещи: уникальный locator, ожидаемый пользовательский результат, evidence с номером попытки и действие с понятным rollback. На контролируемом отрицательном пути тест должен падать на соответствующем контракте, а не на случайном timeout. На повторном запуске команда должна видеть, что изменилось: locator, readiness, данные или окружение. Только после этого статус flaky становится входом для проверки, а не заменой объяснения.
\n