From 84645cf2e3793ba3f6abd1f0c2e3621d59f9cd7c Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 19:20:07 +0300 Subject: [PATCH] Rewrite editorial article 188 --- editorial/agent-rewrites/188.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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

Механизм: от действия к результату

\n

Рассмотрим форму с текстовым полем и кнопкой отправки. Пользователь вводит текст. Состояние формы остаётся editing. После отправки приложение переходит в submitting и связывает попытку с идентификатором запроса. Только подтверждение этой попытки переводит форму в saved. Если подтверждение не пришло или относится к старой попытке, интерфейс показывает recovery-required, а не объявляет успех.

\n

В этой схеме есть четыре разных факта. Клик сообщает о намерении. Переход меняет модель. UI делает переход видимым. Assertion проверяет видимый результат. Клик не доказывает отправку. Исчезновение кнопки не доказывает сохранение. Истёкшая секунда не доказывает ни один из этих фактов.

\n
Что ждёт проверка после отправки формы
СлойПримерЧего он не доказываетНужная проверка
ДействиеПользователь нажал «Сохранить»Ответ принятПроверить переход в pending
Переходsubmitting и request-01Результат виден человекуПроверить guard и связь ответа с запросом
Наблюдениеrole=status, имя «Сохранение выполняется»Сеть работала без ошибокПроверить доступный результат сценария
ОтветПодтверждение с тем же идентификаторомЛюбой экран с текстом «Готово» корректенПроверить переход в saved
\n

Почему одной переменной loading недостаточно

\n

Флаг isLoading отвечает только на вопрос о занятом состоянии. Он не различает первую и вторую попытку, не защищает от двойной отправки и не объясняет, что делать после ошибки. Для асинхронной формы нужен корреляционный признак. В учебном примере это requestId. Первый submit получает request-01. Ответ с request-00 считается устаревшим и не меняет экран.

\n

Тот же guard закрывает повторный submit. Пока состояние равно submitting, второй клик не создаёт новый запрос. В реальном интерфейсе кнопка может стать disabled, но смысл правила должен жить в переходе состояния, а не только в DOM. Иначе другой обработчик или быстрый повторный клик обойдёт визуальную блокировку.

\n

Отрицательный путь важен не меньше happy path. Если сервер не подтвердил запрос, форма не должна показывать «Сохранено». Она должна сохранить черновик, показать понятное восстановление и дать действие, предусмотренное продуктом: повторить, проверить результат или вернуться к редактированию. Конкретная политика зависит от системы. Тест обязан проверить, что ложного успеха нет.

\n

Учебный пример: переходы без браузера

\n

Следующий код ограничен локальной моделью. Он не открывает страницу, не отправляет HTTP-запрос и не показывает результат реального продукта. Его задача — сделать инварианты видимыми до написания browser-теста: повторная отправка блокируется, старый ответ игнорируется, отсутствие подтверждения ведёт к восстановлению.

\n
const 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
\"Схема
Схема отделяет действие, переход, наблюдение и отрицательный путь. Это учебная модель, а не trace браузера и не результат production-прогона.
\n

Как выглядит browser assertion

\n

После того как страница получила понятный контракт, assertion выражает пользовательский факт. В Playwright пример может выглядеть так:

\n
await page.getByRole('button', { name: 'Сохранить' }).click();\nawait expect(page.getByRole('status'))\n  .toHaveText('Сохранение выполняется');\n\n// Контролируемый ответ тестового окружения приходит здесь.\nawait expect(page.getByRole('status'))\n  .toHaveText('Изменение сохранено');
\n

Вызов toHaveText ждёт условие до установленного timeout. Это полезно только тогда, когда условие связано с переходом, который важен пользователю. Проверка «кнопка исчезла» может пройти из-за закрытия модального окна, ошибки рендера или смены маршрута. Она не заменяет статус результата.

\n

Выбирайте locator по смыслу. Роль и имя подходят для состояния, которое должен распознать пользователь. data-testid уместен для технического узла, у которого нет пользовательской семантики. Ни один locator не исправит отсутствующий contract. Если экран не различает pending, success и recovery, автоматизация будет угадывать состояние по косвенным признакам.

\n

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

