Когда навигационная панель появляется на каждой странице, дублирование её локаторов в каждом page-объекте первый признак что нужны объекты компонентов: класс NavigationBar встроенный в BasePage чтобы каждая страница наследовала nav.logout() без копирования кода. Многошаговые флоу требуют другого паттерна: proceedToCheckout() возвращающий CheckoutPage и continueToPayment() возвращающий PaymentPage даёт системе типов точную карту куда ведёт каждое действие, и тесты читаются как сам флоу. Это руководство разбирает объекты компонентов для общего UI вроде навбаров и модалов, подход с компоновкой в BasePage, динамические компоненты таблиц, типизированные цепочки страниц для многошаговых флоу и паттерн builder для сборки тестовых данных без раздутых конструкторов.

Проблемы наивного POM

Базовый POM работает для простых страниц. Ломается когда:

  • Навигационная панель появляется на каждой странице. Дублировать её в каждом page-объекте?
  • Модал может появляться с нескольких страниц. Где он живёт?
  • Сложная многошаговая форма растянута на несколько страниц. Как их связывать?
  • Локаторы устаревают из-за частых изменений структуры страниц

Продвинутые паттерны POM решают эти проблемы.

Объекты компонентов: переиспользуемые части UI

Выносим общие UI-элементы в отдельные классы:

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

export class NavigationBar {
  readonly page: Page;
  readonly userMenu: Locator;
  readonly notificationBell: Locator;
  readonly searchBar: Locator;
  readonly logo: Locator;

  constructor(page: Page) {
    this.page = page;
    this.userMenu = page.getByTestId('user-menu');
    this.notificationBell = page.getByTestId('notification-bell');
    this.searchBar = page.getByTestId('nav-search');
    this.logo = page.getByTestId('logo');
  }

  async logout() {
    await this.userMenu.click();
    await this.page.getByTestId('logout-option').click();
  }

  async goToProfile() {
    await this.userMenu.click();
    await this.page.getByTestId('profile-option').click();
  }

  async search(query: string) {
    await this.searchBar.fill(query);
    await this.searchBar.press('Enter');
  }
}

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

export class ConfirmationModal {
  readonly page: Page;
  readonly modal: Locator;
  readonly title: Locator;
  readonly message: Locator;
  readonly confirmButton: Locator;
  readonly cancelButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.modal = page.getByTestId('confirmation-modal');
    this.title = page.getByTestId('modal-title');
    this.message = page.getByTestId('modal-message');
    this.confirmButton = page.getByTestId('modal-confirm');
    this.cancelButton = page.getByTestId('modal-cancel');
  }

  async confirm() {
    await this.confirmButton.click();
    await this.modal.waitFor({ state: 'hidden' });
  }

  async cancel() {
    await this.cancelButton.click();
    await this.modal.waitFor({ state: 'hidden' });
  }

  async waitForOpen() {
    await this.modal.waitFor({ state: 'visible' });
  }
}

BasePage с компоновкой

Вместо наследования везде компонуем страницы из компонентов:

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

export abstract class BasePage {
  readonly page: Page;
  readonly nav: NavigationBar;
  readonly confirmModal: ConfirmationModal;

  constructor(page: Page) {
    this.page = page;
    this.nav = new NavigationBar(page);
    this.confirmModal = new ConfirmationModal(page);
  }

  abstract navigate(): Promise<void>;

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

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

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

export class UserManagementPage extends BasePage {
  readonly userRows: Locator;
  readonly addUserButton: Locator;
  readonly searchInput: Locator;

  constructor(page: Page) {
    super(page);  // Настраивает nav и confirmModal
    this.userRows = page.getByTestId('user-row');
    this.addUserButton = page.getByTestId('add-user');
    this.searchInput = page.getByTestId('user-search');
  }

  async navigate() {
    await this.page.goto('/admin/users');
    await this.waitForPageLoad();
  }

  async deleteUser(userId: number) {
    await this.page.getByTestId(`delete-user-${userId}`).click();
    await this.confirmModal.waitForOpen();
    await this.confirmModal.confirm();
  }
}

// В тестах
test('admin can delete a user', async ({ page }) => {
  const usersPage = new UserManagementPage(page);
  await usersPage.navigate();
  
  const initialCount = await usersPage.userRows.count();
  await usersPage.deleteUser(123);
  
  await expect(usersPage.userRows).toHaveCount(initialCount - 1);
  // Навбар тоже доступен:
  await usersPage.nav.logout();
});

Динамические строки и таблицы

Работа со списками и таблицами с динамическим контентом:

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

export class DataTable {
  readonly rows: Locator;
  readonly headers: Locator;
  readonly sortButtons: Locator;
  readonly pagination: Locator;

  constructor(page: Page, tableTestId: string) {
    const table = page.getByTestId(tableTestId);
    this.rows = table.getByTestId('table-row');
    this.headers = table.getByTestId('table-header');
    this.sortButtons = table.getByTestId('sort-button');
    this.pagination = table.getByTestId('pagination');
  }

  getRow(index: number) {
    return this.rows.nth(index);
  }

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

  async getCellValue(row: number, column: number) {
    return this.rows.nth(row)
      .getByTestId('table-cell')
      .nth(column)
      .textContent();
  }

  async sortBy(columnName: string) {
    await this.sortButtons.filter({ hasText: columnName }).click();
  }

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

// В page-объекте
class OrdersPage extends BasePage {
  readonly ordersTable: DataTable;
  
