{ "index": 187, "slug": "editorial-2022-10-field-ui-tests", "title": "UI-тест после клика: ждать состояние, а не время", "excerpt": "Как разобрать flaky UI-тест: связать действие с наблюдаемым состоянием, отсеять устаревший ответ и проверить отказ без ложного успеха.", "contentHtml": "
На прогоне формы тест заполнял поле и нажимал «Сохранить». Иногда следующий шаг не находил сообщение «Изменение сохранено», хотя клик уже состоялся. В тесте стояла фиксированная пауза: на быстрой машине её хватало, на занятом CI — нет. Увеличение паузы лишь снижало частоту падения и делало прогон дольше. Цена ошибки — недоверие к результату сборки и риск принять старый или неполный ответ за результат текущего сохранения.
\nПервое предположение обычно звучит как «сервер медленный». Но тест в этот момент не знает, какого события ждёт. Он может проверять истёкшую секунду, исчезнувший spinner или текст, оставшийся от предыдущей попытки. Надёжный сценарий связывает действие, переход состояния, пользовательское подтверждение и отказ. Таймаут ограничивает поиск результата, но не определяет сам результат.
\nНачните с одного конкретного падения. Зафиксируйте маршрут, исходные данные, действие, locator и последний видимый статус. Если в trace видно только клик, это ещё не объясняет причину. Запрос мог не уйти, ответ мог прийти от первой попытки, обработчик мог дважды отправить форму, а ошибка могла остаться только в консоли.
\nРазделите проверку на два наблюдения. Клик проверяет готовность элемента к действию. После клика нужен отдельный контракт результата: например, область с ролью status меняет текст на «Отправка...», а затем на «Изменение сохранено». Внутреннее поле phase помогает приложению управлять переходом, но не является хорошим единственным объектом для e2e-проверки: пользователь его не видит.
| Симптом | Гипотеза | Минимальная проверка | Решение |
|---|---|---|---|
| После submit стоит sleep | Не названо состояние ожидания | Посмотреть DOM и trace между кликом и ответом | Добавить pending-сигнал и ждать его |
| Старый успех проходит иногда | Ответ не связан с попыткой | Сравнить идентификатор ответа с текущим | Игнорировать stale reply |
| Два клика дают два запроса | Повторный submit не блокируется | Задержать ответ и повторить клик | Запретить переход из submitting |
| Отказ заканчивается timeout | Нет видимого recovery | Вернуть из стенда ошибку 500 | Показать ошибку и оставить черновик |
| Тест читает private flag | Проверяется деталь реализации | Сопоставить результат с UI-контрактом | Утверждать пользовательское поведение |
Для одной попытки достаточно явно разделить четыре состояния: editing, submitting, saved и recovery-required. При submit приложение сохраняет черновик, назначает requestId и переходит в submitting. Пока этот переход не завершён, второй submit не создаёт новую попытку. Ответ разрешено применить только при совпадении его идентификатора с текущим.
Такой идентификатор — проектное решение, а не требование Playwright или WebDriver. Он нужен там, где ответы могут приходить не по порядку. Если первая попытка завершилась после второй, обработчик без корреляции может показать «Сохранено» для уже неактуального текста. При несовпадении идентификатора состояние не меняется. При отказе оно переходит в recovery, а черновик остаётся доступным для исправления или повторной отправки.
\nСначала проверьте переходы отдельно от браузера. В примере ниже обработчик не зависит от времени: он принимает только ответ для текущего состояния. Тексты сообщений и значения requestId — проектные, поэтому в настоящем приложении их нужно заменить на свои.
type Phase = 'editing' | 'submitting' | 'saved' | 'recovery-required';\n\ntype State = {\n phase: Phase;\n draft: string;\n requestId?: string;\n message: string;\n};\n\ntype Reply = {\n requestId: string;\n outcome: 'accepted' | 'rejected';\n};\n\nfunction applyReply(state: State, reply: Reply): State {\n if (state.phase !== 'submitting' || state.requestId !== reply.requestId) {\n return state;\n }\n\n return reply.outcome === 'accepted'\n ? { ...state, phase: 'saved', message: 'Изменение сохранено' }\n : {\n ...state,\n phase: 'recovery-required',\n message: 'Не удалось сохранить. Черновик остался в форме',\n };\n}\nСостояние с requestId: 'request-02' должно проигнорировать ответ с requestId: 'request-01': фаза останется submitting, а текст черновика не изменится. Ответ accepted для request-02 переводит состояние в saved. Ответ rejected для той же попытки переводит его в recovery-required. Это и есть воспроизводимая проверка гонки, а не надежда на случайный порядок сетевых событий.
Когда переходы определены, зафиксируйте их через видимый контракт. Playwright ждёт готовность элемента перед действием, а web-first assertion повторяет проверку до совпадения или истечения timeout. Поэтому задержку стоит вводить в контролируемый ответ стенда, чтобы проверить pending и success в известном порядке.
\nimport { test, expect } from '@playwright/test';\n\ntest('waits for the current save result', async ({ page }) => {\n let releaseResponse = () => {};\n const responseReleased = new Promise((resolve) => {\n releaseResponse = resolve;\n });\n\n await page.route('**/api/profile', async (route) => {\n await responseReleased;\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ ok: true }),\n });\n });\n\n await page.goto('/profile');\n await page.getByLabel('Описание').fill('черновик');\n await page.getByRole('button', { name: 'Сохранить' }).click();\n await expect(page.getByRole('status')).toHaveText('Отправка...');\n releaseResponse();\n await expect(page.getByRole('status')).toHaveText('Изменение сохранено');\n});\nМаршрут **/api/profile, URL, подпись поля и тексты в этом фрагменте принадлежат учебной форме. Их нужно заменить на контракт своего стенда. Смысл примера в другом: тест сначала задерживает ответ, убеждается в pending, затем разрешает ответ и ждёт success. В нём нет waitForTimeout, а timeout assertion остаётся страховкой для настоящего зависания.
Положительный путь не доказывает устойчивость. Отдельным тестом верните из контролируемого endpoint отказ и проверьте две вещи: ошибка доступна пользователю, а введённый текст не потерян. Для сообщения, которое не требует немедленного прерывания работы, подходит status. Существенную и срочную ошибку можно объявить через alert, но роль не должна подменять проектирование фокуса и способ восстановления.
await page.route('**/api/profile', (route) => route.fulfill({\n status: 500,\n contentType: 'application/json',\n body: JSON.stringify({ ok: false }),\n}));\n\nawait page.getByRole('button', { name: 'Сохранить' }).click();\nawait expect(page.getByRole('alert')).toHaveText('Не удалось сохранить');\nawait expect(page.getByLabel('Описание')).toHaveValue('черновик');\nДля stale reply нужен контролируемый порядок двух ответов или unit-тест функции перехода из предыдущего раздела. Не подменяйте эту проверку повторным запуском: десять удачных прогонов не создают гонку, если стенд ни разу не поменял порядок ответов. В browser-тесте также проверьте, что второй клик во время submitting не создаёт второй запрос; это отдельное утверждение, а не побочный эффект ожидания.
editing → submitting → saved или recovery-required.Эта схема не решает серверную идемпотентность, конфликт нескольких вкладок, авторизацию, кеш, ретраи транспорта и reconciliation. Если сервер принял запрос, а клиент потерял ответ, одного recovery-required недостаточно: может потребоваться повторное чтение или журнал операции. Решение о повторе зависит от доменного протокола.
Роль status имеет смысл только при корректно выбранном сообщении. WAI-ARIA 1.1 описывает её как live region с advisory-информацией и ненавязчивым уведомлением; alert предназначен для важной, обычно срочной информации. Автотест с getByRole проверяет наличие заявленного интерфейсного сигнала, но не заменяет ручную проверку фокуса, клавиатуры, порядка чтения и локализации.
Изменение готово, если можно воспроизвести задержанный success, отказ, повторный submit и stale reply, а для каждой ветки назвать видимый результат. Формулируйте итог узко: «тест ждёт status текущей попытки и оставляет черновик при отказе». Фраза «flaky-тест исправлен» требует отдельного измерения: версия браузера и runner, окружение, число повторов, доля падений до и после.
\nstatus и alert и их live-region semantics; это источник для выбора доступного сигнала, но не доказательство корректности конкретного интерфейса.