diff --git a/editorial/agent-rewrites/187.json b/editorial/agent-rewrites/187.json index 74be7c0..868210f 100644 --- a/editorial/agent-rewrites/187.json +++ b/editorial/agent-rewrites/187.json @@ -3,5 +3,5 @@ "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

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

" + "contentHtml": "

На прогоне формы тест заполнял поле и нажимал «Сохранить». Иногда следующий шаг не находил сообщение «Изменение сохранено», хотя клик уже состоялся. В тесте стояла фиксированная пауза: на быстрой машине её хватало, на занятом CI — нет. Увеличение паузы лишь снижало частоту падения и делало прогон дольше. Цена ошибки — недоверие к результату сборки и риск принять старый или неполный ответ за результат текущего сохранения.

\n

Первое предположение обычно звучит как «сервер медленный». Но тест в этот момент не знает, какого события ждёт. Он может проверять истёкшую секунду, исчезнувший spinner или текст, оставшийся от предыдущей попытки. Надёжный сценарий связывает действие, переход состояния, пользовательское подтверждение и отказ. Таймаут ограничивает поиск результата, но не определяет сам результат.

\n

Сцена: что именно упало

\n

Начните с одного конкретного падения. Зафиксируйте маршрут, исходные данные, действие, locator и последний видимый статус. Если в trace видно только клик, это ещё не объясняет причину. Запрос мог не уйти, ответ мог прийти от первой попытки, обработчик мог дважды отправить форму, а ошибка могла остаться только в консоли.

\n

Разделите проверку на два наблюдения. Клик проверяет готовность элемента к действию. После клика нужен отдельный контракт результата: например, область с ролью status меняет текст на «Отправка...», а затем на «Изменение сохранено». Внутреннее поле phase помогает приложению управлять переходом, но не является хорошим единственным объектом для e2e-проверки: пользователь его не видит.

\n
От симптома к проверяемой гипотезе
СимптомГипотезаМинимальная проверкаРешение
После submit стоит sleepНе названо состояние ожиданияПосмотреть DOM и trace между кликом и ответомДобавить pending-сигнал и ждать его
Старый успех проходит иногдаОтвет не связан с попыткойСравнить идентификатор ответа с текущимИгнорировать stale reply
Два клика дают два запросаПовторный submit не блокируетсяЗадержать ответ и повторить кликЗапретить переход из submitting
Отказ заканчивается timeoutНет видимого recoveryВернуть из стенда ошибку 500Показать ошибку и оставить черновик
Тест читает private flagПроверяется деталь реализацииСопоставить результат с UI-контрактомУтверждать пользовательское поведение
\n

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

\n

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

\n

Такой идентификатор — проектное решение, а не требование Playwright или WebDriver. Он нужен там, где ответы могут приходить не по порядку. Если первая попытка завершилась после второй, обработчик без корреляции может показать «Сохранено» для уже неактуального текста. При несовпадении идентификатора состояние не меняется. При отказе оно переходит в recovery, а черновик остаётся доступным для исправления или повторной отправки.

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

Маленькая модель для воспроизведения

\n

Сначала проверьте переходы отдельно от браузера. В примере ниже обработчик не зависит от времени: он принимает только ответ для текущего состояния. Тексты сообщений и значения requestId — проектные, поэтому в настоящем приложении их нужно заменить на свои.

\n
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. Это и есть воспроизводимая проверка гонки, а не надежда на случайный порядок сетевых событий.

\n

Browser-тест: задержать ответ, а не тест

\n

Когда переходы определены, зафиксируйте их через видимый контракт. Playwright ждёт готовность элемента перед действием, а web-first assertion повторяет проверку до совпадения или истечения timeout. Поэтому задержку стоит вводить в контролируемый ответ стенда, чтобы проверить pending и success в известном порядке.

\n
import { 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 остаётся страховкой для настоящего зависания.

\n

Отказ и устаревший ответ

\n

Положительный путь не доказывает устойчивость. Отдельным тестом верните из контролируемого endpoint отказ и проверьте две вещи: ошибка доступна пользователю, а введённый текст не потерян. Для сообщения, которое не требует немедленного прерывания работы, подходит status. Существенную и срочную ошибку можно объявить через alert, но роль не должна подменять проектирование фокуса и способ восстановления.

\n
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 не создаёт второй запрос; это отдельное утверждение, а не побочный эффект ожидания.

\n

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

\n
  1. Запишите исходное падение: маршрут, данные, действие, locator, assertion, timeout и последний видимый статус.
  2. Включите trace на первом retry и проверьте DOM-снимки, сеть и последовательность действий.
  3. Нарисуйте переходы editing → submitting → saved или recovery-required.
  4. Назначьте владельца каждого перехода: компонент формы меняет state, транспорт возвращает outcome, интерфейс показывает status или alert.
  5. Свяжите ответ с текущей попыткой. При несовпадении идентификатора не закрывайте форму и не показывайте успех.
  6. Замените фиксированную паузу на web-first assertion, которое ждёт конкретный текст, атрибут или доступное состояние.
  7. Заморозьте ответы стенда для success, отказа и перестановки ответов. Проверьте повторный submit и сохранение черновика.
  8. После изменения сравните результат на чистых данных и в том же браузере, где возникло падение.
\n

Ограничения и критерий готовности

\n

Эта схема не решает серверную идемпотентность, конфликт нескольких вкладок, авторизацию, кеш, ретраи транспорта и reconciliation. Если сервер принял запрос, а клиент потерял ответ, одного recovery-required недостаточно: может потребоваться повторное чтение или журнал операции. Решение о повторе зависит от доменного протокола.

\n

Роль status имеет смысл только при корректно выбранном сообщении. WAI-ARIA 1.1 описывает её как live region с advisory-информацией и ненавязчивым уведомлением; alert предназначен для важной, обычно срочной информации. Автотест с getByRole проверяет наличие заявленного интерфейсного сигнала, но не заменяет ручную проверку фокуса, клавиатуры, порядка чтения и локализации.

\n

Изменение готово, если можно воспроизвести задержанный success, отказ, повторный submit и stale reply, а для каждой ветки назвать видимый результат. Формулируйте итог узко: «тест ждёт status текущей попытки и оставляет черновик при отказе». Фраза «flaky-тест исправлен» требует отдельного измерения: версия браузера и runner, окружение, число повторов, доля падений до и после.

\n

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

" }