javascript

page.waitForTimeout — это всего лишь warning. Почему зелёный линт не спасает Playwright-тесты

  • среда, 22 июля 2026 г. в 00:00:17
https://habr.com/ru/articles/1058692/

Вот тест, который проходит CI в большинстве проектов, что я видел:

test('форма отправляется', async ({ page }) => {
  await page.goto('/feedback');
  await page.getByLabel('Email').fill('a@b.c');
  await page.locator('#submit').click({ force: true });
  await page.waitForTimeout(3000);
});

Три греха в пяти строках: клик в обход проверок видимости, слепая пауза вместо ожидания состояния и ни одной проверки результата — тест «проходит», даже если форма мертва. При этом подключите eslint-plugin-playwright с рекомендованным конфигом, и линт останется зелёным. Все три нарушения в recommended-наборе — это warning, а warning не валит сборку.

Это вторая статья про paranoid-qa — опенсорсный пак скиллов, который заставляет Claude Code подтверждать каждый вердикт артефактом (первая — о том, как я запретил агенту говорить «всё работает» без пруфов: https://habr.com/ru/articles/1058134/). Сегодня — про ревью автотестов: три слоя, по которым дефект проходит мимо линта. Покажу конфиг, который закрывает первые два, и десять правил для третьего.

Слой первый: плагина может просто не быть

Два самых опасных класса дефектов в Playwright-тестах — висящие промисы и ручные ассерты — без специальных правил невидимы для ESLint.

expect(page.getByText('Готово')).toBeVisible();        // нет await - тест не ждёт
expect(await loc.isVisible()).toBe(true);              // мгновенный замер, не ретраится

Первое ловит playwright/missing-playwright-await, второе — playwright/prefer-web-first-assertions. Оба правила — из eslint-plugin-playwright, который ставится отдельно и подключён далеко не в каждом проекте. Универсальный @typescript-eslint/no-floating-promises тоже поймал бы первый случай, но он требует type-aware линтинга (parserOptions.project) — его включают ещё реже, потому что линт становится заметно медленнее.

Пока плагина нет, floating promise будет тихо флакать месяцами.

Слой второй: recommended наполовину состоит из warn

Я пошёл в исходники плагина и выписал severity каждого правила из recommended-конфига — файл src/plugin.ts, объект sharedConfig; актуально для v2.10.5, июль 2026. Вот что там:

Severity

Правила

error

missing-playwright-await, prefer-web-first-assertions, no-networkidle, no-focused-test, valid-expect, no-standalone-expect, no-wait-for-navigation и ещё несколько

warn

no-wait-for-timeout, no-force-option, expect-expect (тест без ассертов!), no-skipped-test, no-conditional-in-test, no-element-handle, no-eval, no-page-pause и другие

Во второй строке — слепая пауза, клик с force и тест, который ничего не проверяет: всё warn. Логика мейнтейнеров понятна: у этих правил бывают легитимные исключения, ломать сборку из-за них жестоко. При этом для исключений есть штатный выход — точечный // eslint-disable-next-line playwright/no-force-option -- кастомный инпут, без force не кликнуть: нарушение остаётся видимым и обоснованным, а правило — строгим для всех остальных мест. Но warning все скроллят: если в CI не стоит --max-warnings 0, предупреждения не остановят ни один мерж.

Что с этим делать — копируемый блок для eslint.config. Включаем плагин, поднимаем критичные для стабильности warn до error и закрываем floating promises:

import playwright from 'eslint-plugin-playwright';

export default [
  {
    ...playwright.configs['flat/recommended'],
    files: ['tests/**'],
    rules: {
      ...playwright.configs['flat/recommended'].rules,
      'playwright/no-wait-for-timeout': 'error',
      'playwright/no-force-option': 'error',
      // ассерты инкапсулированы в хелперах/POM? перечислите их в assertFunctionNames
      'playwright/expect-expect': 'error',
      // если проект готов к type-aware линтингу:
      // '@typescript-eslint/no-floating-promises': 'error',
    },
  },
];

Если поднять всё разом страшно — поднимайте по одному правилу в неделю и чините найденное. Так команда переваривает изменения без бунта.

Слой третий: смысловые дыры, которые не ловит никакой линт

Дальше уже ревью руками. Здесь правила про смысл теста; машина без понимания намерения их не проверит. Десять штук, которые чаще всего стреляют. У каждого в скобках severity из нашей модели (о ней ниже).

1. Гонка в ожидании сети (Blocker). Объявили waitForResponse после действия — событие уже улетело.

// ❌ клик мог уйти до подписки
await page.getByRole('button', { name: 'Отправить' }).click();
const res = await page.waitForResponse('**/api/submit');

// ✅ promise → действие → await
const resPromise = page.waitForResponse('**/api/submit');
await page.getByRole('button', { name: 'Отправить' }).click();
const res = await resPromise;

2. Мгновенный замер как gate (Major). count() и allTextContents() не ретраятся. На SSR-приложениях после гидратации коллекция может на миг опустеть — замер ловит ноль и валит тест. Или хуже: проходит, пока не начнёт флакать.

// ❌
const items = await page.getByRole('listitem').allTextContents();
expect(items.length).toBeGreaterThan(5);

// ✅ составную проверку - в toPass
await expect(async () => {
  const items = await page.getByRole('listitem').allTextContents();
  expect(items.length).toBeGreaterThan(5);
}).toPass({ timeout: 10_000 });

3. Суррогатный оракул (Major). Тест называется «валидация формы», а проверяет только активность кнопки. Кнопка активна — а в payload уходит [object Object] (привет первой статье). Тест должен проверять то, что обещает его имя: для формы — тело уходящего запроса.

const reqPromise = page.waitForRequest('**/api/feedback');
await submit();
expect((await reqPromise).postDataJSON()).toMatchObject({ email: 'a@b.c' });

4. Точные значения в ассертах (Major). toHaveText('4 567 ₽') упадёт при первом изменении прайса, хотя функциональность жива. Динамический контент — через regex или структурную проверку: toHaveText(/\d[\d\s]*₽/).

5. Плавающие элементы ищутся от секции (Major). Дропдауны, тосты и модалки часто рендерятся порталом в конец body. Локатор section.getByRole('option') их не найдёт никогда. Портальное — искать от page.

6. CSS-цепочки по структуре DOM (Major). div.card > div.body > span.title умирает при первом ребрендинге. Приоритет: getByRole({ name }), потом getByText/getByLabel, и только в крайнем случае CSS. Стратегию селектора не проверяет ни одно правило линта — это целиком решение человека.

7. Тесты зависят друг от друга (Blocker). Первый тест создаёт сущность, второй её открывает по переменной из внешнего скоупа. В одиночку и в другом порядке второй падает. Каждый тест готовит своё состояние сам — через API-запрос в setup.

8. Retries как лекарство (Major). Прошёл со второй попытки — значит, флакает: ретраи маскируют гонку, которая однажды стрельнёт в проде. Диагностика простая: --retries=0 --repeat-each=5. Пять из пяти — тест стабилен, меньше — чинить причину.

9. try/catch вокруг действий и ассертов (Major). Auto-waiting уже встроен в Playwright; catch вокруг клика или ассерта просто глушит то падение, ради которого тест написан.

10. test.fixme для известного бага (Minor). fixme не выполняется — когда баг починят, никто не узнает. test.fail() выполняется и сразу после фикса падает с «Expected to fail, but passed» — бесплатный детектор починки.

Полный каталог — 43 правила с примерами и ссылками на официальную доку по большинству — лежит в репо; там же, в скилле test-review, — чеклист на 49 пунктов.

Severity, чтобы ревью не превращалось во вкусовщину

Спор «важно это или придирка» съедает больше времени, чем само ревью. Помогает жёсткая шкала:

Метка

Значение

Примеры

Blocker

Тест сломан, недетерминирован или маскирует баг. Не мержить

Floating promise, гонка сети, тест без ассертов, test.only в мерже

Major

Хрупкость или флак при смене контента/окружения

CSS-цепочки, точные цены, retries-маскировка, суррогатный оракул

Minor

Читаемость и поддерживаемость

Нет test.step, .nth(2) вместо .filter(), fixme вместо fail

Nit

Косметика

Именование, порядок импортов

Правило игры: Blocker и Major всегда с конкретным фиксом «было → стало», Minor — по желанию автора, Nit не блокирует ничего. И отдельно: если в категории нарушений нет — так и пишется «чисто», без высасывания замечаний ради объёма.

Как это ревьюит агент

Всё это я отдал Claude Code как скилл. Ревью тестов — хорошо механизируемая работа: правила и severity формализованы, фиксы шаблонны. Но у AI-ревьюера есть своя болезнь — придумывать замечания к строкам, которых он не читал. Лечится той же доказательной дисциплиной, что и в первой статье:

  • замечание существует, только если указывает на реально прочитанную строку (file:line) или на вывод инструмента;

  • сначала запускаются typecheck и линт — их вывод репортится как есть;

  • если код выглядит как анти-паттерн, но может быть осознанным решением проекта — агент помечает «под вопросом» и сверяется с конвенциями репозитория;

  • режим по умолчанию — диагностика: отчёт без правок, пока не попросили чинить.

Отдельная секция в скилле — правила конкретного репозитория: какие кастомные фикстуры обязательны, чем smoke отличается от regress, какие особенности у тестового стенда. Универсальный чеклист их не знает, поэтому под них оставлен шаблон.

Ограничение: агент иногда флагает намеренные решения — легаси-хелперы, осознанные исключения. Для этого и нужны пометка «под вопросом» и секция с конвенциями: чем полнее она заполнена, тем меньше ложных срабатываний.

Ссылки

Репозиторий со скиллом test-review и полным каталогом правил: github.com/akovalion/paranoid-qa. Первая статья цикла — про доказательную дисциплину тестирования, ссылка на неё в начале.

И вопрос к вам: какое правило ревью автотестов в вашей команде самое спорное? У нас это no-force-option — у кастомных контролов с display:none инпутами без force иногда правда никак. Самые интересные правила из комментариев заберу в каталог с указанием авторства.