Базовый Page Object Model ломается когда одна и та же таблица данных встречается на пяти страницах и её локаторы продублированы пятикратно, или когда loginWith() возвращает Page вместо DashboardPage и тестам приходится угадывать на какой странице они оказались. Эта статья разбирает паттерны которые решают эти проблемы: абстрактный класс BasePage для общей навигации и ожидания загрузки, объекты компонентов для переиспользуемых UI-секций вроде таблиц данных, фикстура PageFactory которая доставляет все page-объекты через единственный параметр pages, и fluent-цепочки методов где каждый метод возвращает следующий page-объект. Здесь же разбираем когда нужно остановиться: базовый POM с одним классом на страницу правильный выбор для большинства проектов до 50 тестов.

Базовый класс страницы

Каждый page-объект в сьюте разделяет общее поведение: навигация на страницу, ожидание загрузки, проверка заголовка страницы. Выносим это в BasePage:

// pages/BasePage.ts
import { Page, Locator } from '@playwright/test';

export abstract class BasePage {
  protected page: Page;
  protected readonly url: string;

  constructor(page: Page, url: string) {
    this.page = page;
    this.url = url;
  }

  async navigate() {
    await this.page.goto(this.url);
    await this.waitForLoad();
  }

  // Переопределяй в подклассах для ожидания индикаторов загрузки конкретной страницы
  protected async waitForLoad() {
    await this.page.waitForLoadState('domcontentloaded');
  }

  async getTitle() {
    return this.page.title();
  }

  async getURL() {
    return this.page.url();
  }
}

Конкретная страница:

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';

export class LoginPage extends BasePage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;

  constructor(page: Page) {
    super(page, '/login');
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Log in' });
    this.errorMessage = page.getByRole('alert');
  }

  protected async waitForLoad() {
    await this.emailInput.waitFor({ state: 'visible' });
  }

  async loginWith(email: string, password: string): Promise<DashboardPage> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    return new DashboardPage(this.page);
  }

  async getErrorMessage() {
    return this.errorMessage.textContent();
  }
}

Возвращаемый тип DashboardPage делает флоу явным: loginWith() возвращает страницу на которую попадаешь.

Объекты компонентов

Большие страницы содержат секции которые повторяются: таблица данных, виджет уведомлений, панель фильтров которая появляется на нескольких страницах. Выносим их в объекты компонентов:

// components/DataTable.ts
import { Page, Locator } from '@playwright/test';

export class DataTable {
  private readonly container: Locator;

  constructor(container: Locator) {
    this.container = container;
  }

  get rows() {
    return this.container.getByRole('row');
  }

  async getRowCount() {
    return this.rows.count();
  }

  async getRowByText(text: string) {
    return this.rows.filter({ hasText: text });
  }

  async clickAction(rowText: string, actionName: string) {
    const row = this.rows.filter({ hasText: rowText });
    await row.getByRole('button', { name: actionName }).click();
  }

  async sortBy(columnName: string) {
    await this.container
      .getByRole('columnheader', { name: columnName })
      .click();
  }
}

Использование в page-объектах:

// pages/OrdersPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { DataTable } from '../components/DataTable';

export class OrdersPage extends BasePage {
  readonly ordersTable: DataTable;

  constructor(page: Page) {
    super(page, '/orders');
    this.ordersTable = new DataTable(
      page.getByTestId('orders-table')
    );
  }
}

Тест:

test('orders table shows correct data', async ({ page }) => {
  const ordersPage = new OrdersPage(page);
  await ordersPage.navigate();

  expect(await ordersPage.ordersTable.getRowCount()).toBeGreaterThan(0);
  await ordersPage.ordersTable.clickAction('Order #123', 'View details');
});

Тот же компонент DataTable переиспользуется в ProductsPage, UsersPage или где угодно ещё появляется таблица.

Фабрики page-объектов

Когда page-объектов много и тесты навигируют между ними, фабрика избавляет от повторного инстанцирования:

// pages/PageFactory.ts
import { Page } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { DashboardPage } from './DashboardPage';
import { OrdersPage } from './OrdersPage';
import { ProfilePage } from './ProfilePage';

export class PageFactory {
  constructor(private page: Page) {}

  get login() { return new LoginPage(this.page); }
  get dashboard() { return new DashboardPage(this.page); }
  get orders() { return new OrdersPage(this.page); }
  get profile() { return new ProfilePage(this.page); }
}

С фикстурой:

// fixtures/pages.ts
import { test as base } from '@playwright/test';
import { PageFactory } from '../pages/PageFactory';

export const test = base.extend<{ pages: PageFactory }>({
  pages: async ({ page }, use) => {
    await use(new PageFactory(page));
  },
});

Тест:

import { test } from '../fixtures/pages';
import { expect } from '@playwright/test';

test('user can view their orders', async ({ pages }) => {
  await pages.login.navigate();
  const dashboard = await pages.login.loginWith('user@example.com', 'password');
  await pages.orders.navigate();

  expect(await pages.orders.ordersTable.getRowCount()).toBeGreaterThan(0);
});

Fluent API: цепочки методов

Для читабельности тестов методы могут возвращать this для цепочек:

// pages/CheckoutPage.ts
export class CheckoutPage extends BasePage {
  async fillShipping(address: ShippingAddress) {
    await this.page.getByLabel('Street').fill(address.street);
    await this.page.getByLabel('City').fill(address.city);
    await this.page.getByLabel('Postal code').fill(address.postalCode);
    return this;
  }

  async selectPaymentMethod(method: 'card' | 'paypal') {
    await this.page.getByRole('radio', { name: method }).click();
    return this;
  }

  async submit() {
    await this.page.getByRole('button', { name: 'Place order' }).click();
    return new ConfirmationPage(this.page);
  }
}

Тест:

const confirmation = await checkoutPage
  .fillShipping({ street: '123 Main St', city: 'Berlin', postalCode: '10115' })
  .then(p => p.selectPaymentMethod('card'))
  .then(p => p.submit());

await expect(confirmation.heading).toHaveText('Order confirmed!');

Когда базового POM достаточно

Не каждый проект нуждается в объектах компонентов, фабриках и fluent-цепочках. Базовый POM (один класс на страницу, локаторы и методы) подходит для проектов с 20–50 тестами, для команд которые только знакомятся с POM и для приложений с простой, преимущественно плоской структурой навигации.

Добавляй сложность только когда базовый паттерн причиняет реальную боль: скопированный код который ломается в нескольких местах, сложность с поиском где определены селекторы, тесты которые трудно читать потому что связь между страницами непонятна.

Цель всегда одна: читабельные, поддерживаемые тесты, а не архитектурная изощрённость.

→ See also: Page Object Model в Playwright: от хаоса к поддерживаемым тестам | TypeScript интерфейсы и типы для Page Object Model | Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми