В первой части был заложен фундамент: локаторы, «умные» ассерты и хелсчеки. Это критически важные вещи, но на масштабе в 100+ тестов неизбежно возникает «второй слой» проблем — сложность диагностики и изоляция данных.

Почему конструкторы в SPA — это зона риска (Lazy POM)

В приложениях со сложным жизненным циклом работа с конструкторами классов — это всегда прогулка по минному полю. Главной ошибкой будет попытка вычислить состояние элемента или самого приложения прямо в момент создания объекта Page Object.

Проблема асинхронности и IIFE

Поскольку конструкторы в JavaScript синхронны, часто возникает соблазн использовать IIFE (самовызывающиеся функции), чтобы «подтянуть» данные из браузера (например, count() элементов или текст заголовка) сразу при создании объекта .

// ОПАСНО: Попытка вычислить состояние в конструкторе
constructor(page: Page) {
  (async () => {
    this.initialCount = await page.locator('.items').count(); // Бомба под вашим CI
  })();
}

Это создает неуправляемую гонку (Race Condition). Тест может обратиться к initialCount еще до того, как промис внутри конструктора выполнится. В итоге произойдут случайные падения, которые практически невозможно воспроизвести локально, но которые стабильно «красят» CI при большой нагрузке.

Стандартный подход

Даже если вы не используете IIFE и просто инициализируете локаторы синхронно (что считается «стандартом» во многих фреймворках), вы всё равно закладываете архитектурный долг:

// ТЕХНИЧЕСКИ БЕЗОПАСНО, НО СУБОПТИМАЛЬНО
constructor(page: Page) {
  this.submitButton = page.locator('button#submit'); 
}

Позднее связывание через геттеры

Самый надежный и идиоматичный способ в Playwright — это использование get-методов.

// ПРАВИЛЬНО: Ленивый доступ к элементам
get submitButton() {
  return this.page.getByRole('button', { name: 'Оформить заказ' });
}

Вам могут сказать, что это помогает экономить память, но давайте будем честны, это «экономия на спичках». У геттеров есть более важное преимущество и это позднее связывание.

Playwright-локаторы ленивы сами по себе, но здесь важен и другой архитектурный аспект — ловушка состояния (State Trap). В динамичном SPA компоненты постоянно перерендериваются. Если вычислить состояние (например, this.isMobile) один раз в конструкторе, объект привязывается к «снимку» DOM в момент инициализации. Геттеры же реализуют принцип позднего связывания. Сам локатор и любая логика вокруг него вычисляются заново в момент обращения. Это гарантирует, что Page Object всегда видит актуальный DOM, оставаясь абсолютно Stateless-инструментом.

От ручного создания объектов к Dependency Injection

Прямое инстанцирование вроде const loginPage = new LoginPage(page) внутри тестов ведет к каше из импортов и раздуванию кода. Но главной проблемой является цена рефакторинга.

Если завтра конструктор LoginPage изменится (например, ему понадобится дополнительный конфиг или логгер), вам придется «пройтись» по сотням файлов проекта, чтобы обновить каждый вызов new. В больших проектах это превращается в бессмысленную рутину, которая блокирует развитие архитектуры.

Фикстуры решают это, выступая в роли полноценного Dependency Injection (DI) контейнера. Вы описываете логику создания объекта в одном месте, и это становится единой точкой входа.

Настоящий профит начинается, когда одна фикстура (например, POM) пробрасывается в другую (например, Flow). Playwright кеширует фикстуры, поэтому если пять разных Flow зависят от cartPage, будет создан ровно один инстанс на весь тест.

// fixtures.ts — единая точка управления объектами
export const test = base.extend({
  cartPage: async ({ page }, use) => { 
    // Если здесь изменится способ создания объекта, тесты этого даже не заметят
    await use(new CartPage(page)); 
  },
  
  // Внедряем зависимости прямо в аргументах
  checkoutFlow: async ({ cartPage, checkoutPage }, use) => {
    await use(new CheckoutFlow(cartPage, checkoutPage));
  }
});

// Тест теперь читается как спецификация и не зависит от реализации конструкторов
test('оформление заказа', async ({ authFlow, checkoutFlow }) => {
  await authFlow.loginAs(user); 
  await checkoutFlow.submitOrder();
});

Масштабирование через mergeTests и Namespacing

На старте один файл fixtures.ts — это удобно. Но когда фикстур становится 20+, файл превращается в простыню на сотни строк. Поддерживать такой монолит становится мучительно.

Решением будет Domain-Driven Fixtures. Благодаря методу mergeTests, можно разделить фикстуры по доменам (Auth, Billing, Checkout), а затем собрать их в единый контекст.

// 1. Выносим доменную логику в auth.fixtures.ts
export const authTest = base.extend<{ adminPage: AdminPage }>({ ... });

// 2. Мерджим в основной файл fixtures.ts
import { mergeTests } from '@playwright/test';
import { authTest } from './auth.fixtures';
import { cartTest } from './cart.fixtures';

export const test = mergeTests(authTest, cartTest);

Для самих тестов ничего не меняется, но появляется возможность декомпозировать гигантский контекст. Однако здесь есть важный нюанс про конфликты имен. Если в разных файлах есть фикстуры с одинаковым именем (например, user), Playwright не выдаст ошибку, сработает правило «Последний побеждает». Чтобы не играть в эту угадайку, стоит использовать Namespacing, группировать фикстуры в объекты по доменам:

