Перейти к содержанию

Разбор нестабильного сценария

Иногда сценарий проходит на одном компьютере и падает на другом, хотя пользовательское поведение сайта не изменилось. Частая причина — ожидание, которое связано с прошедшими миллисекундами, а не с состоянием приложения. Чем быстрее выполняется действие на привычной машине, тем легче не заметить такую скрытую зависимость.

В этом уроке разберём ожидание загрузки нашего каталога. Исходники lesson-23/start и expected находятся в архиве продолжения. В отдельном ANTIPATTERN.md сохранён фрагмент для чтения; он не добавлен как выполняемый тест. Ни повторные запуски, ни статистика нестабильности, ни trace сейчас не получались.

Пауза не является признаком результата

Рассмотрим ошибочный фрагмент:

await page.goto('/');
await page.waitForTimeout(100);
expect(await page.getByRole('list', { name: 'Курсы' })
  .getByRole('listitem').count()).toBe(4);

Здесь число сто не связано с завершением запроса /api/courses. При быстром ответе четыре элемента уже могут появиться. При медленном ответе список останется пустым. Увеличение паузы уменьшит вероятность одного сбоя, но добавит ожидание каждому запуску и не определит, сколько времени считать достаточным в другой среде.

count() возвращает текущее число в момент чтения. Обычный expect() над этим числом не ждёт появления элементов. В предыдущих уроках мы использовали expect(locator).toHaveCount(), чтобы ожидание относилось к наблюдаемому состоянию. Но сначала важно определить, какое состояние вообще ожидается и что мешает ему появиться.

Текущую проблему не следует автоматически называть измеренной нестабильностью. Мы видим потенциальную временную зависимость в исходниках. Чтобы установить частоту и условия сбоев, понадобились бы реальные повторные выполнения. Сейчас такого измерения нет; материал объясняет механизм и готовит управляемый пример.

Удержим ответ осмысленно

Вместо случайной задержки добавим gate — обещание, которое сценарий освобождает сам. Когда браузер обращается к каталогу, обработчик маршрута сообщает о начале запроса и ждёт разрешения. После освобождения он возвращает прежние четыре записи. Такой приём позволяет отдельно исследовать состояние загрузки и готового каталога.

let entered;
let release;
const requested = new Promise(resolve => { entered = resolve; });
const gate = new Promise(resolve => { release = resolve; });
await page.route('**/api/courses', async route => {
  entered();
  await gate;
  await route.fulfill({
    status: 200, contentType: 'application/json', json: { courses }
  });
});

Это локальная подмена браузерного запроса, а не изменение общего массива сервера. Каждый тест получает свой обработчик и свои promises. Параллельный сценарий не обязан ждать эту же gate, поэтому мы не создаём общий выключатель загрузки для двух workers.

Ожидание requested означает, что интересующий запрос достиг обработчика. Оно не обещает, что ответ уже доставлен. Эти события намеренно разделены: только после первого можно уверенно исследовать интерфейс, который ещё ждёт данные. Затем сам сценарий выбирает момент освобождения.

Наблюдения до и после ответа

Полный expected открывает страницу, ждёт начало запроса и проверяет два условия загрузки: статус «Загрузка каталога» и отсутствие карточек. После release() проверяется статус готовности и точный список названий.

try {
  await page.goto('/');
  await requested;
  await expect(page.getByTestId('results-status'))
    .toHaveText('Загрузка каталога');
  await expect(page.getByRole('list', { name: 'Курсы' })
    .getByRole('listitem')).toHaveCount(0);
  release();
  await expect(page.getByTestId('results-status'))
    .toHaveText('Найдено курсов: 4');
  await expect(page.getByRole('list', { name: 'Курсы' }).getByRole('link'))
    .toHaveText(courses.map(course => course.title));
} finally {
  release();
}

finally важен для неудачной проверки начального состояния. Если первый assert остановит выполнение, gate всё равно освобождается. Повторное разрешение того же promise не создаёт второй ответ. Такая структура уменьшает риск оставить ожидающий обработчик при завершении сценария.

Утверждения имеют ограничение времени из конфигурации. Это предел ожидания нужного состояния, а не обязательная пауза. Если состояние появляется быстро, сценарий продолжает работу. Если оно не появляется, отказ сообщает, какое условие не наблюдалось. Отдельный общий timeout не позволяет бесконечно ждать запрос, который приложение перестало отправлять.

Как отделять разные причины

Если список остаётся пустым после освобождения, нужно проверить, дошёл ли ответ, совпадает ли его схема и выполняется ли render. Если статус готовности появился, а названия неверны, проблема относится к данным или фильтрации. Если начальная загрузка никогда не видна даже при удержанном ответе, вероятно, изменилось управление состоянием страницы.

Для реального расследования полезны шаги trace и точный момент неудачи, разобранные ранее. Но нельзя написать «trace доказал причину», пока файла нет. Сейчас управляемая gate выражает условия, по которым будущий запуск поможет различить причины. Исходный фрагмент с паузой не давал такого различения: он только читал число после произвольного времени.

Общий изменяемый ресурс даёт другой класс проблем. Например, запись скачанного файла в одно имя для всех тестов могла бы смешать результаты даже при правильном ожидании браузера. В нашей лаборатории download сохранялся в testInfo.outputPath(), а каталог не менялся при предпросмотре. Эти решения сохраняются и не заменяются увеличением timeout.

Управляемый ответ проверяет переход интерфейса, а не скорость настоящей сети. Пока gate закрыта, задержка создаётся сценарием намеренно. Время между отправкой и освобождением нельзя использовать как показатель производительности приложения. Для измерения скорости понадобились бы настоящие запросы, отдельные условия нагрузки и другой способ сбора данных. Такая граница не уменьшает пользу примера: мы получаем точный контроль над причинной последовательностью.

Фикстура catalogPage здесь не используется, поскольку она ждёт уже готовые четыре курса и подставляет свой быстрый ответ. Новый тест берёт обычную page и устанавливает маршрут до открытия. Если оставить прежнюю фикстуру, начальное состояние загрузки уже было бы потеряно, а два обработчика одного адреса сделали бы объяснение труднее. Полный файл показывает это изменение явно.

При закрытии контекста незавершённый обработчик тоже может получить ошибку. Поэтому важно освободить gate в finally, а не предполагать, что cleanup всегда решит смысловую задачу. При этом finally не скрывает первоначальную ошибку assert и не объявляет тест успешным. Он только завершает подготовленный ресурс, оставляя наблюдение runner о неудаче для последующего разбора.

Повтор не скрывает договорённость

В конфигурации retries остаётся нулём. Это позволяет увидеть первоначальный отказ без автоматического повторения. В рабочем проекте повторные попытки могут быть частью стратегии наблюдения, но успешный retry не объясняет, почему первая попытка потеряла состояние. По документации Playwright о retries, такой результат отдельно классифицируется как flaky.

Не стоит добавлять повтор ради зелёного отчёта и забывать об исходной зависимости. Понадобятся условия воспроизведения, данные о первоначальном шаге, версия среды и объяснение исправления. Будущий повтор после устранения причины должен проверять договорённость, а не просто увеличивать число шансов попасть в удачное время.

Ожидаемый результат нашего исправленного исходника — видимая загрузка до ответа и четыре точных курса после него. Мы не измеряли длительность и не обещаем одинаковое время на всех машинах. Следующий урок свяжет такие ограниченные наблюдения с отчётом: он должен сохранять полезные сведения о задаче и отделять факт выполнения от гипотезы причины.