{ "index": 189, "slug": "editorial-2022-10-practice-ui-tests", "title": "UI-тест ждёт состояние, а не секунду", "excerpt": "Как заменить хрупкую паузу после действия на наблюдаемый контракт, проверить отрицательный путь и понять, когда сценарий действительно готов.", "contentHtml": "

UI-тест нажимает «Сохранить», ждёт секунду и ищет сообщение «Сохранено». На ноутбуке разработчика он проходит. На загруженном CI падает по таймауту. После увеличения паузы тест становится медленнее, но не умнее: сеть может ответить раньше, позже или ошибкой. В каждом случае одна секунда сообщает только о времени. Она не сообщает, принял ли интерфейс действие.

\n

Цена ошибки двойная. CI даёт ложный красный сигнал, а команда начинает повторять запуск и увеличивать timeout. Реальный дефект теряется среди случайных падений. Иногда тест зелёный, хотя пользователь получил старое сообщение или второй запрос. Надёжный UI-тест должен ждать факт, который видит пользователь, и проверять переход к этому факту.

\n

Тезис: ожидать нужно наблюдаемое состояние

\n

После действия у страницы должен быть короткий и понятный контракт. Например: поле редактируется; отправка ожидает подтверждения; изменение сохранено; сценарий требует восстановления. У каждого состояния есть владелец, доступное представление и условие перехода. Тест проверяет это представление. Он не угадывает готовность по прошедшему времени, исчезновению CSS-класса или случайной смене DOM.

\n

У контракта есть три слоя. Действие пользователя создаёт намерение. Логика приложения принимает или отклоняет переход и связывает его с конкретной попыткой. Интерфейс показывает результат через текст, роль или другое доступное наблюдение. Если один слой пропущен, тест начинает читать внутренний флаг либо ждать паузу. Оба варианта дают слабый оракул.

\n

Механизм на простом сценарии

\n

Рассмотрим форму с одним полем. Пользователь вводит текст и нажимает кнопку. До ответа сервера состояние должно стать submitting. Повторный submit в этом состоянии не создаёт вторую попытку. Ответ принимается только для текущего requestId. Тогда поздний ответ старой попытки не сможет показать успех поверх нового редактирования.

\n

В учебной модели ниже нет браузера, сети и таймера. Она показывает только переходы состояния. Поэтому пример нельзя выдавать за результат запуска настоящего e2e-теста. Его задача — сделать правила явными до выбора локаторов и способа управления ответом.

\n
import { expect, test } from '@playwright/test';\n\ntest('сохраняет профиль после подтверждения', async ({ page }) => {\n  await page.goto('/profile');\n  await page.getByLabel('Почта').fill('user@example.test');\n  await page.getByRole('button', { name: 'Сохранить' }).click();\n\n  await expect(page.getByRole('status'))\n    .toHaveText('Отправка ожидает подтверждения');\n\n  // Тестовое окружение должно ответить именно этой попытке.\n  await expect(page.getByRole('status'))\n    .toHaveText('Изменение сохранено');\n});
\n

В примере первая проверка важна не меньше второй. Она доказывает, что click привёл к состоянию ожидания, а не просто вернул управление обработчику. Вторая проверка подтверждает итог. Если приложение не показывает промежуточное состояние, это не повод добавить sleep. Сначала нужно решить, что пользователь должен увидеть во время операции.

\n

Не называйте успешным любой найденный текст. Сообщение от предыдущего действия может остаться в DOM. Связь с текущим requestId должна жить во внутреннем состоянии приложения: при новом submit старый статус очищается, а ответ старой попытки отбрасывается. В тесте проверяйте публичный результат — роль, доступное имя и актуальный текст, — а не сам идентификатор, если пользователь его не видит. Если локализация меняет текст, зафиксируйте отдельный стабильный контракт, но не прячьте смысл в техническом атрибуте, который понятен только тесту.

\n
\"Переходы
Учебная схема отделяет действие, ожидание подтверждения, успешный ответ и восстановление. Она не показывает браузерный trace и не доказывает работу конкретного приложения.
\n

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

\n
Диагностика нестабильной UI-проверки
СимптомПричинаПроверкаДействие
После click стоит wait(1000)Нет условия готовностиНазвать состояние, которое должен увидеть пользовательДобавить status или alert и ждать его
Тест видит старый successСообщение не связано с попыткойОчистить состояние и проверить новый requestIdСопоставлять ответ с текущим переходом
Два click дают два запросаНет guard в состоянии отправкиПовторить действие до ответаЗаблокировать переход из submitting
Поздний ответ меняет новый экранНе отбрасывается stale replyПередать ответ старого requestId после нового submitИгнорировать чужой ответ и записать сигнал
После ошибки нечего повторитьСбой очищает черновикПроверить текст и доступное действие восстановленияОставить данные, показать alert и определить retry
\n

Почему auto-retrying assertion не заменяет контракт

\n

Playwright умеет повторно проверять web-assertion до успеха или истечения timeout. Это полезный механизм: тест не обязан угадывать задержку рендера. Но инструмент не знает, означает ли исчезнувшая кнопка сохранение, закрытие диалога или ошибку. Он также не отличит текущий ответ от старого. Неправильное условие, которое повторяется дольше, остаётся неправильным условием.

\n

Выбирайте assertion по пользовательскому факту. toHaveText проверяет сообщение, toBeVisible — наличие видимого состояния, toBeDisabled — запрет повторного действия. Эти проверки отвечают на разные вопросы. Не заменяйте их общим isVisible() в обычном асинхронном сценарии: такой вызов возвращает снимок в момент вызова и сам по себе не ждёт нужного состояния.

\n

Локатор по роли и имени обычно лучше связывает тест с доступным интерфейсом. data-testid остаётся допустимым для технического контейнера, сложного виджета или случая, где публичная семантика не подходит. Выбор должен быть осознанным. Не добавляйте скрытый атрибут только для того, чтобы не формулировать пользовательский результат.

\n

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

\n
  1. Зафиксируйте исходный симптом: действие, текущую проверку, timeout, сообщение падения и состояние страницы.
  2. Удалите объяснение «медленно» и назовите недостающий факт: запрос принят, отправка заблокирована, результат сохранён или ошибка объяснена.
  3. Опишите переходы вокруг действия: исходное состояние, pending, success и recovery. Для каждого укажите владельца и видимое наблюдение.
  4. Проверьте отрицательный путь: пустой ввод, двойной submit, отказ ответа, повтор после ошибки и поздний ответ старой попытки.
  5. Добавьте в интерфейс одну устойчивую семантическую точку наблюдения. Согласуйте текст, роль и доступное имя с UX и доступностью.
  6. В browser-тесте дождитесь промежуточного и конечного состояния через auto-retrying assertion. Не переносите учебную модель как доказательство работы страницы.
  7. Запустите сценарий с контролируемым успехом и отказом. Сохраните версию браузера, окружение, команду и отчёт, если делаете вывод о стабильности.
  8. Если контракт оказался неверным, откатите его вместе с assertion. Не оставляйте фиксированную паузу как постоянный запасной путь.
\n

Отрицательный путь и восстановление

\n

Ошибку нельзя сводить к красному фону. Пользователь должен понять, что произошло и что можно сделать дальше. Для учебного сценария достаточно состояния recovery-required: черновик остаётся, успешный результат не появляется, а экран предлагает повторить операцию или вернуться к редактированию. В production причина может быть сетевой, серверной или связанной с правами. UI-тест не должен смешивать эти причины, если продукт показывает их по-разному.

\n

Rollback тоже имеет смысл только при ясном контракте. Возврат к редактированию не означает, что запрос отменён на сервере. Если сервер мог принять операцию, нужен отдельный reconciliation или повторное чтение. Поэтому тест проверяет ограниченный факт: интерфейс не объявляет успех без подтверждения и не теряет ввод при выбранном сценарии восстановления. Он не доказывает согласованность всех систем.

\n

Ограничения

\n

Ожидание состояния не устраняет все причины нестабильности. Тест может падать из-за неверных данных, авторизации, гонки между вкладками, недоступного сервиса или дефекта самого интерфейса. Доступное имя может зависеть от локали. Сетевой ответ может прийти дважды. Каждый такой случай требует отдельного контракта и отдельной проверки.

\n

Не объявляйте флак исправленным после одного зелёного запуска. Учебный код не даёт production-статистику. Документация инструмента объясняет поведение assertion, но не подтверждает состояние вашего приложения. Проверяемый вывод должен быть узким: «тест ждёт named state и проверяет текущий результат», а не «сценарий теперь всегда стабилен».

\n

Критерий готовности

\n

Сценарий готов, если после каждого значимого действия существует проверяемое состояние с понятным смыслом; assertion ждёт это состояние, а не прошедшее время; повторная отправка и устаревший ответ имеют определённый исход; ошибка оставляет пользователю объяснимый путь восстановления; локатор не скрывает отсутствие публичного результата. В отчёте отдельно указано, что проверено локально, а что подтверждено реальным browser-запуском.

\n

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

" }