{ "index": 157, "slug": "editorial-2023-08-field-e2e-stability", "title": "Flaky e2e-тест: как найти причину и сохранить сигнал", "excerpt": "Если первый запуск падает, а retry проходит, тест не становится надёжным. Разбираем locator, готовность интерфейса, данные и trace, чтобы менять только доказанный слой.", "contentHtml": "
В CI тест оформления платежа падает на click. Повторный запуск проходит. Через день тот же тест снова красный, но уже на проверке результата. Команда увеличивает timeout, добавляет ещё один retry и получает более длинную очередь. Ошибка не исчезает: тест лишь дольше скрывает нарушение пользовательского сценария.
Цена такого решения измерима. Разработчик ждёт обратную связь дольше. Красный запуск перестаёт отличать дефект продукта от дефекта теста. Если retry маскирует настоящий сбой оплаты, команда может пропустить проблему до релиза. Если виноваты общие данные, каждый тест с большим timeout платит за чужую гонку.
\nТезис простой: flaky — это сигнал для разбора, а не причина менять настройки вслепую. Сначала разделите locator, actionability, readiness, данные и внешние зависимости. Затем привяжите evidence к конкретной попытке. После этого выбирайте маленькую правку с owner и понятным rollback.
\nСтатус failed → passed сообщает только о разных исходах запусков. Он не называет причину. Retry не продолжает ту же страницу с того же места. Runner создаёт новую попытку и может использовать другой worker. Меняются cookies, storage, порядок тестов, состояние базы, очистка данных и доступность внешнего сервиса.
У действия есть несколько независимых условий. Locator должен указывать ровно на один элемент. Перед click элемент должен быть видимым, стабильным, доступным для событий и активным. После действия интерфейс должен показать пользовательский результат. Успешный click доказывает готовность действия. Он не доказывает, что платёж подтверждён.
Ожидание networkidle не равно готовому экрану. Исчезнувший spinner не равен успешной операции. Прошедший retry не равен воспроизводимому тесту. Trace помогает увидеть ход одной попытки, но не подменяет контракт результата.
Зафиксируйте четыре факта до изменения конфигурации: чем пользователь находит control, какой результат он должен увидеть, что произошло в initial и retry, и какой артефакт относится к каждой попытке. Не называйте доказательством файл без номера попытки. Trace retry не объясняет автоматически initial failure.
\nimport { expect, test } from '@playwright/test'; test('confirms payment', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Оплатить' }).click(); await expect(page.getByRole('status')).toHaveText('Платёж подтверждён'); }); // Учебный пример: текст, маршрут и данные замените на контракт конкретного приложения.\nВ примере locator описывает действие языком пользователя. Assertion ждёт смысловой результат, а не случайную задержку. Это учебный фрагмент: он не подтверждает работу платёжного контура и не заменяет проверку в вашем окружении. В реальном тесте задайте независимые данные и очистку, иначе зелёный запуск может зависеть от предыдущего теста.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Ноль или несколько совпадений locator | Selector не описывает одну цель | Проверьте роль, имя и область контейнера | Исправьте locator; timeout не меняйте |
| Click ждёт и завершается timeout | Элемент скрыт, перекрыт, движется или disabled | Смотрите actionability и состояние экрана | Исправьте предусловие или selector |
| Click прошёл, результата нет | Неверный readiness contract, данные или ответ сервиса | Сверьте assertion с пользовательским итогом и входом | Уточните UI-контракт или изоляцию данных |
| Initial failed, retry passed | Гонка, загрязнение данных или внешний сбой | Сравните worker, данные, порядок и evidence | Создайте triage; не добавляйте retry автоматически |
| Trace есть только у retry | Контекст попыток собран несимметрично | Проверьте attempt, тест и проект в отчёте | Добавьте путь сбора initial evidence |
Начните с cardinality. Locator должен находить ровно один элемент в момент действия. Если кнопок несколько, сузьте область диалога или списка. Если совпадений нет, разберитесь с состоянием страницы и текстом. Увеличение timeout не делает неоднозначную цель однозначной.
\nПредпочитайте роль, доступное имя и label. CSS-цепочка по классам связывает тест со строением DOM, а не с поведением интерфейса. Это не абсолютный запрет на test id или CSS. Test id полезен для стабильного технического контракта. CSS оправдан, когда команда сознательно поддерживает его как API. Важно назвать владельца и смысл locator.
\nПосле click проверяйте факт, который видит пользователь: сообщение об успехе, новую запись, смену статуса или подтверждённый маршрут. Не используйте spinner как финальный результат. Не делайте expect(await locator.isVisible()).toBe(true), если нужен web-first assertion: такая форма сначала получает снимок состояния и теряет встроенное ожидание.
Retry полезен как диагностический слой и как защита от краткого сбоя инфраструктуры. Он опасен, когда превращается в разрешение на merge. Запишите номер попытки, worker, используемые данные и исходную ошибку. Слово «flaky» описывает классификацию запусков. Оно не заменяет root cause.
\nНе смешивайте классы причин. Ошибка locator требует проверки DOM. Ошибка actionability требует проверки overlay, animation и disabled state. Отсутствие readiness требует проверки UI и ответа операции. Разные данные требуют проверки setup и cleanup. Внешний сервис требует отдельной политики зависимости. Один глобальный timeout не лечит все случаи.
\nuse: { trace: 'on-first-retry', screenshot: 'only-on-failure' }, retries: process.env.CI ? 1 : 0 // Учебная конфигурация. Значения зависят от цены очереди и среды.\nФрагмент показывает форму, а не готовую политику. Trace на первом retry даёт контекст повторной попытки и экономит место. Он не создаёт trace initial failure. Если первичный контекст критичен, настройте отдельный способ его сохранить и явно подпишите артефакт.
\nОткройте trace с одним вопросом: locator указывал на одну кнопку перед click или после click появился нужный status? Trace Viewer позволяет сопоставить timeline, DOM snapshot, action log и сетевые запросы. Это помогает сузить гипотезу. Но trace показывает конкретный запуск. Он не знает, был ли результат бизнес-успешным, пока тест не проверяет assertion.
\nЕсли артефакта нет, запишите evidence: absent. Не заменяйте отсутствие данных уверенным объяснением. Сначала сверяйте имя теста, проект, commit и номер попытки. Затем смотрите один слой. Широкий запрос «найти причину по trace» часто приводит к непроверенной версии.
Проверяйте не только успешную оплату. Добавьте сценарий, в котором сервер возвращает отказ или данные невалидны. Убедитесь, что тест видит сообщение об ошибке и не принимает disabled control, spinner или старый status за успех. Если отрицательный путь ломается из-за случайного текста, проблема может быть в контракте интерфейса, а не в retry.
\nПроверка изоляции тоже должна иметь отрицательный путь. Запустите тест отдельно и в другом порядке. Используйте новый идентификатор данных. Удалите запись после сценария. Если результат меняется, не маскируйте гонку timeout. Найдите владельца состояния и границу cleanup.
\nНи один locator не защищает от неверного продукта. Auto-waiting ждёт actionability, но не исправляет серверный ответ. Web-first assertion ждёт условие, но не делает условие правильным. Retry может уменьшить шум инфраструктуры, но может и скрыть редкую ошибку. Trace полезен только там, где его записали и правильно связали с попыткой.
\nУчебные фрагменты не дают production-результатов. Они не измеряют flake rate, длительность очереди, совместимость браузеров или качество данных. Версию Playwright, project config и окружение нужно сверять отдельно. Повышение timeout допустимо только после доказанной верхней границы задержки и проверки, что ожидание относится к нужному событию.
\nРазбор готов, если команда может показать карточку запуска и ответить на пять вопросов: что упало в initial, что прошло в retry, какой locator использовался, какой пользовательский результат ожидался и какой evidence относится к каждой попытке. После правки отдельный запуск проверяет тот же результат на свежих данных, а отрицательный путь завершается ожидаемой ошибкой.
\nДля временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry не проходит этот критерий. Проходит повторяемый контракт, в котором тест отличает готовое действие от подтверждённого результата.
\n