{ "index": 187, "slug": "editorial-2022-10-field-ui-tests", "title": "UI-тест после клика: ждать состояние, а не время", "excerpt": "Как разобрать flaky UI-тест: связать действие с наблюдаемым состоянием, отсеять устаревший ответ и проверить отказ без ложного успеха.", "contentHtml": "
UI-тест иногда падает после успешного клика: поле заполнилось, кнопка нажалась, а проверка не нашла сообщение «Сохранено». В коде обычно стоит wait(1000) или увеличенный timeout. На быстрой машине тест успевает увидеть результат. На занятой машине он ждёт слишком мало. После увеличения паузы тот же дефект просто проявляется позже. Цена ошибки — зелёный CI без доверия, медленная диагностика и риск выпустить сценарий, который теряет данные или принимает не тот ответ.
Причина часто лежит не в скорости. Тест не знает, какое состояние должно наступить после действия. Он ждёт время, исчезновение spinner или случайный текст. Надёжная проверка связывает четыре факта: действие пользователя, переход состояния, видимое подтверждение и ветку отказа. Таймаут ограничивает ожидание. Он не заменяет условие готовности.
\nЗапишите падение буквально. Какое действие выполнил тест? Что он проверял сразу после действия? Какой элемент искал? Был ли между ними sleep? Не начинайте с гипотезы «сервер медленный». Один timeout может скрывать несколько причин: запрос не отправился, submit сработал дважды, ответ относится к старой попытке, ошибка не попала в интерфейс или assertion ждёт внутренний флаг.
\nПолезная граница проходит между переходом и наблюдением. Переход меняет состояние приложения: форма переходит из editing в submitting. Наблюдение сообщает об этом пользователю: статус получает роль status и имя «Отправка ожидает подтверждения». Assertion проверяет наблюдение. Если тест знает только, что после клика прошла секунда, он не проверяет переход.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| sleep после submit | нет названного pending-состояния | после submit проверить submitting | добавить видимый status |
| успех иногда старый | ответ не связан с попыткой | сравнить requestId | игнорировать stale reply |
| два клика создают два запроса | нет guard на переходе | повторить submit в submitting | заблокировать второй переход |
| ошибка заканчивается timeout | нет recovery-состояния | передать отказ без подтверждения | показать alert и сохранить черновик |
| тест ждёт private flag | проверка не видит пользовательский результат | сопоставить flag и доступное имя | утверждать public contract |
Рассмотрим форму, которая отправляет текст. После submit приложение создаёт идентификатор попытки и переходит в submitting. Пока подтверждение не пришло, повторный submit запрещён. Ответ считается текущим только тогда, когда его requestId совпадает с идентификатором состояния. Успешный ответ переводит форму в saved. Отсутствующий или отрицательный ответ переводит её в recovery-required, а не в ложный успех.
Идентификатор нужен не для красоты. Пользователь может быстро повторить действие после ошибки. Первый ответ способен прийти после второй попытки. Без корреляции приложение примет старый ответ за новый и закроет форму с неверным результатом. Поэтому проверка должна включать отрицательный путь: stale reply не меняет состояние, а отказ оставляет данные для восстановления.
\nНиже — учебная модель переходов. Она не открывает страницу, не отправляет HTTP-запрос и не доказывает свойства production-кода. В ней оставлены только условия, которые нужно увидеть в настоящем компоненте и затем перенести в browser-тест.
\nconst 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;\nВ этой строке saved.phase станет saved только для текущего ответа. Если заменить идентификатор на request-00, состояние останется submitting. В настоящей модели добавьте отдельную ветку recovery-required для отказа и сохраните текст черновика. Значения request-01 и accepted — проектные значения учебного примера. Реальное приложение может использовать UUID, серверную версию или другой токен.
В browser-тесте проверяйте доступный результат, если он составляет часть интерфейсного контракта:
\nawait page.getByRole('button', { name: 'Сохранить' }).click(); await expect(page.getByRole('status')).toHaveText('Отправка ожидает подтверждения'); // Учебный пример: способ контроля ответа зависит от стенда. await expect(page.getByRole('status')).toHaveText('Изменение сохранено');\nPlaywright повторяет web assertion до выполнения условия или истечения timeout. Это помогает пережить нормальную асинхронность. Но инструмент не угадывает правильное условие. toBeVisible() на spinner может пройти, когда работа только началась. Assertion на произвольный текст «Успех» может поймать старое сообщение. В тесте должны совпасть действие, state и наблюдаемая семантика.
editing → submitting → saved или recovery-required.data-testid без владельца.Модель не описывает router, кеш, несколько вкладок, повторную отправку, авторизацию, локализацию, сетевые ретраи и серверную идемпотентность. Она не говорит, что любой stale reply нужно молча отбросить. В некоторых системах нужен повторный read, журнал конфликта или reconciliation с сервером. Эти решения относятся к доменному протоколу, а не к одному UI-тесту.
\nРоль status и alert в примере — требование к наблюдаемому контракту. Она не заменяет полноценную проверку доступности. Тест может найти доступное имя и всё равно пропустить плохой фокус, неверный порядок чтения или недоступную ошибку. Для этого нужны отдельные проверки и ручная оценка интерфейса.
Учебный код не измеряет flake rate, длительность CI или влияние на production. После изменения такие утверждения требуют отдельного отчёта: версия раннера и браузера, окружение, число запусков, результаты и известные исключения. Здесь проверяется только логика переходов и выбранный пользовательский сигнал.
\nИзменение готово, если любой reviewer может пройти сценарий по состояниям и ответить на четыре вопроса. Какое действие запускает переход? Какой видимый факт подтверждает pending и success? Что происходит при отказе? Почему старый ответ не может подтвердить новую попытку? В коде должны существовать проверки этих веток, а browser-тест должен ждать состояние, а не прошедшее время.
\nФормулируйте результат узко: «тест ждёт status с именем “Изменение сохранено” после ответа текущей попытки и проверяет recovery при отказе». Не пишите «флак устранён», если нет повторяемого измерения. Такая формулировка связывает изменение с наблюдаемым условием и оставляет место для следующего дефекта.
\n