\n
Карта диагностики нестабильной UI-проверки
СимптомПричинаПроверкаДействие
После click стоит wait(1000)Нет названного pending-состоянияНайти видимый результат до и после отправкиДобавить status и assertion на него
Тест ждёт исчезновения кнопкиDOM-признак подменяет бизнес-результатПроверить, что будет при server errorУтвердить success и recovery отдельно
Два быстрых click создают два эффектаGuard живёт только в UIПовторить submit в состоянии submittingЗапретить переход и проверить один request id
Поздний ответ показывает старый успехОтвет не связан с попыткойПередать устаревший requestIdИгнорировать ответ или отправить его в согласованный recovery-путь
После ошибки нечего повторитьФорма очистила черновик до подтвержденияПроверить текст и snapshot после failureСохранить черновик и назвать действие восстановления
\n

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

\n
  1. Запишите симптом. Сохраните текст падения, действие перед ним и текущий assertion. Не увеличивайте timeout до диагностики.
  2. Назовите состояния. Опишите pending, success и recovery словами, которые понимает пользователь.
  3. Назначьте владельца перехода. Укажите, какой обработчик создаёт request id, кто принимает ответ и кто меняет видимый статус.
  4. Проверьте отрицательный путь. Подайте пустой ввод, повторный submit, устаревший ответ и отсутствие подтверждения.
  5. Добавьте локальные проверки модели. Убедитесь, что guard, correlation и rollback не зависят от времени и DOM.
  6. Настройте контролируемый browser-вход. В тестовом окружении зафиксируйте разрешённый способ получить success и failure. Не выдавайте локальную модель за e2e.
  7. Поставьте assertion на observable state. Проверяйте role, имя и текст результата. Timeout оставьте ограничителем, а не условием успеха.
  8. Проверьте обратимость. Если новый contract расходится с UX, откатите его вместе с assertion. Не оставляйте паузу как постоянный обход.
\n

Ограничения

\n

Модель не знает о re-render, планировщике фреймворка, локализации, авторизации, нескольких вкладках и фактической доставке ответа. request-01 — учебное имя, а не требование к production-протоколу. В реальной системе поздний ответ может требовать журнала, повторного чтения данных или server reconciliation, а не молчаливого игнорирования.

\n

Роль status или alert нельзя добавлять только ради теста. Доступное сообщение должно соответствовать срочности и поведению интерфейса. Источник текста, локализация и фокус требуют отдельной проверки. UI-тест подтверждает выбранный contract, но не сертифицирует всю доступность страницы.

\n

Учебный пример не доказывает, что флак исчез, не измеряет длительность CI и не сообщает production-результаты. Для такого вывода нужны воспроизводимые запуски, версия браузера и runner, окружение, история падений и правило сравнения. Без этих данных корректно утверждать только то, что assertion теперь ждёт названное состояние.

\n

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

\n

Изменение готово, если один сценарий проходит по четырём наблюдаемым веткам: pending появляется после действия, корректное подтверждение показывает success, повторная отправка не создаёт второй переход, а устаревший или отсутствующий ответ не показывает ложный успех. Для каждой ветки есть assertion на public UI contract. В коде нет произвольной паузы, которая заменяет отсутствующее состояние. Локальные проверки модели и browser-тест имеют явно описанные границы.

\n

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

" + "title": "Механика UI-проверки: сделать состояние наблюдаемым", + "excerpt": "Как отделить transition, наблюдение и assertion, чтобы не лечить флак произвольным ожиданием.", + "contentHtml": "

Проблема теста, который ждёт секунду потому, что после клика ему нечего наблюдать, обычно скрыта в самой странице. Внутри меняется несколько флагов, затем кто-то закрывает модалку, а итог пользователю виден только случайно. Цена — флак и дорогая диагностика: лог показывает click, но не показывает, какой transition не состоялся и почему.

\n

Механика ниже строит один узкий мост: domain transition создаёт named visible state, а assertion проверяет именно его. Это не настоящий Playwright run и не реализация WebDriver. Локальная fixture специально не содержит таймеров, selector-ов, DOM или HTTP, чтобы можно было проверить reason перехода и rollback без истории конкретного браузера.

\n

Три слоя, которые нельзя склеивать

\n

Первый слой — intent: пользователь ввёл текст или нажал submit. Второй — transition: state machine решила, что переход допустим, и записала request id. Третий — observation: экран получил status либо alert с доступным именем. Когда тест сразу читает внутренний флаг второго слоя, он перестаёт проверять пользовательский результат. Когда он ждёт только время, он не проверяет ни один слой. Нужен явный третий слой, связанный с переходом.

