В неструктурированных E2E тестах селекторы элементов и низкоуровневые команды навигации размещаются непосредственно внутри тела тестовых функций. Это порождает три критические архитектурные проблемы:
Паттерн Page Object Model (POM) решает эту задачу, отделяя структуру интерфейса от логики верификации.
Однако классическая реализация POM (пришедшая из Java и Selenium), где экземпляры страниц создаются вручную через оператор new MyPage(page) в блоках beforeEach, в современных тестах является устаревшей. В экосистеме Playwright 2026 стандартом является синтез Page Objects с мощной системой внедрения зависимостей (Dependency Injection) на базе кастомных фикстур (Custom Fixtures).
В архитектуре чистого тестирования тестовый сценарий никогда не обращается к DOM-дереву напрямую. Он взаимодействует исключительно с высокоуровневыми интерфейсами предметной области (Domain Pages & Widgets), которые предоставляются через контейнер внедрения зависимостей Playwright.
+-----------------------------------------------------------------------------+
| Test Runner |
| test('пользователь покупает тариф Enterprise', async ({ checkoutPage }) |
+-----------------------------------------------------------------------------+
│
▼ (Внедрение зависимости через IoC)
+-----------------------------------------------------------------------------+
| Playwright Fixtures Engine (test.extend) |
| Автоматический Setup -> Инициализация страниц -> Teardown ресурсов |
+-----------------------------------------------------------------------------+
│
▼ (Вызовы бизнес-методов)
+-----------------------------------------------------------------------------+
| Page Object Model Layer |
| |
| +--------------------------+ +------------------------------+ |
| | ShopPage | | CheckoutPage | |
| | - selectProduct() | | - fillPaymentDetails() | |
| | - openCart() | | - placeOrder() | |
| +--------------------------+ +------------------------------+ |
| │ │ |
| ▼ (Композиция) ▼ (Композиция) |
| +--------------------------+ +------------------------------+ |
| | HeaderNavigationWidget | | NotificationToastWidget | |
| +--------------------------+ +------------------------------+ |
+-----------------------------------------------------------------------------+
│
▼ (Инкапсулированные селекторы getByRole)
+-----------------------------------------------------------------------------+
| Playwright Core Driver |
| Page API -> Chrome DevTools Protocol / WebDriver BiDi |
+-----------------------------------------------------------------------------+
│
▼ (Физические события мыши/клавиатуры)
+-----------------------------------------------------------------------------+
| Browser DOM |
+-----------------------------------------------------------------------------+Определение интерфейса CustomFixtures, перечисляющего все инжектируемые Page Objects, контексты и генераторы данных.
Выполняется до передачи управления тесту: создание инстанса Page Object, предварительные API-вызовы или сидирование БД.
Точка передачи управления в тело теста. Выполнение функции фикстуры приостанавливается, пока выполняется тестовый сценарий.
Тест вызывает методы полученной фикстуры, проверяя бизнес-логику без прямого обращения к сырому DOM.
Код после await use() выполняется гарантированно даже при падении теста: удаление созданных сущностей и очистка кэша.
Какое ключевое преимущество дает использование test.extend() с кастомными фикстурами по сравнению с классическим ручным созданием классов new PageObject(page) в хуках beforeEach?
В соответствии со стандартами промышленного кода локаторы объявляются как readonly свойства типа Locator. Конструктор принимает объект Page.
import { type Page, type Locator, expect } from '@playwright/test';
export interface PaymentInfo {
cardNumber: string;
cardHolder: string;
expiryDate: string;
cvv: string;
}
export class CheckoutPage {
// Инкапсулированные локаторы высокой стабильности (User-Facing)
private readonly pageTitle: Locator;
private readonly cardNumberInput: Locator;
private readonly cardHolderInput: Locator;
private readonly expiryInput: Locator;
private readonly cvvInput: Locator;
private readonly submitOrderButton: Locator;
private readonly successBanner: Locator;
private readonly errorMessageContainer: Locator;
constructor(private readonly page: Page) {
this.pageTitle = page.getByRole('heading', { name: 'Оформление заказа', level: 1 });
this.cardNumberInput = page.getByLabel('Номер банковской карты');
this.cardHolderInput = page.getByLabel('Имя владельца карты');
this.expiryInput = page.getByPlaceholder('ММ/ГГ');
this.cvvInput = page.getByLabel('CVV/CVC');
this.submitOrderButton = page.getByRole('button', { name: 'Подтвердить и оплатить' });
this.successBanner = page.getByRole('status').filter({ hasText: /заказ успешно оформлен/i });
this.errorMessageContainer = page.getByRole('alert');
}
/** Навигация на страницу */
async navigate(): Promise<void> {
await this.page.goto('/checkout');
await expect(this.pageTitle).toBeVisible();
}
/** Комплексное действие заполнения платежных реквизитов */
async fillPaymentForm(info: PaymentInfo): Promise<void> {
await this.cardNumberInput.fill(info.cardNumber);
await this.cardHolderInput.fill(info.cardHolder);
await this.expiryInput.fill(info.expiryDate);
await this.cvvInput.fill(info.cvv);
}
/** Подтверждение заказа */
async submitOrder(): Promise<void> {
await this.submitOrderButton.click();
}
/** Проверка успешного завершения операции */
async expectOrderSuccessful(): Promise<void> {
await expect(this.successBanner).toBeVisible({ timeout: 10_000 });
}
/** Проверка появления ошибки валидации */
async expectValidationError(message: string): Promise<void> {
await expect(this.errorMessageContainer).toContainText(message);
}
}test.extend#Вместо ручного инстанцирования страниц мы расширяем базовый объект test. Метод test.extend<T>() создает типизированный контекст:
import { test as baseTest } from '@playwright/test';
// 1. Определение интерфейса внедряемых фикстур
export interface AppFixtures {
checkoutPage: CheckoutPage;
catalogPage: CatalogPage;
}
// 2. Создание специализированного раннера с автоматическим жизненным циклом
export const test = baseTest.extend<AppFixtures>({
checkoutPage: async ({ page }, use) => {
// Фаза SETUP: создание экземпляра Page Object
const checkoutPage = new CheckoutPage(page);
// Передача инстанса в тело теста
await use(checkoutPage);
// Фаза TEARDOWN (выполняется автоматически после теста):
// Здесь можно выполнить очистку, если необходимо
},
catalogPage: async ({ page }, use) => {
const catalogPage = new CatalogPage(page);
await use(catalogPage);
},
});
export { expect } from '@playwright/test';Крупные приложения состоят из повторяющихся блоков: шапки (Header), боковой панели (Sidebar), корзины (CartDrawer) и центра уведомлений. Создавать плоские монолитные Page Objects на 1000 строк — антипаттерн. Правильное решение — декомпозиция на виджеты.
import { type Page, type Locator, expect } from '@playwright/test';
export class HeaderWidget {
private readonly root: Locator;
private readonly cartButton: Locator;
private readonly cartBadge: Locator;
private readonly searchInput: Locator;
private readonly userMenuButton: Locator;
constructor(page: Page) {
// Инкапсулируем корневой узел шапки
this.root = page.getByRole('banner');
this.cartButton = this.root.getByRole('button', { name: /корзина/i });
this.cartBadge = this.cartButton.getByTestId('cart-items-count');
this.searchInput = this.root.getByRole('searchbox', { name: /поиск по каталогу/i });
this.userMenuButton = this.root.getByRole('button', { name: /профиль пользователя/i });
}
async openCart(): Promise<void> {
await this.cartButton.click();
}
async searchProduct(query: string): Promise<void> {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
async expectCartItemCount(count: number): Promise<void> {
if (count === 0) {
await expect(this.cartBadge).toBeHidden();
} else {
await expect(this.cartBadge).toHaveText(String(count));
}
}
}Теперь страница ShopPage включает в себя виджет HeaderWidget:
import { type Page, type Locator } from '@playwright/test';
import { HeaderWidget } from '../widgets/HeaderWidget';
export class ShopPage {
public readonly header: HeaderWidget;
private readonly productList: Locator;
constructor(private readonly page: Page) {
this.header = new HeaderWidget(page);
this.productList = page.getByRole('main').getByRole('list');
}
async navigate(): Promise<void> {
await this.page.goto('/shop');
}
async addProductToCart(productName: string): Promise<void> {
const productCard = this.productList.getByRole('listitem').filter({
hasText: productName,
});
await productCard.getByRole('button', { name: /в корзину/i }).click();
}
}Паттерн гарантирует, что каждый тест работает с уникальными данными в базе (например, созданным через API заказом) и автоматически подчищает за собой данные после прогона, исключая деградацию БД тестового стенда.
import { test as baseTest, expect } from '@playwright/test';
import { ShopPage } from '../pages/ShopPage';
import { CheckoutPage } from '../pages/CheckoutPage';
export interface TestUser {
id: string;
email: string;
token: string;
}
export interface CustomFixtures {
shopPage: ShopPage;
checkoutPage: CheckoutPage;
temporaryCustomer: TestUser;
}
export const test = baseTest.extend<CustomFixtures>({
// Фикстура создания изолированного покупателя через backend API
temporaryCustomer: async ({ request }, use) => {
// 1. SETUP: Генерируем уникального пользователя через быстрый HTTP-запрос
const uniqueId = `e2e_${Date.now()}_${Math.random().toString(36).substring(7)}`;
const createRes = await request.post('/api/testing/create-ephemeral-user', {
data: {
email: `${uniqueId}@agenticlab-test.internal`,
role: 'customer',
},
});
expect(createRes.ok()).toBeTruthy();
const customer = (await createRes.json()) as TestUser;
// 2. YIELD: передаем сущность в контекст теста
await use(customer);
// 3. TEARDOWN: гарантированное удаление пользователя после теста
const deleteRes = await request.delete(`/api/testing/cleanup-user/${customer.id}`, {
headers: { Authorization: `Bearer ${customer.token}` },
});
expect(deleteRes.ok()).toBeTruthy();
},
// Фикстура страницы магазина
shopPage: async ({ page }, use) => {
const shopPage = new ShopPage(page);
await use(shopPage);
},
// Фикстура страницы оформления
checkoutPage: async ({ page }, use) => {
const checkoutPage = new CheckoutPage(page);
await use(checkoutPage);
},
});
export { expect };Благодаря объединению POM и фикстур финальный тест становится лаконичным, легко читаемым для продакт-овнеров и устойчивым к изменениям верстки.
import { test, expect } from '../fixtures/test-suite';
test.describe('Электронная коммерция: Оформление заказа', () => {
test('покупатель успешно добавляет товар в корзину и совершает оплату', async ({
shopPage,
checkoutPage,
temporaryCustomer,
}) => {
// 1. Навигация в магазин
await shopPage.navigate();
// 2. Добавление товара и проверка счетчика корзины через компонентный виджет
await shopPage.addProductToCart('Курс по архитектуре React 19');
await shopPage.header.expectCartItemCount(1);
// 3. Переход к оформлению через шапку
await shopPage.header.openCart();
await checkoutPage.navigate();
// 4. Заполнение платежных реквизитов данными тестового пользователя
await checkoutPage.fillPaymentForm({
cardNumber: '4000 1234 5678 9010',
cardHolder: temporaryCustomer.email.toUpperCase(),
expiryDate: '12/28',
cvv: '789',
});
// 5. Отправка и проверка результата
await checkoutPage.submitOrder();
await checkoutPage.expectOrderSuccessful();
});
});Что произойдет, если в теле кастомной фикстуры разработчик забудет ключевое слово await перед вызовом use()?
Когда метод POM возвращает сырой объект Locator наружу, вызывающий тест начинает привязываться к внутренним деталям реализации DOM, что разрушает принцип инкапсуляции.
// ❌ Антипаттерн: страница отдает сырой локатор наружу
export class CartPage {
getCheckoutButton(): Locator {
return this.page.locator('#btn-checkout-submit');
}
}
// В тесте:
await cartPage.getCheckoutButton().click(); // Тест управляет кликом кнопки внутри корзины
// ✅ Правильное решение: инкапсуляция действия и высокоуровневый ассерт внутри POM
export class CartPage {
async proceedToCheckout(): Promise<void> {
await this.page.getByRole('button', { name: 'Перейти к оплате' }).click();
}
}Создание страниц вручную через оператор new лишает тест преимуществ ленивой инициализации Playwright и усложняет композицию зависимостей между несколькими страницами и хелперами.
// ❌ Антипаттерн: громоздкий beforeEach с ручным созданием классов
let loginPage: LoginPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
});
// ✅ Правильное решение: использование test.extend() с чистым внедрением аргументов
test('успешный логин', async ({ loginPage, dashboardPage }) => {
await loginPage.login('[email protected]', 'password');
await dashboardPage.expectLoaded();
});Если в теле фикстуры test.extend разработчик забывает ключевое слово await перед вызовом use(), или не вызывает use() вовсе, раннер Playwright не сможет корректно передать управление в тело теста и зависнет до истечения глобального таймаута воркера.
// ❌ Антипаттерн: пропущен await или не вызван use
myFixture: async ({ page }, use) => {
const service = new MyService(page);
use(service); // Ошибка: промис use() не ожидает завершения теста
}
// ✅ Правильное решение: обязательное ожидание завершения теста
myFixture: async ({ page }, use) => {
const service = new MyService(page);
await use(service); // Тест выполняется здесь, пока use() не зарезолвится
}Вспомогательные методы взаимодействия (например, fillForm()) не должны содержать специфических бизнес-ассертов сценария. Если метод ввода данных будет падать на проверке заголовка страницы, этот метод невозможно будет переиспользовать в негативных сценариях (например, при проверке ввода невалидного email).
Решение: разделять методы действий (fillForm(), clickSubmit()) и методы верификации состояния (expectLoaded(), expectErrorMessage()).