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

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

\n

Причина часто лежит не в скорости. Тест не знает, какое состояние должно наступить после действия. Он ждёт время, исчезновение spinner или случайный текст. Надёжная проверка связывает четыре факта: действие пользователя, переход состояния, видимое подтверждение и ветку отказа. Таймаут ограничивает ожидание. Он не заменяет условие готовности.

\n

Сначала опишите наблюдаемый симптом

\n

Запишите падение буквально. Какое действие выполнил тест? Что он проверял сразу после действия? Какой элемент искал? Был ли между ними sleep? Не начинайте с гипотезы «сервер медленный». Один timeout может скрывать несколько причин: запрос не отправился, submit сработал дважды, ответ относится к старой попытке, ошибка не попала в интерфейс или assertion ждёт внутренний флаг.

\n

Полезная граница проходит между переходом и наблюдением. Переход меняет состояние приложения: форма переходит из editing в submitting. Наблюдение сообщает об этом пользователю: статус получает роль status и имя «Отправка ожидает подтверждения». Assertion проверяет наблюдение. Если тест знает только, что после клика прошла секунда, он не проверяет переход.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
sleep после submitнет названного pending-состоянияпосле submit проверить submittingдобавить видимый status
успех иногда старыйответ не связан с попыткойсравнить requestIdигнорировать stale reply
два клика создают два запросанет guard на переходеповторить submit в submittingзаблокировать второй переход
ошибка заканчивается timeoutнет recovery-состоянияпередать отказ без подтвержденияпоказать alert и сохранить черновик
тест ждёт private flagпроверка не видит пользовательский результатсопоставить flag и доступное имяутверждать public contract
\n

Механизм: состояние владеет результатом

\n

Рассмотрим форму, которая отправляет текст. После submit приложение создаёт идентификатор попытки и переходит в submitting. Пока подтверждение не пришло, повторный submit запрещён. Ответ считается текущим только тогда, когда его requestId совпадает с идентификатором состояния. Успешный ответ переводит форму в saved. Отсутствующий или отрицательный ответ переводит её в recovery-required, а не в ложный успех.

\n

Идентификатор нужен не для красоты. Пользователь может быстро повторить действие после ошибки. Первый ответ способен прийти после второй попытки. Без корреляции приложение примет старый ответ за новый и закроет форму с неверным результатом. Поэтому проверка должна включать отрицательный путь: stale reply не меняет состояние, а отказ оставляет данные для восстановления.

\n
\"Схема
Состояние формы и ответ сервера разделены идентификатором попытки. Иллюстрация показывает учебную модель, а не trace реального браузера.
\n

Конкретный пример

\n

Ниже — учебная модель переходов. Она не открывает страницу, не отправляет HTTP-запрос и не доказывает свойства production-кода. В ней оставлены только условия, которые нужно увидеть в настоящем компоненте и затем перенести в browser-тест.

\n
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;
\n

В этой строке saved.phase станет saved только для текущего ответа. Если заменить идентификатор на request-00, состояние останется submitting. В настоящей модели добавьте отдельную ветку recovery-required для отказа и сохраните текст черновика. Значения request-01 и accepted — проектные значения учебного примера. Реальное приложение может использовать UUID, серверную версию или другой токен.

\n

В browser-тесте проверяйте доступный результат, если он составляет часть интерфейсного контракта:

\n
await page.getByRole('button', { name: 'Сохранить' }).click(); await expect(page.getByRole('status')).toHaveText('Отправка ожидает подтверждения'); // Учебный пример: способ контроля ответа зависит от стенда. await expect(page.getByRole('status')).toHaveText('Изменение сохранено');
\n

Playwright повторяет web assertion до выполнения условия или истечения timeout. Это помогает пережить нормальную асинхронность. Но инструмент не угадывает правильное условие. toBeVisible() на spinner может пройти, когда работа только началась. Assertion на произвольный текст «Успех» может поймать старое сообщение. В тесте должны совпасть действие, state и наблюдаемая семантика.

\n

Порядок исправления

\n
  1. Зафиксируйте исходное падение: действие, assertion, locator, timeout и последний видимый статус.
  2. Нарисуйте короткую шкалу без времени: editing → submitting → saved или recovery-required.
  3. Назовите публичные наблюдения для pending, успеха и отказа. Выберите роль и имя, которые нужны пользователю, а не только тесту.
  4. Добавьте проверку переходов рядом с владельцем состояния. Покройте повторный submit, stale reply и возврат к редактированию после отказа.
  5. Выберите разрешённый способ управлять ответом в тестовом окружении. Учебный пример не означает, что такой контроль подходит каждому стенду.
  6. Замените sleep на assertion, которое ждёт конкретный condition. Укажите специальный timeout только после того, как условие стало правильным.
  7. Проверьте отрицательную ветку. Ошибка должна быть видна, черновик — сохранён, а старый ответ — не менять новую попытку.
  8. Удаляйте новый state и assertion вместе, если согласованный UX не принимает переход. Не оставляйте locator или data-testid без владельца.
\n

Ограничения

\n

Модель не описывает router, кеш, несколько вкладок, повторную отправку, авторизацию, локализацию, сетевые ретраи и серверную идемпотентность. Она не говорит, что любой stale reply нужно молча отбросить. В некоторых системах нужен повторный read, журнал конфликта или reconciliation с сервером. Эти решения относятся к доменному протоколу, а не к одному UI-тесту.

\n

Роль status и alert в примере — требование к наблюдаемому контракту. Она не заменяет полноценную проверку доступности. Тест может найти доступное имя и всё равно пропустить плохой фокус, неверный порядок чтения или недоступную ошибку. Для этого нужны отдельные проверки и ручная оценка интерфейса.

\n

Учебный код не измеряет flake rate, длительность CI или влияние на production. После изменения такие утверждения требуют отдельного отчёта: версия раннера и браузера, окружение, число запусков, результаты и известные исключения. Здесь проверяется только логика переходов и выбранный пользовательский сигнал.

\n

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

\n

Изменение готово, если любой reviewer может пройти сценарий по состояниям и ответить на четыре вопроса. Какое действие запускает переход? Какой видимый факт подтверждает pending и success? Что происходит при отказе? Почему старый ответ не может подтвердить новую попытку? В коде должны существовать проверки этих веток, а browser-тест должен ждать состояние, а не прошедшее время.

\n

Формулируйте результат узко: «тест ждёт status с именем “Изменение сохранено” после ответа текущей попытки и проверяет recovery при отказе». Не пишите «флак устранён», если нет повторяемого измерения. Такая формулировка связывает изменение с наблюдаемым условием и оставляет место для следующего дефекта.

\n

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

" }