Мы разобрались с асинхронщиной, идемпотентностью и моками. Код выглядит хорошо. Тесты проходят. Но когда проект вырастает до тысячи тестов, начинают падать вещи которые падать не должны, и причина почти никогда не в логике теста.
Причина в архитектурных минах которые заложили месяц назад и забыли. В этой части разберём самые частые из них и настроим ESLint чтобы робот ловил их вместо лида на ревью.
Примеры кода намеренно упрощены для наглядности. Фокус на идее, не на архитектуре.
Антипаттерны, которые убивают масштаб
Антипаттерн 1: Логика данных прямо в тесте
Если в .spec.ts файле есть api.post или sql.query — это проблема. Не потому что это некрасиво, а потому что когда схема API изменится, придётся переписывать не одну фикстуру, а сто тестов.
// Плохо test('создание заказа', async ({ request, page }) => { const user = await request.post('/api/users', { data: { name: 'Ivan' } }); await page.goto(`/orders?user=${user.id}`); }); // Хорошо test('создание заказа', async ({ user, page }) => { await page.goto(`/orders?user=${user.id}`); });
Фикстура user под капотом сама создаёт сущность и инкапсулирует всю грязную работу. Тест просто просит готовый объект.
Важный нюанс: даже если вынести API-вызов в OrderHelper.create() и вызывать его внутри теста, это всё равно протечка. Идеалом будет фикстура, которая отдаёт уже готовую инициализированную сущность.
Если фикстуры ещё нет, а проверить нужно прямо сейчас, прямой вызов API временно допустим. Главное, пометить // TODO: move to fixture, чтобы технический долг не стал хроническим.
Антипаттерн 2: Состояние внутри Page Object
Хранить динамические данные в свойствах Page Object — значит закладывать мину для параллельных прогонов.
// Плохо class OrderPage { private orderId: string; async createOrder() { this.orderId = await this.page.getByRole('heading', { name: /Заказ №/ }).innerText(); } async checkOrder() { await expect(this.page).toHaveURL(new RegExp(this.orderId)); } }
На первый взгляд выглядит безобидно. Playwright создаёт новый POM для каждого теста через свежую page, данные не пересекутся. Но есть два сценария где это взрывается:
Отладка. Когда тест падает непонятно почему, вы не видите откуда пришло значение
this.orderId. Пришлось добавить логирование, потом ещё, потом ещё.Оптимизация фикстур. Если когда-нибудь решите кешировать POM между тестами чтобы ускорить прогон, грязный стейт от предыдущего теста мгновенно превратит параллельный запуск в лотерею.
Правило простое: Page Object только методы, никакого состояния. Всё динамическое исключительно через аргументы или возвращаемые значения.
// Хорошо class OrderPage { async createOrder(): Promise<string> { return await this.page.getByRole('heading', { name: /Заказ №/ }).innerText(); } async checkOrder(orderId: string) { await expect(this.page).toHaveURL(new RegExp(orderId)); } }
Антипаттерн 3: Прямой импорт Faker
// Плохо import { faker } from '@faker-js/faker';
В части 2 мы настроили сидирование для воспроизводимости тестов. Прямой импорт Faker обходит этот механизм. Если тест упал, воспроизвести его с теми же данными уже не получится.
Используйте только засидированную фикстуру faker из контекста теста. ESLint-правило ниже закроет вопрос автоматически.
Антипаттерн 4: Static-переменные в тестовом коде
// Плохо class OrderFlow { static lastId: string; }
Воркеры Playwright переиспользуются между тестами. Если Тест А запишет ID в static lastId, Тест Б запущенный следом в том же воркере прочитает чужой ID. Это создаёт самые неуловимые флаки, зависящие от порядка запуска тестов.
Никаких static-переменных в тестовом коде вообще.
Отдельный вопрос про static-методы в утилитах. Если вы пришли из Java или C# и привыкли писать CryptoUtils.hashCode(), это допустимо. Static-метод не хранит состояние, он просто функция привязанная к классу. Но в TypeScript проще и безопаснее писать обычный export function. Никакого класса, никакого static. Меньше кода и нет соблазна случайно добавить static-переменную рядом.
Антипаттерн 5: Циклические грабли
forEach с async
Классика при переходе с бэкенда. forEach не умеет ждать промисы. Команды улетают в пустоту, тест завершается раньше, чем отработали клики, и вы получаете призрачный зелёный результат.
// Плохо items.forEach(async (item) => { await page.getByText(item).click(); }); // Хорошо for (const item of items) { await page.getByText(item).click(); }
Только for...of. Всегда.
Promise.all для UI-действий
Параллельное заполнение формы кажется хорошей идеей для ускорения. Пока не столкнётесь с тем, что действия меняют стейт страницы: фокус, поп-апы, анимации.
// Плохо await Promise.all(inputs.map(i => page.fill(i.selector, i.val)));
Разделяйте слои: API-подготовку делайте параллельно, UI-действия — строго последовательно.
Цикл без test.step
Если цикл на 50 итераций падает на 42-й, в отчёте будет просто «Test failed». Оборачивайте итерации в шаги:
for (const item of items) { await test.step(`Проверка айтема: ${item.name}`, async () => { await page.goto(`/product/${item.id}`); await expect(page.getByTestId('product-price')).toContainText(item.price); }); }
Это превращает цикл из чёрного ящика в прозрачный список шагов в отчёте.
ESLint вместо лида на ревью
Объяснять одно и то же на каждом ревью — дорого. Настройте eslint-plugin-playwright один раз, и робот будет делать это за вас круглосуточно.
// .eslintrc.js (ESLint v8) module.exports = { extends: ['plugin:playwright/recommended'], rules: { 'playwright/no-wait-for-timeout': 'error', 'playwright/no-focused-test': 'error', 'playwright/no-skipped-test': 'warn', 'playwright/no-page-pause': 'error', 'no-restricted-imports': ['error', { paths: [{ name: '@faker-js/faker', message: 'Используйте засидированную фикстуру faker из контекста теста (см. Часть 2).' }] }], 'no-async-promise-executor': 'error', 'no-await-in-loop': 'off', 'no-restricted-syntax': ['error', { 'selector': "ClassDeclaration[id.name=/.*Flow$/] MethodDefinition[kind='method']:not(:has(Decorator[expression.callee.name='Step']))", 'message': 'Методы Flow обязаны быть обёрнуты в декоратор @Step для прозрачности отчётов.' }], }, };
Если вы на ESLint v9, конфиг будет выглядеть так:
// eslint.config.mjs (ESLint v9+) import playwright from 'eslint-plugin-playwright'; export default [ { files: ['tests/**'], ...playwright.configs['flat/recommended'], rules: { ...playwright.configs['flat/recommended'].rules, 'playwright/no-wait-for-timeout': 'error', 'playwright/no-focused-test': 'error', 'playwright/no-skipped-test': 'warn', 'playwright/no-page-pause': 'error', 'no-restricted-imports': ['error', { paths: [{ name: '@faker-js/faker', message: 'Используйте засидированную фикстуру faker из контекста теста.' }] }], 'no-async-promise-executor': 'error', 'no-await-in-loop': 'off', }, }, ];
Ловушка toBeHidden
expect(loc).toBeHidden() возвращает true если элемента нет в DOM или он скрыт. Это создаёт риск ложноположительного результата, который сложно поймать.
Представьте сценарий: вводите неверный пароль, потом верный, проверяете, что ошибка исчезла. Если фронтенд не успел отрисовать первую ошибку за 100 мс, тест сразу перейдёт к проверке и покажет зелёный. Ошибка даже не появлялась, а тест «прошёл».
Когда проверяете исчезновение, сначала убедитесь что элемент был:
const passwordInput = page.getByLabel('Пароль'); const error = page.getByText('Неверный пароль'); await passwordInput.fill('wrong'); await expect(error).toBeVisible(); // сначала убеждаемся что ошибка появилась await passwordInput.fill('correct'); await expect(error).toBeHidden(); // теперь проверка легитимна const error = page.getByText('Неверный пароль');
Когда проверяете, что элемент вообще не появился, нужен якорь:
await page.fill('#email', 'valid@test.com'); await page.getByRole('button', { name: 'Отправить' }).click(); await page.waitForResponse('/api/login'); // ждём ответа API. Это якорь await expect(page.getByText('Ошибка')).toBeHidden();
Без якоря toBeHidden — это гадание. Тест проходит не потому, что всё хорошо, а потому что не успел увидеть плохое.
Если важно убедиться, что элемент вообще отсутствует в DOM, используйте expect(loc).not.toBeAttached(). Это жёстче, чем toBeHidden и исключает случай когда элемент есть в дереве, но просто скрыт стилями.
Воркеры и память
fullyParallel: true включается одной строкой, но на слабом CI-агенте 20 браузеров начнут бороться за память. И вы получите случайные таймауты, которые выглядят как флаки, но флаками не являются.
// playwright.config.ts workers: process.env.CI ? '50%' : undefined,
50% доступных ядер оставляет запас для Node.js и операционной системы. Простое правило, которое предотвращает целый класс проблем.
Утечки памяти на длинных прогонах
На тысяче тестов маленькая утечка в фикстуре превращается в критическую проблему к концу пайплайна. Тяжёлые объекты, созданные в globalSetup, висят в памяти весь прогон.
Правило простое: всё, что создаёте в фикстуре, должно иметь логику очистки после await use().
// Фикстура с клинапом export const test = base.extend({ user: async ({ request }, use) => { const user = await createUser(request); await use(user); await deleteUser(request, user.id); // клинап гарантирован } });
Таблица: симптом vs лекарство
Если хочется поставить sleep, архитектура сигнализирует о проблеме. Вот как читать эти сигналы:
Хочется поставить sleep потому что | Реальная причина | Что делать |
|---|---|---|
Данные не успели доехать в БД | Eventual Consistency |
|
Анимация перекрывает клик | UI Race Condition |
|
Фронт ещё не распарсил JSON | Async Gap |
|
Долгая индексация в поиске | Background Job lag |
|
Тесты могут быть зелёными и при этом держаться на честном слове. Антипаттерны из этой части не падают сразу. Они копятся и взрываются, когда проект вырастает. ESLint-правила выше закрывают большую часть из них автоматически, остальное — вопрос привычки команды.
В следующей части Health Radar: как визуализировать здоровье тестов, настроить авто-карантин и на цифрах объяснить бизнесу зачем вообще нужна стабильность.
Playwright BDR Template — github.com/dmitryAQA/playwright-bdr-template
BDR Methodology Manifesto — github.com/dmitryAQA/bdr-methodology