// Группируем фикстуры, чтобы избежать коллизий
export const test = base.extend<{ auth: { admin: Admin, user: User } }>({
  auth: async ({ page }, use) => {
     await use({ admin: new Admin(page), user: new User(page) });
  }
});

// В тесте это выглядит максимально чисто:
test('Админ может войти', async ({ auth }) => {
  await auth.admin.loginAs();
});

Изоляция данных: RUN_ID против хаоса в параллели

Параллельный запуск 1000 тестов может убить стабильность, если данные разных потоков пересекаются. Обычный workerIndex хорош только при локальном запуске. В CI, где может быть 10 параллельных агентов (шардов), на каждом из них будет свой «Воркер №0», и коллизии гарантированы.

Для реальной изоляции нужны три составляющие:

  1. RUN_ID: Уникальный ID билда из CI (например, GITHUB_RUN_ID)

  2. testId: Нативный хэш от пути и имени теста

  3. repeatEachIndex: На случай повторных запусков одного теста

Сидирование Faker комбинацией этих параметров дает предсказуемость, которой так не хватает в CI. Данные стабильны при ретраях и на разных шардах, но уникальны между разными билдами.

// utils/faker.utils.ts
export function seedFaker(testInfo: TestInfo) {
    const RUN_ID = process.env.RUN_ID || 'local';
    const seed = hashCode(`${testInfo.testId}-${RUN_ID}`);
    faker.seed(seed);
    return faker;
}

// fixtures.ts
export const test = base.extend({
  faker: async ({}, use, testInfo) => {
    await use(seedFaker(testInfo)); // Сюда мы смотрим только за результатом
  }
});

Это позволяет «прокрутить время назад». Если тест упал в CI, можно взять RUN_ID из логов пайплайна, запустить тест локально с этим же ID и получить те же самые имена, email-ы и UUID, что были в CI.

Почему не использовать workerIndex для сида? При ретраях тест может перезапуститься на другом воркере. Сид изменится, данные станут другими, и воспроизвести причину падения будет невозможно, если проблема именно в этих данных.

Слоеная архитектура данных: Фабрики, Оверрайды и Датасеты

Для масштабирования мало изолировать код, нужно изолировать сами данные. Эффективная схема выглядит так: Faker -> Фабрики -> Оверрайды.

  1. Faker: Генерирует «информационный шум» (имена, почты), который не важен для логики

  2. Фабрики: Задают структуру объекта с разумными дефолтами

  3. Overrides: В самом тесте меняются только те поля, которые критичны для проверки

// Фабрика (user.factory.ts)
export function createUser(overrides?: Partial<User>, f = faker): User {
  return {
    id: f.string.uuid(),
    name: f.person.fullName(), 
    role: 'customer',
    ...overrides
  };
}

Если поле не влияет на результат теста — не трогай его. Это избавляет тесты от лишнего шума. А если какой-то набор данных описывает конкретный бизнес-кейс и повторяется постоянно, то его стоит выносить в именованный Dataset.

// src/data/datasets/users.ts
export const VIP_USER = { role: 'vip', discount: 0.15 };

// В тесте используем комбинацию: структура + датасет + шум фейкера
test('VIP скидка применяется', async ({ checkoutFlow, faker }) => {
  const vipUser = createUser({ ...VIP_USER }, faker);
  await checkoutFlow.asUser(vipUser).applyPromo();
});

Семантика поведения

В BDR разделяются технические функции и бизнес-поведение. Если группировка кода в функции — это просто рефакторинг, то использование @Step — это уже живая документация.

Есть простая проверка на качество шагов:

  • Технический лог (плохо): @Step('Клик по кнопке "Войти"'). Если ID кнопки изменится, придется менять текст шага. Смысл для бизнеса тут скрыт.

  • Бизнес-интент (хорошо): @Step('Авторизация в системе'). Отчет читается как сценарий. Если «Логин» сменится на «SSO», шаг останется валидным.

export class CheckoutFlow {
  @Step('Оформление заказа #${orderId}')
  async submitOrder(orderId: string) {
    await this.checkoutPage.fillDetails(orderId);
    await this.checkoutPage.submit();
  }
}

Не забывайте, что @Step требует включения "experimentalDecorators": true в tsconfig.json. Если команда против экспериментальных фич, используйте test.step() внутри методов Flow.

Культура как код: ESLint-стражи

Чтобы команда не скатывалась к прямому созданию Page Objects (new LoginPage) в обход архитектуры, стоит закрепить правила на уровне линтера. Например:

// .eslintrc.json
"no-restricted-syntax": ["error", {
  "selector": "NewExpression[callee.name=/.*Page$/]",
  "message": "Используйте фикстуры вместо new для PageObjects"
}]

Любое использование eslint-disable в таком случае должно сопровождаться обязательным комментарием с причиной, почему здесь сделано исключение. Это дисциплинирует и помогает поддерживать чистоту DI-контейнера.

Итоги

Переход к такой архитектуре — это переход к тестированию как к продукту, который должен быть дешевым в поддержке. Минимизируя главные пожиратели времени, болезненный рефакторинг, долгую диагностику падений и коллизии данных в параллели.

В следующей части будет разобран «асинхронный ад»: работа с Eventual Consistency, использование expect.poll и методы очистки базы данных от тестового мусора.


Полезные ссылки