page.waitForTimeout — это всего лишь warning. Почему зелёный линт не спасает Playwright-тесты
- среда, 22 июля 2026 г. в 00:00:17
Вот тест, который проходит 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 будет тихо флакать месяцами.
Я пошёл в исходники плагина и выписал severity каждого правила из recommended-конфига — файл src/plugin.ts, объект sharedConfig; актуально для v2.10.5, июль 2026. Вот что там:
Severity | Правила |
|---|---|
error |
|
warn |
|
Во второй строке — слепая пауза, клик с 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 пунктов.
Спор «важно это или придирка» съедает больше времени, чем само ревью. Помогает жёсткая шкала:
Метка | Значение | Примеры |
|---|---|---|
Blocker | Тест сломан, недетерминирован или маскирует баг. Не мержить | Floating promise, гонка сети, тест без ассертов, |
Major | Хрупкость или флак при смене контента/окружения | CSS-цепочки, точные цены, retries-маскировка, суррогатный оракул |
Minor | Читаемость и поддерживаемость | Нет |
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 иногда правда никак. Самые интересные правила из комментариев заберу в каталог с указанием авторства.