8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 187,
|
||
"slug": "editorial-2022-10-field-ui-tests",
|
||
"title": "UI-тест после клика: ждать состояние, а не время",
|
||
"excerpt": "Как разобрать flaky UI-тест: связать действие с наблюдаемым состоянием, отсеять устаревший ответ и проверить отказ без ложного успеха.",
|
||
"contentHtml": "<p>UI-тест иногда падает после успешного клика: поле заполнилось, кнопка нажалась, а проверка не нашла сообщение «Сохранено». В коде обычно стоит <code>wait(1000)</code> или увеличенный timeout. На быстрой машине тест успевает увидеть результат. На занятой машине он ждёт слишком мало. После увеличения паузы тот же дефект просто проявляется позже. Цена ошибки — зелёный CI без доверия, медленная диагностика и риск выпустить сценарий, который теряет данные или принимает не тот ответ.</p>\n<p>Причина часто лежит не в скорости. Тест не знает, какое состояние должно наступить после действия. Он ждёт время, исчезновение spinner или случайный текст. Надёжная проверка связывает четыре факта: действие пользователя, переход состояния, видимое подтверждение и ветку отказа. Таймаут ограничивает ожидание. Он не заменяет условие готовности.</p>\n<h2>Сначала опишите наблюдаемый симптом</h2>\n<p>Запишите падение буквально. Какое действие выполнил тест? Что он проверял сразу после действия? Какой элемент искал? Был ли между ними sleep? Не начинайте с гипотезы «сервер медленный». Один timeout может скрывать несколько причин: запрос не отправился, submit сработал дважды, ответ относится к старой попытке, ошибка не попала в интерфейс или assertion ждёт внутренний флаг.</p>\n<p>Полезная граница проходит между переходом и наблюдением. Переход меняет состояние приложения: форма переходит из <code>editing</code> в <code>submitting</code>. Наблюдение сообщает об этом пользователю: статус получает роль <code>status</code> и имя «Отправка ожидает подтверждения». 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>sleep после submit</td><td>нет названного pending-состояния</td><td>после submit проверить <code>submitting</code></td><td>добавить видимый status</td></tr><tr><td>успех иногда старый</td><td>ответ не связан с попыткой</td><td>сравнить <code>requestId</code></td><td>игнорировать stale reply</td></tr><tr><td>два клика создают два запроса</td><td>нет guard на переходе</td><td>повторить submit в <code>submitting</code></td><td>заблокировать второй переход</td></tr><tr><td>ошибка заканчивается timeout</td><td>нет recovery-состояния</td><td>передать отказ без подтверждения</td><td>показать alert и сохранить черновик</td></tr><tr><td>тест ждёт private flag</td><td>проверка не видит пользовательский результат</td><td>сопоставить flag и доступное имя</td><td>утверждать public contract</td></tr></tbody></table></div>\n<h2>Механизм: состояние владеет результатом</h2>\n<p>Рассмотрим форму, которая отправляет текст. После submit приложение создаёт идентификатор попытки и переходит в <code>submitting</code>. Пока подтверждение не пришло, повторный submit запрещён. Ответ считается текущим только тогда, когда его <code>requestId</code> совпадает с идентификатором состояния. Успешный ответ переводит форму в <code>saved</code>. Отсутствующий или отрицательный ответ переводит её в <code>recovery-required</code>, а не в ложный успех.</p>\n<p>Идентификатор нужен не для красоты. Пользователь может быстро повторить действие после ошибки. Первый ответ способен прийти после второй попытки. Без корреляции приложение примет старый ответ за новый и закроет форму с неверным результатом. Поэтому проверка должна включать отрицательный путь: stale reply не меняет состояние, а отказ оставляет данные для восстановления.</p>\n<figure><img src=\"/assets/editorial/2022/ui-tests-2022-diagnosis-rollback.svg\" alt=\"Схема UI-проверки: submit переводит форму в submitting, совпавший requestId ведёт в saved, устаревший ответ игнорируется, отсутствие подтверждения ведёт в recovery-required с сохранением черновика.\" loading=\"lazy\" /><figcaption>Состояние формы и ответ сервера разделены идентификатором попытки. Иллюстрация показывает учебную модель, а не trace реального браузера.</figcaption></figure>\n<h2>Конкретный пример</h2>\n<p>Ниже — учебная модель переходов. Она не открывает страницу, не отправляет HTTP-запрос и не доказывает свойства production-кода. В ней оставлены только условия, которые нужно увидеть в настоящем компоненте и затем перенести в browser-тест.</p>\n<pre><code>const pending = { phase: 'submitting', requestId: 'request-01' }; const reply = { requestId: 'request-01', outcome: 'accepted' }; const saved = reply.requestId === pending.requestId && reply.outcome === 'accepted' ? { ...pending, phase: 'saved' } : pending;</code></pre>\n<p>В этой строке <code>saved.phase</code> станет <code>saved</code> только для текущего ответа. Если заменить идентификатор на <code>request-00</code>, состояние останется <code>submitting</code>. В настоящей модели добавьте отдельную ветку <code>recovery-required</code> для отказа и сохраните текст черновика. Значения <code>request-01</code> и <code>accepted</code> — проектные значения учебного примера. Реальное приложение может использовать UUID, серверную версию или другой токен.</p>\n<p>В browser-тесте проверяйте доступный результат, если он составляет часть интерфейсного контракта:</p>\n<pre><code>await page.getByRole('button', { name: 'Сохранить' }).click(); await expect(page.getByRole('status')).toHaveText('Отправка ожидает подтверждения'); // Учебный пример: способ контроля ответа зависит от стенда. await expect(page.getByRole('status')).toHaveText('Изменение сохранено');</code></pre>\n<p>Playwright повторяет web assertion до выполнения условия или истечения timeout. Это помогает пережить нормальную асинхронность. Но инструмент не угадывает правильное условие. <code>toBeVisible()</code> на spinner может пройти, когда работа только началась. Assertion на произвольный текст «Успех» может поймать старое сообщение. В тесте должны совпасть действие, state и наблюдаемая семантика.</p>\n<h2>Порядок исправления</h2>\n<ol><li>Зафиксируйте исходное падение: действие, assertion, locator, timeout и последний видимый статус.</li><li>Нарисуйте короткую шкалу без времени: <code>editing</code> → <code>submitting</code> → <code>saved</code> или <code>recovery-required</code>.</li><li>Назовите публичные наблюдения для pending, успеха и отказа. Выберите роль и имя, которые нужны пользователю, а не только тесту.</li><li>Добавьте проверку переходов рядом с владельцем состояния. Покройте повторный submit, stale reply и возврат к редактированию после отказа.</li><li>Выберите разрешённый способ управлять ответом в тестовом окружении. Учебный пример не означает, что такой контроль подходит каждому стенду.</li><li>Замените sleep на assertion, которое ждёт конкретный condition. Укажите специальный timeout только после того, как условие стало правильным.</li><li>Проверьте отрицательную ветку. Ошибка должна быть видна, черновик — сохранён, а старый ответ — не менять новую попытку.</li><li>Удаляйте новый state и assertion вместе, если согласованный UX не принимает переход. Не оставляйте locator или <code>data-testid</code> без владельца.</li></ol>\n<h2>Ограничения</h2>\n<p>Модель не описывает router, кеш, несколько вкладок, повторную отправку, авторизацию, локализацию, сетевые ретраи и серверную идемпотентность. Она не говорит, что любой stale reply нужно молча отбросить. В некоторых системах нужен повторный read, журнал конфликта или reconciliation с сервером. Эти решения относятся к доменному протоколу, а не к одному UI-тесту.</p>\n<p>Роль <code>status</code> и <code>alert</code> в примере — требование к наблюдаемому контракту. Она не заменяет полноценную проверку доступности. Тест может найти доступное имя и всё равно пропустить плохой фокус, неверный порядок чтения или недоступную ошибку. Для этого нужны отдельные проверки и ручная оценка интерфейса.</p>\n<p>Учебный код не измеряет flake rate, длительность CI или влияние на production. После изменения такие утверждения требуют отдельного отчёта: версия раннера и браузера, окружение, число запусков, результаты и известные исключения. Здесь проверяется только логика переходов и выбранный пользовательский сигнал.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение готово, если любой reviewer может пройти сценарий по состояниям и ответить на четыре вопроса. Какое действие запускает переход? Какой видимый факт подтверждает pending и success? Что происходит при отказе? Почему старый ответ не может подтвердить новую попытку? В коде должны существовать проверки этих веток, а browser-тест должен ждать состояние, а не прошедшее время.</p>\n<p>Формулируйте результат узко: «тест ждёт status с именем “Изменение сохранено” после ответа текущей попытки и проверяет recovery при отказе». Не пишите «флак устранён», если нет повторяемого измерения. Такая формулировка связывает изменение с наблюдаемым условием и оставляет место для следующего дефекта.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://playwright.dev/docs/test-assertions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright: Assertions</a> — официальная документация описывает web assertions, которые повторяют проверку до выполнения условия или истечения timeout.</li><li><a href=\"https://www.w3.org/TR/webdriver2/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C WebDriver</a> — официальный стандарт описывает протокол удалённого управления браузером; он не определяет смысл прикладного состояния «сохранено».</li><li><a href=\"https://www.w3.org/TR/WCAG22/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Web Content Accessibility Guidelines 2.2</a> — нормативные требования задают границу для доступности интерфейса; наличие роли в тесте само по себе не доказывает соответствие.</li></ul>"
|
||
}
|