{ "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

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

" }