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