diff --git a/editorial/agent-rewrites/188.json b/editorial/agent-rewrites/188.json index c1b3ee7..96da098 100644 --- a/editorial/agent-rewrites/188.json +++ b/editorial/agent-rewrites/188.json @@ -1,7 +1,7 @@ { "index": 188, "slug": "editorial-2022-10-mechanism-ui-tests", - "title": "Механика UI-теста: наблюдаемое состояние вместо случайной паузы", - "excerpt": "Как связать действие пользователя, переход состояния и проверяемый результат, чтобы UI-тест объяснял сбой, а не маскировал его ожиданием.", - "contentHtml": "
UI-тест нажимает «Сохранить», ждёт секунду и иногда падает на проверке результата. На быстрой машине он успевает увидеть новый экран. На занятой машине проверка срабатывает раньше. Если увеличить паузу, тест станет медленнее, но не умнее. Цена ошибки — флак в CI, повторный запуск и потеря доверия к зелёному результату. При настоящем дефекте команда получает сигнал поздно, потому что тест не знает, какого состояния он ждёт.
\nТезис простой: UI-тест должен ждать не время, а наблюдаемое состояние, которое означает завершение пользовательского действия. Страница должна назвать переход. Тест должен проверить это имя через доступную семантику или другой устойчивый контракт. Переход, наблюдение и assertion остаются отдельными слоями. Если их смешать, timeout превращается в замену модели.
\nРассмотрим форму с текстовым полем и кнопкой отправки. Пользователь вводит текст. Состояние формы остаётся editing. После отправки приложение переходит в submitting и связывает попытку с идентификатором запроса. Только подтверждение этой попытки переводит форму в saved. Если подтверждение не пришло или относится к старой попытке, интерфейс показывает recovery-required, а не объявляет успех.
В этой схеме есть четыре разных факта. Клик сообщает о намерении. Переход меняет модель. UI делает переход видимым. Assertion проверяет видимый результат. Клик не доказывает отправку. Исчезновение кнопки не доказывает сохранение. Истёкшая секунда не доказывает ни один из этих фактов.
\n| Слой | Пример | Чего он не доказывает | Нужная проверка |
|---|---|---|---|
| Действие | Пользователь нажал «Сохранить» | Ответ принят | Проверить переход в pending |
| Переход | submitting и request-01 | Результат виден человеку | Проверить guard и связь ответа с запросом |
| Наблюдение | role=status, имя «Сохранение выполняется» | Сеть работала без ошибок | Проверить доступный результат сценария |
| Ответ | Подтверждение с тем же идентификатором | Любой экран с текстом «Готово» корректен | Проверить переход в saved |
Флаг isLoading отвечает только на вопрос о занятом состоянии. Он не различает первую и вторую попытку, не защищает от двойной отправки и не объясняет, что делать после ошибки. Для асинхронной формы нужен корреляционный признак. В учебном примере это requestId. Первый submit получает request-01. Ответ с request-00 считается устаревшим и не меняет экран.
Тот же guard закрывает повторный submit. Пока состояние равно submitting, второй клик не создаёт новый запрос. В реальном интерфейсе кнопка может стать disabled, но смысл правила должен жить в переходе состояния, а не только в DOM. Иначе другой обработчик или быстрый повторный клик обойдёт визуальную блокировку.
Отрицательный путь важен не меньше happy path. Если сервер не подтвердил запрос, форма не должна показывать «Сохранено». Она должна сохранить черновик, показать понятное восстановление и дать действие, предусмотренное продуктом: повторить, проверить результат или вернуться к редактированию. Конкретная политика зависит от системы. Тест обязан проверить, что ложного успеха нет.
\nСледующий код ограничен локальной моделью. Он не открывает страницу, не отправляет HTTP-запрос и не показывает результат реального продукта. Его задача — сделать инварианты видимыми до написания browser-теста: повторная отправка блокируется, старый ответ игнорируется, отсутствие подтверждения ведёт к восстановлению.
\nconst state = { phase: 'editing', text: 'Согласовать условия', requestId: null };\n\nfunction submit(current) {\n if (current.phase === 'submitting') {\n return { ...current, event: 'submit-blocked' };\n }\n return { ...current, phase: 'submitting', requestId: 'request-01' };\n}\n\nfunction acknowledge(current, id) {\n if (current.phase !== 'submitting' || id !== current.requestId) {\n return { ...current, event: 'stale-acknowledgement' };\n }\n return { ...current, phase: 'saved', event: 'saved' };\n}\n\nfunction fail(current, id) {\n if (current.phase !== 'submitting' || id !== current.requestId) {\n return { ...current, event: 'stale-failure' };\n }\n return { ...current, phase: 'recovery-required', event: 'recovery' };\n}\nКод намеренно не решает transport, retry и доступность. Он фиксирует границу переходов. После него browser-тест может проверять факты интерфейса: появился статус отправки, второй submit не изменил попытку, корректный ответ показал сохранение, а устаревший ответ не перекрыл новую форму.
\nПосле того как страница получила понятный контракт, assertion выражает пользовательский факт. В Playwright пример может выглядеть так:
\nawait page.getByRole('button', { name: 'Сохранить' }).click();\nawait expect(page.getByRole('status'))\n .toHaveText('Сохранение выполняется');\n\n// Контролируемый ответ тестового окружения приходит здесь.\nawait expect(page.getByRole('status'))\n .toHaveText('Изменение сохранено');\nВызов toHaveText ждёт условие до установленного timeout. Это полезно только тогда, когда условие связано с переходом, который важен пользователю. Проверка «кнопка исчезла» может пройти из-за закрытия модального окна, ошибки рендера или смены маршрута. Она не заменяет статус результата.
Выбирайте locator по смыслу. Роль и имя подходят для состояния, которое должен распознать пользователь. data-testid уместен для технического узла, у которого нет пользовательской семантики. Ни один locator не исправит отсутствующий contract. Если экран не различает pending, success и recovery, автоматизация будет угадывать состояние по косвенным признакам.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
После click стоит wait(1000) | Нет названного pending-состояния | Найти видимый результат до и после отправки | Добавить status и assertion на него |
| Тест ждёт исчезновения кнопки | DOM-признак подменяет бизнес-результат | Проверить, что будет при server error | Утвердить success и recovery отдельно |
| Два быстрых click создают два эффекта | Guard живёт только в UI | Повторить submit в состоянии submitting | Запретить переход и проверить один request id |
| Поздний ответ показывает старый успех | Ответ не связан с попыткой | Передать устаревший requestId | Игнорировать ответ или отправить его в согласованный recovery-путь |
| После ошибки нечего повторить | Форма очистила черновик до подтверждения | Проверить текст и snapshot после failure | Сохранить черновик и назвать действие восстановления |
Модель не знает о re-render, планировщике фреймворка, локализации, авторизации, нескольких вкладках и фактической доставке ответа. request-01 — учебное имя, а не требование к production-протоколу. В реальной системе поздний ответ может требовать журнала, повторного чтения данных или server reconciliation, а не молчаливого игнорирования.
Роль status или alert нельзя добавлять только ради теста. Доступное сообщение должно соответствовать срочности и поведению интерфейса. Источник текста, локализация и фокус требуют отдельной проверки. UI-тест подтверждает выбранный contract, но не сертифицирует всю доступность страницы.
Учебный пример не доказывает, что флак исчез, не измеряет длительность CI и не сообщает production-результаты. Для такого вывода нужны воспроизводимые запуски, версия браузера и runner, окружение, история падений и правило сравнения. Без этих данных корректно утверждать только то, что assertion теперь ждёт названное состояние.
\nИзменение готово, если один сценарий проходит по четырём наблюдаемым веткам: pending появляется после действия, корректное подтверждение показывает success, повторная отправка не создаёт второй переход, а устаревший или отсутствующий ответ не показывает ложный успех. Для каждой ветки есть assertion на public UI contract. В коде нет произвольной паузы, которая заменяет отсутствующее состояние. Локальные проверки модели и browser-тест имеют явно описанные границы.
\nПроблема теста, который ждёт секунду потому, что после клика ему нечего наблюдать, обычно скрыта в самой странице. Внутри меняется несколько флагов, затем кто-то закрывает модалку, а итог пользователю виден только случайно. Цена — флак и дорогая диагностика: лог показывает click, но не показывает, какой transition не состоялся и почему.
\nМеханика ниже строит один узкий мост: domain transition создаёт named visible state, а assertion проверяет именно его. Это не настоящий Playwright run и не реализация WebDriver. Локальная fixture специально не содержит таймеров, selector-ов, DOM или HTTP, чтобы можно было проверить reason перехода и rollback без истории конкретного браузера.
\nПервый слой — intent: пользователь ввёл текст или нажал submit. Второй — transition: state machine решила, что переход допустим, и записала request id. Третий — observation: экран получил status либо alert с доступным именем. Когда тест сразу читает внутренний флаг второго слоя, он перестаёт проверять пользовательский результат. Когда он ждёт только время, он не проверяет ни один слой. Нужен явный третий слой, связанный с переходом.
\n| Слой | Хранит | Не должен утверждать | Проверка |
|---|---|---|---|
| Intent | ввод и click | что сохранение принято | событие доступно пользователю |
| Transition | phase и request id | что браузер уже отрисовал результат | guard и matching acknowledgement |
| Observation | role, name, testState | что сеть работала | assertion пользовательского сценария |
| Транспорт | запрос и reply | смысл UI без mapping | отдельный API-contract |
Если confirmation приходит после submit, модели недостаточно boolean isLoading. Ей нужно знать, какую попытку она ждёт. Первый submit создаёт request-01; acknowledgement с request-00 отвергается. После rollback счётчик не возвращается назад: повторный submit получает request-02, а запоздалый reply от первой попытки остаётся чужим. Это не утверждение о формате production ID и не требование передавать именно такое поле по сети. Это способ записать invariант: старый reply не может признать текущий transition.
Дальше работает guard. Повторный submit во время submitting возвращает submit-guard-active и не создаёт второй request id. Такой исход важен именно как наблюдаемая граница: кнопка в реальном UI может стать disabled, но смысл должен жить не только в button. Иначе пользовательский сценарий будет зависеть от timing отрисовки, а компонент-автоматизация найдёт обходной путь.
Ниже нет page.click, locator или sleep. Вызовы создают намерение, планируют transition и вручную передают acknowledgement. Проверка не измеряет задержку: она смотрит на shape evidence и на testState. Если заменить order событий, stale acknowledgement не сдвигает сценарий; если сделать rollback, состояние возвращается к snapshot. Это удобный first gate перед настоящим UI.
import { runUiTestsFixture } from './upgrade-2022-10.mjs';\nconst report = runUiTestsFixture();\nif (!Object.values(report.assertions).every(Boolean)) throw new Error('fixture contract failed');\nconsole.log(report.evidence.planned.observation.testState); // 'submitting'\nconsole.log(report.evidence.accepted.observation.name); // 'Изменение сохранено'\nПосле failure fixture возвращает pre-submit snapshot: editing с черновиком, без request id и без сохранённого результата. При этом счётчик запросов не откатывается. Поэтому повторный submit получает новый id, а поздний request-01 не может подтвердить вторую попытку. Это не метрика надёжности и не готовая политика production rollback. Это узкая защита от модели, которая после возврата случайно принимает старый reply за текущий успех.
После модели assertion должен выражать пользовательский факт, например: после действия есть status с именем «Отправка ожидает подтверждения», а после контролируемого ответа — status «Изменение сохранено». В Playwright-документации v1.27.0 retrying assertion повторяет проверку выбранного условия до timeout. Поэтому timeout — ограничитель ожидания условия, не само условие. Если condition выбран как «кнопка где-то исчезла», инструмент добросовестно ждёт неправильный факт.
\nСтабильный selector допустим, если он не подменяет семантику. Для чисто технического контейнера можно применять data-testid; для результата сценария лучше сначала искать роль и имя, которые уже нужны человеку. Это не абсолютное правило: составной виджет, локализация и визуально скрытый status потребуют отдельного решения. Важно зафиксировать, какой contract должен пережить рефакторинг, а какой selector является внутренней деталью.
Первая ошибка — назвать все состояния loading. Пользователь не отличит ожидание подтверждения от повторной попытки, а тест не отличит новый запрос от старого reply. Вторая — очищать форму сразу после click. Тогда rollback нечего восстанавливать. Третья — делать success индикатор побочным эффектом network callback без проверки request id. В такой схеме поздний callback способен сообщить о старой операции поверх нового редактирования.
Четвёртая ошибка — добавить aria-live или role только ради теста. WCAG 2.1, критерий 4.1.3, требует, чтобы сообщения о состоянии можно было определить программно и передать вспомогательным технологиям без перевода фокуса. Это не даёт универсального шаблона: роль должна соответствовать срочности и поведению интерфейса. Полный accessibility-аудит реализации остаётся отдельной задачей.
Учебная модель не знает re-render, framework scheduler, локализацию, авторизацию, несколько вкладок и фактическую доставку reply. Она не говорит, что любой stale reply следует молча отбросить: production может потребовать журнал, server reconciliation или повторное чтение данных. Фиксированная строка request id и ручной acknowledgement существуют только для детерминированной fixture.
\nСледующий шаг — взять один настоящий тест и нарисовать его timeline без времени: intent, pending observation, controlled response, final observation. Отдельно обозначьте fail path. Если на этой схеме нет наблюдаемого условия после click, сначала добавьте contract страницы. Только потом заменяйте sleep на auto-retrying assertion выбранного инструмента.
\nТехническая граница построена на immutable Playwright documentation commit v1.27.0, WebDriver Working Draft от 25 октября 2022 года и WCAG 2.1 Recommendation. Ни один из документов не является доказательством того, что локальная fixture открывала страницу или что конкретный UI обладает заявленной доступностью.
\n