\n
Разделение ответственности
СлойХранитНе должен утверждатьПроверка
Intentввод и clickчто сохранение принятособытие доступно пользователю
Transitionphase и request idчто браузер уже отрисовал результатguard и matching acknowledgement
Observationrole, name, testStateчто сеть работалаassertion пользовательского сценария
Транспортзапрос и replyсмысл UI без mappingотдельный API-contract
\n

Почему request id входит в учебную модель

\n

Если confirmation приходит после submit, модели недостаточно boolean isLoading. Ей нужно знать, какую попытку она ждёт. Первый submit создаёт request-01; acknowledgement с request-00 отвергается. После rollback счётчик не возвращается назад: повторный submit получает request-02, а запоздалый reply от первой попытки остаётся чужим. Это не утверждение о формате production ID и не требование передавать именно такое поле по сети. Это способ записать invariант: старый reply не может признать текущий transition.

\n

Дальше работает guard. Повторный submit во время submitting возвращает submit-guard-active и не создаёт второй request id. Такой исход важен именно как наблюдаемая граница: кнопка в реальном UI может стать disabled, но смысл должен жить не только в button. Иначе пользовательский сценарий будет зависеть от timing отрисовки, а компонент-автоматизация найдёт обходной путь.

\n

Пример: local fixture проверяет owner перехода

\n

Ниже нет page.click, locator или sleep. Вызовы создают намерение, планируют transition и вручную передают acknowledgement. Проверка не измеряет задержку: она смотрит на shape evidence и на testState. Если заменить order событий, stale acknowledgement не сдвигает сценарий; если сделать rollback, состояние возвращается к snapshot. Это удобный first gate перед настоящим UI.

\n
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 за текущий успех.

\n
\"Схема
Рисунок отделяет модель переходов от инструмента автоматизации и не показывает настоящий DOM или WebDriver-сеанс.
\n

Как писать browser assertion после контракта

\n

После модели assertion должен выражать пользовательский факт, например: после действия есть status с именем «Отправка ожидает подтверждения», а после контролируемого ответа — status «Изменение сохранено». В Playwright-документации v1.27.0 retrying assertion повторяет проверку выбранного условия до timeout. Поэтому timeout — ограничитель ожидания условия, не само условие. Если condition выбран как «кнопка где-то исчезла», инструмент добросовестно ждёт неправильный факт.

\n

Стабильный selector допустим, если он не подменяет семантику. Для чисто технического контейнера можно применять data-testid; для результата сценария лучше сначала искать роль и имя, которые уже нужны человеку. Это не абсолютное правило: составной виджет, локализация и визуально скрытый status потребуют отдельного решения. Важно зафиксировать, какой contract должен пережить рефакторинг, а какой selector является внутренней деталью.

\n

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

\n
  1. Симптом. В отчёте есть timeout либо sleep между click и проверкой, но не видно нужного состояния.
  2. Причина. Intent, transition и observation склеены: тест знает click, но не знает owner результата.
  3. Проверка. Найдите phase, request id и текст/role результата. Если одного нет, тесту нечего ждать корректно.
  4. Действие. Добавьте named status для pending/saved и alert для recovery; свяжите их с transition в одном месте.
  5. Проверка guard. Локально подтвердите duplicate submit и stale reply. Fixture не заменяет browser тест.
  6. Rollback. Откатывайте весь новый contract вместе с тестом, если доступное состояние расходится с утверждённой UX-копией; не возвращайте sleep как постоянный патч.
\n

Ошибки внедрения

\n

Первая ошибка — назвать все состояния loading. Пользователь не отличит ожидание подтверждения от повторной попытки, а тест не отличит новый запрос от старого reply. Вторая — очищать форму сразу после click. Тогда rollback нечего восстанавливать. Третья — делать success индикатор побочным эффектом network callback без проверки request id. В такой схеме поздний callback способен сообщить о старой операции поверх нового редактирования.

\n

Четвёртая ошибка — добавить aria-live или role только ради теста. WCAG 2.1, критерий 4.1.3, требует, чтобы сообщения о состоянии можно было определить программно и передать вспомогательным технологиям без перевода фокуса. Это не даёт универсального шаблона: роль должна соответствовать срочности и поведению интерфейса. Полный accessibility-аудит реализации остаётся отдельной задачей.

\n

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

\n

Учебная модель не знает 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

Историческая граница октября 2022

\n

Техническая граница построена на immutable Playwright documentation commit v1.27.0, WebDriver Working Draft от 25 октября 2022 года и WCAG 2.1 Recommendation. Ни один из документов не является доказательством того, что локальная fixture открывала страницу или что конкретный UI обладает заявленной доступностью.

\n

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

" }