{ "index": 159, "slug": "editorial-2023-08-practice-e2e-stability", "title": "Почему e2e-тест проходит со второго раза и что проверять вместо retry", "excerpt": "Flaky после retry — это симптом, а не причина. Разбираем границы locator, готовности интерфейса, изоляции данных и evidence на примере Playwright.", "contentHtml": "

Тест нажимает «Оплатить», получает ошибку на первой попытке и проходит на повторной. CI помечает его как flaky. Команда поднимает timeout, добавляет retry и закрывает pull request. Через неделю тот же тест снова падает, но уже дольше занимает очередь.

Цена ошибки — не только медленный pipeline. Retry может скрыть настоящий отказ: грязные данные, зависимость от порядка тестов, гонку после ответа API или неверный locator. Прошедшая повторная попытка создаёт ощущение исправления, хотя первый запуск так и остался необъяснённым.

Тезис: стабильность e2e строится не вокруг большого timeout. Сначала нужно разделить selector, техническую готовность элемента, продуктовый результат, состояние данных и evidence конкретной попытки. Retry помогает классифицировать запуск. Он не объясняет причину.

Что именно ждёт тест

У одного сценария несколько ожиданий. Locator отвечает на вопрос «какой элемент нужно найти». Для click() Playwright дополнительно проверяет actionability: элемент должен существовать, быть видимым, стабильным, доступным для события и включённым. Это защищает от клика по скрытой или перекрытой кнопке.

Эти проверки не знают, завершилась ли операция. Кнопка может принять клик, пока запрос ещё идёт. Сервер может вернуть ошибку. Экран может показать промежуточный spinner. Поэтому после действия нужен отдельный readiness contract: наблюдаемый факт, который означает завершение сценария для пользователя.

Для платежа таким фактом может быть статус «Подтверждено». Для сохранения профиля — сообщение об успехе и новое значение в карточке. Для импорта — строка с терминальным состоянием. disabled и исчезновение spinner подходят только тогда, когда продуктовый контракт прямо говорит, что они означают успех.

Есть и граница данных. Если тест создаёт пользователя с фиксированным логином и не удаляет его, результат зависит от предыдущего запуска. Если два теста меняют одну корзину, retry получает другое начальное состояние. Locator и assertion могут быть правильными, а падает изоляция.

Пример: проверяем результат, а не паузу

Ниже учебный фрагмент для Playwright. Названия кнопки и статуса условны. Код не доказывает готовность конкретного продукта и не сообщает production-результаты. Он показывает границу между действием и постусловием.

import { expect, test } from '@playwright/test';\n\ntest('подтверждает оплату', async ({ page }) => {\n  const payButton = page.getByRole('button', { name: 'Оплатить' });\n  const paymentState = page.getByTestId('payment-state');\n\n  await payButton.click();\n  await expect(paymentState).toHaveText('confirmed');\n});

getByRole() выбирает пользовательское действие. toHaveText() ждёт смысловой результат и повторяет проверку до assertion timeout. Такой код лучше, чем waitForTimeout(1000): пауза ничего не говорит о состоянии приложения и одинаково плохо работает для быстрого и медленного ответа.

Если locator совпадает с несколькими кнопками, сначала исправляют область поиска или контракт доступной разметки. Если кнопка одна, но payment-state не меняется, проверяют продуктовый поток, API и состояние данных. Нельзя менять locator, assertion и timeout одновременно: после такой правки исчезает связь между симптомом и действием.

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

Короткая карта разбора нестабильного e2e-теста
СимптомПричинаПроверкаДействие
Locator не найденРазметка не появилась или селектор зависит от CSSСнять число совпадений и посмотреть DOM первой попыткиВыбрать user-facing locator и сузить scope
Клик проходит, статус не меняетсяНет readiness condition, ошибка API или промежуточное состояние принято за успехПроверить итоговый сигнал и ответ операцииЖдать terminal state; отдельно исправить приложение или данные
Первый запуск failed, retry passedГонка, утечка данных, порядок тестов или новый workerСопоставить данные, worker, шаг ошибки и evidence обеих попытокИзолировать данные и подтвердить один источник различия
Тест проходит после роста timeoutОжидание было коротким или assertion смотрит не на тот фактИзмерить время наступления названного состоянияМенять timeout только вместе с контрактом и лимитом
Trace есть, причина неяснаАртефакт относится к одной попыткеПроверить номер попытки и последний шагСформулировать гипотезу и проверить её отдельно

Почему retry меняет картину

Retry запускает тест снова после сбоя. В Playwright повтор может выполняться в новом worker. Это полезно для изоляции, но одновременно меняет окружение: новый браузерный контекст, новое состояние фикстур, другой порядок подготовки. Если первая попытка получила пользователя от соседнего теста, повтор может пройти только потому, что набор данных изменился.

Статус flaky описывает сочетание результатов «первая попытка не прошла, повторная прошла». Он не доказывает случайность. Он не говорит, что сеть была медленной, selector неверен или приложение сломалось. Для каждой гипотезы нужны свои признаки.

Trace, screenshot и action log тоже имеют границу. Они показывают, что происходило в записанном запуске. Trace на on-first-retry полезен для повторной попытки, но не превращается в запись первого отказа. Сохраняйте номер попытки рядом с артефактом и не называйте trace root-cause analysis.

Иллюстрация разбора

Схема разбора e2e-сбоя через locator, readiness, retry и evidence
Схема разделяет четыре вопроса: найден ли элемент, наступил ли пользовательский результат, что изменил retry и к какой попытке относится evidence. Изображение иллюстрирует порядок проверки и не содержит измерений реального проекта.

Если locator не уникален, не обсуждают timeout. Если locator стабилен, но состояние не наступает, смотрят на readiness и ответ операции. Если обе части верны, сравнивают данные и окружение initial/retry. Только после этого решают, нужна ли настройка retry или дополнительные артефакты.

Порядок действий

  1. Зафиксируйте симптом: какая попытка упала, какая прошла, на каком шаге и с каким текстом ошибки.
  2. Проверьте locator. Он должен выбирать ожидаемый пользовательский элемент в нужной области. Не принимайте first() как доказательство правильного выбора.
  3. Назовите readiness condition одним предложением. Она должна описывать terminal state, а не длительность ожидания.
  4. Сверьте assertion с этим условием. Уберите sleep, если он маскирует отсутствие постусловия. Оставьте timeout как верхнюю границу.
  5. Сравните данные initial и retry: идентификаторы, cookies, storage, записи на сервере и порядок подготовки.
  6. Прочитайте evidence по номеру попытки. Отделите факт от гипотезы: trace показывает шаг, но не объясняет причину состояния.
  7. Внесите одну правку в один слой. После неё повторите сценарий в тех же условиях и сохраните критерий отката.
  8. Если причина не подтверждена, оставьте тест в очереди разбора. Не объявляйте повышение timeout исправлением.

Ограничения

Эта схема не гарантирует отсутствие flaky-тестов. Она не заменяет проверку backend, браузеров, сети, CI-ресурсов и тестовых данных. Playwright может ждать actionability и web-first assertion, но не может выбрать бизнес-сигнал за команду. Для сложного процесса readiness может включать несколько состояний, однако каждое должно иметь понятное сообщение об ошибке.

Учебный пример не запускался против браузера и не измеряет flake rate, latency или совместимость платформ. Синтетическая модель попыток не является production evidence. Официальная документация меняется вместе с версиями Playwright, поэтому поведение конкретного runner проверяют по версии в проекте.

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

Разбор можно считать завершённым, если для выбранного теста записаны пять вещей: уникальный locator, наблюдаемый readiness condition, входные данные каждой попытки, evidence с номером попытки и одна подтверждённая причина различия. После правки тест проходит несколько независимых запусков без изменения timeout как единственного изменения, а отрицательный сценарий всё ещё падает на неверном результате. Если пункт отсутствует, стабильность не доказана — есть только удачный retry.

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

" }