DarkRiDDeR13 мин

Selector, ready и retry: четыре контракта одного e2e-теста

ТестированиеFrontend

У e2e-теста часто один большой timeout и один текст ошибки, хотя внутри живут четыре независимых контракта. Locator должен найти ровно тот control, auto-wait должен сделать действие допустимым, продуктовый assertion должен дождаться результата, а retry должен сохранить факт повторного запуска. Когда все четыре слоя названы словом «ожидание», падение становится непонятным: инженер видит TimeoutError, но не знает, кнопка не нашлась, была перекрыта, результат не наступил или retry изменил исходные условия. Цена ошибки — замедлить CI и оставить flaky-тест без причины.

После первой удобной правки команда повышает timeout, затем добавляет retry. Часть ошибок превращается в длинные flaky, CI медленнее, а trace не связан с гипотезой. Вместо этого один тест раскладывают на четыре контракта: у каждого свой вопрос, evidence, владелец изменения и rollback.

Контракт действия не равен контракту результата

В Playwright 1.37.0 auto-wait перед click() проверяет набор actionability conditions. Для click это attached, visible, stable, receives events и enabled. Этот механизм решает узкую задачу: не отправить действие в элемент, который исчез, невидим, движется, перекрыт или disabled. Он не может узнать смысл вашей операции. Кнопка может быть полностью ready для click, но сервер вернёт отказ, клиент покажет validation error или асинхронное подтверждение не появится.

Поэтому после действия нужен второй контракт — readiness. Он принадлежит сценарию, а не библиотеке. Для оплаты это может быть подтверждённый статус; для сохранения профиля — видимое сообщение и обновлённое значение; для импорта — запись с терминальным состоянием. Условие должно быть достаточно узким, чтобы отделить success от промежуточного состояния, и достаточно пользовательским, чтобы пережить смену внутренней разметки. Если product UI не предоставляет такого сигнала, тест обнаруживает не только техническую проблему, но и недостающую наблюдаемость интерфейса.

Четыре контракта и их владельцы
КонтрактВладелец смыслаПример проверкиНегативный сигналНедопустимая подмена
Selectorавтор теста и контракт доступной разметкиrole/button с name или test id в нужном scope0 или несколько совпаденийсчитать уникальный selector подтверждением операции
Actionabilityбиблиотека и состояние DOM перед действиемvisible, stable, receives events, enabled для clickэлемент не готов принять действиеждать бизнес-успех только потому, что click допустим
Readinessпродуктовый сценарий и UI-контрактstatus, итоговая строка, terminal stateпосле действия нужный факт не наблюдаетсяпроверять disabled/spinner вместо результата
Retrytest runner и политика CIномер попытки, outcome, новый workerfailed → passed или failed → failedобъявлять retry объяснением причины
Trace evidenceартефакт конкретной попыткиactions, snapshots, log, source, network log при записиevidence отсутствует или относится к другому запускувыдавать артефакт за root-cause analysis

Auto-wait полезен, но у него есть предел

Auto-wait заменяет ручной sleep ожиданием технической допустимости target. Это снижает зависимость от отрисовки, но не делает интерфейс «готовым»: готов только target для одного действия. Синхронизация с сервером и итоговый статус лежат за границей actionability.

Уникальный locator с role/name находит пользовательский объект, но не является селектором сетевого ответа и не подтверждает update store. Assertion должен отвечать на вопрос «что увидит человек?», а не цепляться за внутренний CSS, который сломается при рефакторинге.

// Иллюстрация контракта: locator выбирает действие, expect проверяет результат.
import { expect, test } from '@playwright/test';

test('подтверждает оплату', async ({ page }) => {
  await page.getByRole('button', { name: 'Оплатить' }).click();
  await expect(page.getByTestId('payment-state')).toHaveText('confirmed');
});

// getByRole — selector. toHaveText — readiness condition.
// Текст и test id должны соответствовать контракту конкретного продукта.

Фрагмент не запускается вместе с fixture и не даёт готовый селектор для чужого приложения. Его задача — показать роль строк: getByRole() указывает действие, toHaveText() описывает постусловие. В более сложном сценарии readiness может состоять из нескольких согласованных фактов, но это не повод прятать их под waitForTimeout(). Лучше назвать один терминальный state, а дополнительные поля проверять отдельными assertions с понятным сообщением об ошибке.

Retry меняет исполнение, но не причинность

Документация Playwright 1.37.0 описывает retry через worker process: после failure runner отбрасывает worker с браузером, а при включённом retry новый worker начинает с повторной попытки упавшего теста. Это важная техническая деталь для разбора. Повтор получает изолированный контекст исполнения и может не унаследовать причину, которая была в первой попытке. Значит, переход failed → passed — наблюдение о двух запусках, а не доказательство случайности, исправления или совместимости браузера.

