Базовый 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: паттерн, который делает тесты читаемыми