Мы разобрались с асинхронщиной, идемпотентностью и моками. Код выглядит хорошо. Тесты проходят. Но когда проект вырастает до тысячи тестов, начинают падать вещи которые падать не должны, и причина почти никогда не в логике теста.

Причина в архитектурных минах которые заложили месяц назад и забыли. В этой части разберём самые частые из них и настроим 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, данные не пересекутся. Но есть два сценария где это взрывается:

  1. Отладка. Когда тест падает непонятно почему, вы не видите откуда пришло значение this.orderId. Пришлось добавить логирование, потом ещё, потом ещё.

  2. Оптимизация фикстур. Если когда-нибудь решите кешировать 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

expect.poll

Анимация перекрывает клик

UI Race Condition

locator.waitFor({ state: 'visible' })

Фронт ещё не распарсил JSON

Async Gap

page.waitForResponse

Долгая индексация в поиске

Background Job lag

expect.toPass


Тесты могут быть зелёными и при этом держаться на честном слове. Антипаттерны из этой части не падают сразу. Они копятся и взрываются, когда проект вырастает. ESLint-правила выше закрывают большую часть из них автоматически, остальное — вопрос привычки команды.

В следующей части Health Radar: как визуализировать здоровье тестов, настроить авто-карантин и на цифрах объяснить бизнесу зачем вообще нужна стабильность.