У неструктурованих 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 |
+-----------------------------------------------------------------------------+Відповідно до стандартів промислового коду локатори оголошуються як 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();
});
});Коли метод 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()).
Визначення інтерфейсу CustomFixtures, що перелічує всі інжектовані Page Objects, контексти та генератори даних.
Виконується до передачі керування тесту: створення інстансу Page Object, попередні API-виклики або сідування БД.
Точка передачі керування в тіло тесту. Виконання функції фікстури призупиняється, поки виконується тестовий сценарій.
Тест викликає методи отриманої фікстури, перевіряючи бізнес-логіку без прямого звернення до сирого DOM.
Код після await use() виконується гарантовано навіть при падінні тесту: видалення створених сутностей та очищення кешу.