8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 188,
|
||
"slug": "editorial-2022-10-mechanism-ui-tests",
|
||
"title": "Механика UI-теста: наблюдаемое состояние вместо случайной паузы",
|
||
"excerpt": "Как связать действие пользователя, переход состояния и проверяемый результат, чтобы UI-тест объяснял сбой, а не маскировал его ожиданием.",
|
||
"contentHtml": "<p>UI-тест нажимает «Сохранить», ждёт секунду и иногда падает на проверке результата. На быстрой машине он успевает увидеть новый экран. На занятой машине проверка срабатывает раньше. Если увеличить паузу, тест станет медленнее, но не умнее. Цена ошибки — флак в CI, повторный запуск и потеря доверия к зелёному результату. При настоящем дефекте команда получает сигнал поздно, потому что тест не знает, какого состояния он ждёт.</p>\n<p>Тезис простой: UI-тест должен ждать не время, а наблюдаемое состояние, которое означает завершение пользовательского действия. Страница должна назвать переход. Тест должен проверить это имя через доступную семантику или другой устойчивый контракт. Переход, наблюдение и assertion остаются отдельными слоями. Если их смешать, timeout превращается в замену модели.</p>\n<h2>Механизм: от действия к результату</h2>\n<p>Рассмотрим форму с текстовым полем и кнопкой отправки. Пользователь вводит текст. Состояние формы остаётся <code>editing</code>. После отправки приложение переходит в <code>submitting</code> и связывает попытку с идентификатором запроса. Только подтверждение этой попытки переводит форму в <code>saved</code>. Если подтверждение не пришло или относится к старой попытке, интерфейс показывает <code>recovery-required</code>, а не объявляет успех.</p>\n<p>В этой схеме есть четыре разных факта. Клик сообщает о намерении. Переход меняет модель. UI делает переход видимым. Assertion проверяет видимый результат. Клик не доказывает отправку. Исчезновение кнопки не доказывает сохранение. Истёкшая секунда не доказывает ни один из этих фактов.</p>\n<div class=\"table-scroll\"><table><caption>Что ждёт проверка после отправки формы</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Пример</th><th scope=\"col\">Чего он не доказывает</th><th scope=\"col\">Нужная проверка</th></tr></thead><tbody><tr><td>Действие</td><td>Пользователь нажал «Сохранить»</td><td>Ответ принят</td><td>Проверить переход в pending</td></tr><tr><td>Переход</td><td><code>submitting</code> и <code>request-01</code></td><td>Результат виден человеку</td><td>Проверить guard и связь ответа с запросом</td></tr><tr><td>Наблюдение</td><td><code>role=status</code>, имя «Сохранение выполняется»</td><td>Сеть работала без ошибок</td><td>Проверить доступный результат сценария</td></tr><tr><td>Ответ</td><td>Подтверждение с тем же идентификатором</td><td>Любой экран с текстом «Готово» корректен</td><td>Проверить переход в <code>saved</code></td></tr></tbody></table></div>\n<h2>Почему одной переменной loading недостаточно</h2>\n<p>Флаг <code>isLoading</code> отвечает только на вопрос о занятом состоянии. Он не различает первую и вторую попытку, не защищает от двойной отправки и не объясняет, что делать после ошибки. Для асинхронной формы нужен корреляционный признак. В учебном примере это <code>requestId</code>. Первый submit получает <code>request-01</code>. Ответ с <code>request-00</code> считается устаревшим и не меняет экран.</p>\n<p>Тот же guard закрывает повторный submit. Пока состояние равно <code>submitting</code>, второй клик не создаёт новый запрос. В реальном интерфейсе кнопка может стать disabled, но смысл правила должен жить в переходе состояния, а не только в DOM. Иначе другой обработчик или быстрый повторный клик обойдёт визуальную блокировку.</p>\n<p>Отрицательный путь важен не меньше happy path. Если сервер не подтвердил запрос, форма не должна показывать «Сохранено». Она должна сохранить черновик, показать понятное восстановление и дать действие, предусмотренное продуктом: повторить, проверить результат или вернуться к редактированию. Конкретная политика зависит от системы. Тест обязан проверить, что ложного успеха нет.</p>\n<h2>Учебный пример: переходы без браузера</h2>\n<p>Следующий код ограничен локальной моделью. Он не открывает страницу, не отправляет HTTP-запрос и не показывает результат реального продукта. Его задача — сделать инварианты видимыми до написания browser-теста: повторная отправка блокируется, старый ответ игнорируется, отсутствие подтверждения ведёт к восстановлению.</p>\n<pre><code>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}</code></pre>\n<p>Код намеренно не решает transport, retry и доступность. Он фиксирует границу переходов. После него browser-тест может проверять факты интерфейса: появился статус отправки, второй submit не изменил попытку, корректный ответ показал сохранение, а устаревший ответ не перекрыл новую форму.</p>\n<figure><img src=\"/assets/editorial/2022/ui-tests-2022-observation-route.svg\" alt=\"Схема переходов UI-теста: intent переходит в submitting, подтверждение с совпадающим request id ведёт в saved, устаревший ответ и повторная отправка блокируются, отсутствие подтверждения ведёт в recovery-required\" loading=\"lazy\" /><figcaption>Схема отделяет действие, переход, наблюдение и отрицательный путь. Это учебная модель, а не trace браузера и не результат production-прогона.</figcaption></figure>\n<h2>Как выглядит browser assertion</h2>\n<p>После того как страница получила понятный контракт, assertion выражает пользовательский факт. В Playwright пример может выглядеть так:</p>\n<pre><code>await page.getByRole('button', { name: 'Сохранить' }).click();\nawait expect(page.getByRole('status'))\n .toHaveText('Сохранение выполняется');\n\n// Контролируемый ответ тестового окружения приходит здесь.\nawait expect(page.getByRole('status'))\n .toHaveText('Изменение сохранено');</code></pre>\n<p>Вызов <code>toHaveText</code> ждёт условие до установленного timeout. Это полезно только тогда, когда условие связано с переходом, который важен пользователю. Проверка «кнопка исчезла» может пройти из-за закрытия модального окна, ошибки рендера или смены маршрута. Она не заменяет статус результата.</p>\n<p>Выбирайте locator по смыслу. Роль и имя подходят для состояния, которое должен распознать пользователь. <code>data-testid</code> уместен для технического узла, у которого нет пользовательской семантики. Ни один locator не исправит отсутствующий contract. Если экран не различает pending, success и recovery, автоматизация будет угадывать состояние по косвенным признакам.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Карта диагностики нестабильной UI-проверки</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>После click стоит <code>wait(1000)</code></td><td>Нет названного pending-состояния</td><td>Найти видимый результат до и после отправки</td><td>Добавить status и assertion на него</td></tr><tr><td>Тест ждёт исчезновения кнопки</td><td>DOM-признак подменяет бизнес-результат</td><td>Проверить, что будет при server error</td><td>Утвердить success и recovery отдельно</td></tr><tr><td>Два быстрых click создают два эффекта</td><td>Guard живёт только в UI</td><td>Повторить submit в состоянии <code>submitting</code></td><td>Запретить переход и проверить один request id</td></tr><tr><td>Поздний ответ показывает старый успех</td><td>Ответ не связан с попыткой</td><td>Передать устаревший <code>requestId</code></td><td>Игнорировать ответ или отправить его в согласованный recovery-путь</td></tr><tr><td>После ошибки нечего повторить</td><td>Форма очистила черновик до подтверждения</td><td>Проверить текст и snapshot после failure</td><td>Сохранить черновик и назвать действие восстановления</td></tr></tbody></table></div>\n<h2>Порядок действий</h2>\n<ol><li><strong>Запишите симптом.</strong> Сохраните текст падения, действие перед ним и текущий assertion. Не увеличивайте timeout до диагностики.</li><li><strong>Назовите состояния.</strong> Опишите pending, success и recovery словами, которые понимает пользователь.</li><li><strong>Назначьте владельца перехода.</strong> Укажите, какой обработчик создаёт request id, кто принимает ответ и кто меняет видимый статус.</li><li><strong>Проверьте отрицательный путь.</strong> Подайте пустой ввод, повторный submit, устаревший ответ и отсутствие подтверждения.</li><li><strong>Добавьте локальные проверки модели.</strong> Убедитесь, что guard, correlation и rollback не зависят от времени и DOM.</li><li><strong>Настройте контролируемый browser-вход.</strong> В тестовом окружении зафиксируйте разрешённый способ получить success и failure. Не выдавайте локальную модель за e2e.</li><li><strong>Поставьте assertion на observable state.</strong> Проверяйте role, имя и текст результата. Timeout оставьте ограничителем, а не условием успеха.</li><li><strong>Проверьте обратимость.</strong> Если новый contract расходится с UX, откатите его вместе с assertion. Не оставляйте паузу как постоянный обход.</li></ol>\n<h2>Ограничения</h2>\n<p>Модель не знает о re-render, планировщике фреймворка, локализации, авторизации, нескольких вкладках и фактической доставке ответа. <code>request-01</code> — учебное имя, а не требование к production-протоколу. В реальной системе поздний ответ может требовать журнала, повторного чтения данных или server reconciliation, а не молчаливого игнорирования.</p>\n<p>Роль <code>status</code> или <code>alert</code> нельзя добавлять только ради теста. Доступное сообщение должно соответствовать срочности и поведению интерфейса. Источник текста, локализация и фокус требуют отдельной проверки. UI-тест подтверждает выбранный contract, но не сертифицирует всю доступность страницы.</p>\n<p>Учебный пример не доказывает, что флак исчез, не измеряет длительность CI и не сообщает production-результаты. Для такого вывода нужны воспроизводимые запуски, версия браузера и runner, окружение, история падений и правило сравнения. Без этих данных корректно утверждать только то, что assertion теперь ждёт названное состояние.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если один сценарий проходит по четырём наблюдаемым веткам: pending появляется после действия, корректное подтверждение показывает success, повторная отправка не создаёт второй переход, а устаревший или отсутствующий ответ не показывает ложный успех. Для каждой ветки есть assertion на public UI contract. В коде нет произвольной паузы, которая заменяет отсутствующее состояние. Локальные проверки модели и browser-тест имеют явно описанные границы.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://playwright.dev/docs/test-assertions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright: Assertions</a> — официальная документация по auto-retrying assertions и условиям, которые проверяются до timeout.</li><li><a href=\"https://www.w3.org/TR/webdriver/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C WebDriver</a> — действующая спецификация интерфейса управления браузером; она не определяет смысл прикладного состояния страницы.</li><li><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html\" target=\"_blank\" rel=\"noopener noreferrer\">W3C WCAG 2.2: Status Messages</a> — официальное описание границы для сообщений о результате, которые не должны незаметно менять контекст пользователя.</li></ul>"
|
||
}
|