Когда навигационная панель появляется на каждой странице, дублирование её локаторов в каждом 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