  constructor(page: Page) {
    super(page);
    this.ordersTable = new DataTable(page, 'orders-table');
  }
}

// В тестах
test('orders sorted by date', async ({ page }) => {
  const ordersPage = new OrdersPage(page);
  await ordersPage.navigate();
  
  await ordersPage.ordersTable.sortBy('Date');
  
  const firstDate = await ordersPage.ordersTable.getCellValue(0, 2);
  const secondDate = await ordersPage.ordersTable.getCellValue(1, 2);
  
  expect(new Date(firstDate!)).toBeGreaterThan(new Date(secondDate!));
});

Многошаговые флоу: цепочки страниц

Для многошаговых флоу вроде чекаута каждый шаг возвращает следующую страницу:

// pages/checkout/CartPage.ts
import { Page } from '@playwright/test';
import { CheckoutPage } from './CheckoutPage';

export class CartPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }

  async navigate() {
    await this.page.goto('/cart');
  }

  async proceedToCheckout(): Promise<CheckoutPage> {
    await this.page.getByTestId('proceed-checkout').click();
    await this.page.waitForURL('/checkout/shipping');
    return new CheckoutPage(this.page);
  }
}

// pages/checkout/CheckoutPage.ts
import { Page } from '@playwright/test';
import { PaymentPage } from './PaymentPage';

export class CheckoutPage extends BasePage {
  readonly firstName: Locator;
  readonly lastName: Locator;
  readonly address: Locator;
  readonly city: Locator;
  
  constructor(page: Page) {
    super(page);
    this.firstName = page.getByTestId('first-name');
    this.lastName = page.getByTestId('last-name');
    this.address = page.getByTestId('address');
    this.city = page.getByTestId('city');
  }

  async fillShipping(details: ShippingDetails): Promise<void> {
    await this.firstName.fill(details.firstName);
    await this.lastName.fill(details.lastName);
    await this.address.fill(details.address);
    await this.city.fill(details.city);
  }

  async continueToPayment(): Promise<PaymentPage> {
    await this.page.getByTestId('continue-payment').click();
    await this.page.waitForURL('/checkout/payment');
    return new PaymentPage(this.page);
  }
}

// Тест использующий цепочку
test('complete checkout flow', async ({ page }) => {
  const cart = new CartPage(page);
  await cart.navigate();
  
  const checkout = await cart.proceedToCheckout();
  await checkout.fillShipping({
    firstName: 'John', lastName: 'Doe',
    address: '123 Main St', city: 'Portland',
  });
  
  const payment = await checkout.continueToPayment();
  await payment.fillCard({ number: '4242424242424242', expiry: '12/28', cvc: '123' });
  
  const confirmation = await payment.submitOrder();
  await expect(confirmation.orderNumber).toBeVisible();
});

Паттерн Builder для сложной настройки

При создании сложных тестовых данных или заполнении сложных форм:

class UserFormBuilder {
  private data: Partial<UserFormData> = {};

  withName(firstName: string, lastName: string) {
    this.data.firstName = firstName;
    this.data.lastName = lastName;
    return this;  // Fluent-интерфейс для цепочки
  }

  withEmail(email: string) {
    this.data.email = email;
    return this;
  }

  withRole(role: 'admin' | 'member') {
    this.data.role = role;
    return this;
  }

  asAdmin() {
    this.data.role = 'admin';
    this.data.permissions = ['manage-users', 'view-reports'];
    return this;
  }

  build(): UserFormData {
    return {
      firstName: this.data.firstName ?? 'Test',
      lastName: this.data.lastName ?? 'User',
      email: this.data.email ?? `test_${Date.now()}@example.com`,
      role: this.data.role ?? 'member',
      permissions: this.data.permissions ?? [],
    };
  }
}

// В тестах — очень читабельно
const adminUser = new UserFormBuilder()
  .withName('Alice', 'Smith')
  .withEmail('alice@test.com')
  .asAdmin()
  .build();

const regularUser = new UserFormBuilder()
  .withEmail('bob@test.com')
  .build();  // Использует дефолты для всего остального

Ленивая инициализация локаторов

Вычисляем локаторы один раз при первом обращении:

class ProductPage extends BasePage {
  private _productCards?: Locator;
  private _filterPanel?: Locator;
  
  get productCards(): Locator {
    if (!this._productCards) {
      this._productCards = this.page.getByTestId('product-card');
    }
    return this._productCards;
  }
  
  get filterPanel(): Locator {
    if (!this._filterPanel) {
      this._filterPanel = this.page.getByTestId('filter-panel');
    }
    return this._filterPanel;
  }
}

Шпаргалка

| Паттерн | Какую проблему решает |

|---------|----------------------|

| Объекты компонентов | Общий UI (навбар, модалы) без дублирования |

| Компоновка в BasePage | Общая функциональность без глубоких цепочек наследования |

| Класс DataTable | Динамические списки/таблицы с переиспользуемыми операциями над строками |

| Цепочки страниц | Многошаговые флоу с типобезопасными переходами |

| Паттерн Builder | Сложные тестовые данные и заполнение форм |

| Ленивые локаторы | Производительность: вычисляются только когда нужны |

Ключевой принцип: page-объекты должны отражать то как пользователи думают о приложении, а не то как устроен DOM. Когда page-объекты читаются как пользовательская история («перейти к оформлению», «заполнить адрес доставки», «оформить заказ»), уровень абстракции выбран правильно.

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