Flaky — повод открыть карточку: фиксированные входы, expected readiness, selector, initial outcome и evidence retry. Общая запись или порядок набора могут исчезнуть в чистом retry context. Тогда правят данные или изоляцию, а не selector и timeout.

Схема из четырёх слоёв: locator указывает на кнопку, actionability разрешает click, readiness проверяет пользовательский факт после click, retry повторно запускает тест, а trace-shaped evidence прикрепляется к конкретному номеру попытки. Между слоями отмечено, что один не доказывает другой.
Диаграмма объясняет контракт ожидания, а не показывает запуск браузера. В ней нет реальных URL, trace, video, длительностей или данных тестового окружения.

Trace — evidence с известной областью действия

Trace Viewer версии 1.37.0 умеет показывать список действий, snapshots, action log, source и network log для записанного trace. Это хороший материал для вопроса «что происходило в этой попытке?». Например, можно заметить, что assertion начался до появления ожидаемого state, или что click пришёлся в другой control. Но нельзя перескочить от одного кадра к слову «причина». Trace не знает ожиданий бизнеса, а сетевой лог не доказывает, что серверная операция эквивалентна пользовательскому успеху без контракта UI.

Режим on-first-retry записывает trace при первом retry, поэтому evidence относится к повторной попытке. Он не восстановит initial failure. План диагностики сначала называет вопрос к artefact: selector, порядок action, видимый state или событие одного запуска.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Тест заканчивается timeout либо статусом flaky. Сначала запишите фазу: поиск locator, click, assertion результата или повторный запуск. Одно слово «упал» слишком мало для изменения.
  2. Причина. Определите, какой контракт нарушен: cardinality selector, actionability control, readiness condition, изоляция данных или политика retry. Не выводите причину из длительности, если нет отдельного измерения.
  3. Проверка selector и actionability. Сверьте user-facing locator и cardinality. Перекрытый или disabled элемент — ещё не проверка результата операции.
  4. Проверка readiness. Запишите terminal state и наблюдаемый элемент. Замените assertion побочного индикатора на web-first assertion пользовательского факта.
  5. Проверка retry. Сопоставьте initial и retry как разные worker contexts. Выясните, какие данные, настройки или cleanup не были частью контракта первой попытки.
  6. Проверка evidence. Если проект записал trace, проверьте, к какой попытке он относится и какой вопрос может сузить. Не заявляйте, что просмотрели trace, пока артефакт не предоставлен.
  7. Действие и rollback. Сделайте один малый diff и сохраните прежний assert либо config diff. Если contract не подтверждается, верните его и соберите evidence, а не поднимайте timeout.

Учебная модель удерживает границы видимыми

Исполнимая часть sidecar не использует Playwright API. Она создаёт в памяти два marked synthetic outcome: initial failure с достигнутым selector и недостигнутым readiness, затем passed на first retry. Обе попытки обязаны нести одну и ту же synthetic retry-policy: иначе их нельзя сравнивать как один учебный сценарий. Функция возвращает классификацию retry-masked-missing-readiness-contract-in-synthetic-input. Длинное имя намеренно: оно не говорит «это настоящий flake», а сообщает, какую форму увидела модель. Вторая ветка делает selector множественным и получает отдельный результат; этим fixture не позволяет склеить два вида проблемы.

node web/scripts/upgrade-2023-08.mjs --verify-fixture

# Команда детерминированно создаёт marked synthetic attempts только в памяти.
# Она не открывает браузер, не читает проект, не запускает Playwright,
# не записывает trace/video, не измеряет duration и не проверяет compatibility.
# PASS проверяет разделение selector, readiness, retry и synthetic evidence.
# PASS не означает, что настоящий тест flaky, что причина найдена или что UI готов.

Учебный plan принимает только readiness condition, совпадающий с condition из evidence; он не может подменить payment-state=confirmed произвольным удобным признаком. Timeout и selector change без evidence отклоняются. Это ограничение модели, не правило для всех проектов. Rollback возвращает неприменённый synthetic plan и сообщает, что test code и runner config не менялись.

Ограничения модели и следующий шаг

Модель не запускает browser, не получает trace/video, не читает исходник, не знает CI, не измеряет duration и не выбирает domain condition. Она не оценивает реальную стабильность. Рамка — Playwright v1.37.0 от 10 августа 2023; другую версию сверяют отдельно.

Следующий шаг — написать для одного flaky теста компактный контракт из четырёх строк: user-facing locator, expected readiness, retry policy и evidence policy. Добавьте owner UI-сигнала и способ rollback тестовой правки. Если не удаётся сформулировать readiness без CSS или sleep, остановитесь и обсудите с владельцем экрана, какое состояние должен видеть пользователь. Этот разговор обычно дешевле, чем ещё один круг timeout и более полезен, чем случайный зелёный retry.

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