In unstructured E2E test suites, low-level DOM selectors and navigation commands reside directly inside test function bodies. This anti-pattern introduces three severe maintenance bottlenecks:
The Page Object Model (POM) resolves these concerns by cleanly separating UI markup structure from business assertion logic.
However, classical POM implementations originating from legacy Java and Selenium ecosystems—where pages are manually instantiated using new MyPage(page) inside beforeEach hooks—are outdated. In modern Playwright architectures, the established standard pairs Page Objects with Dependency Injection (IoC) powered by Custom Fixtures.
In clean testing architecture, a test scenario never interfaces directly with the raw DOM. It interacts exclusively with high-level domain abstractions (Domain Pages & Widgets) injected dynamically by the Playwright fixture container.
+-----------------------------------------------------------------------------+
| Test Runner |
| test('user purchases Enterprise plan', async ({ checkoutPage }) => { ... })|
+-----------------------------------------------------------------------------+
│
▼ (Dependency Injection via IoC)
+-----------------------------------------------------------------------------+
| Playwright Fixtures Engine (test.extend) |
| Automated Setup -> Page Instantiation -> Automatic Teardown |
+-----------------------------------------------------------------------------+
│
▼ (High-Level Domain Calls)
+-----------------------------------------------------------------------------+
| Page Object Model Layer |
| |
| +--------------------------+ +------------------------------+ |
| | ShopPage | | CheckoutPage | |
| | - selectProduct() | | - fillPaymentDetails() | |
| | - openCart() | | - placeOrder() | |
| +--------------------------+ +------------------------------+ |
| │ │ |
| ▼ (Composition) ▼ (Composition) |
| +--------------------------+ +------------------------------+ |
| | HeaderNavigationWidget | | NotificationToastWidget | |
| +--------------------------+ +------------------------------+ |
+-----------------------------------------------------------------------------+
│
▼ (Encapsulated getByRole Locators)
+-----------------------------------------------------------------------------+
| Playwright Core Driver |
| Page API -> Chrome DevTools Protocol / WebDriver BiDi |
+-----------------------------------------------------------------------------+
│
▼ (Native Mouse/Keyboard Events)
+-----------------------------------------------------------------------------+
| Browser DOM |
+-----------------------------------------------------------------------------+Under enterprise standards, locators are declared as readonly class properties of type Locator. The constructor receives an injected Page instance.
import { type Page, type Locator, expect } from '@playwright/test';
export interface PaymentInfo {
cardNumber: string;
cardHolder: string;
expiryDate: string;
cvv: string;
}
export class CheckoutPage {
// Encapsulated resilient user-facing locators
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: 'Checkout', level: 1 });
this.cardNumberInput = page.getByLabel('Card Number');
this.cardHolderInput = page.getByLabel('Cardholder Name');
this.expiryInput = page.getByPlaceholder('MM/YY');
this.cvvInput = page.getByLabel('CVV/CVC');
this.submitOrderButton = page.getByRole('button', { name: 'Complete Payment' });
this.successBanner = page.getByRole('status').filter({ hasText: /order placed successfully/i });
this.errorMessageContainer = page.getByRole('alert');
}
/** Navigate to checkout route */
async navigate(): Promise<void> {
await this.page.goto('/checkout');
await expect(this.pageTitle).toBeVisible();
}
/** Fill payment credentials */
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);
}
/** Submit payment transaction */
async submitOrder(): Promise<void> {
await this.submitOrderButton.click();
}
/** Assert order completion banner */
async expectOrderSuccessful(): Promise<void> {
await expect(this.successBanner).toBeVisible({ timeout: 10_000 });
}
/** Assert validation error message */
async expectValidationError(message: string): Promise<void> {
await expect(this.errorMessageContainer).toContainText(message);
}
}test.extend Method#Rather than manually instantiating classes across test files, extend the foundational test runner. test.extend<T>() provides a type-safe dependency injection context:
import { test as baseTest } from '@playwright/test';
// 1. Declare fixture interfaces
export interface AppFixtures {
checkoutPage: CheckoutPage;
catalogPage: CatalogPage;
}
// 2. Create specialized runner with automated lifecycles
export const test = baseTest.extend<AppFixtures>({
checkoutPage: async ({ page }, use) => {
// SETUP phase: instantiate Page Object
const checkoutPage = new CheckoutPage(page);
// Yield control to test execution
await use(checkoutPage);
// TEARDOWN phase (runs automatically after test completion)
},
catalogPage: async ({ page }, use) => {
const catalogPage = new CatalogPage(page);
await use(catalogPage);
},
});
export { expect } from '@playwright/test';Large applications share recurring UI patterns: Header bars, Sidebars, Cart Drawers, and Notification Toasts. Building 1,000-line monolithic Page Objects is an antipattern; decomposing into composed widgets ensures maintainability.
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) {
// Encapsulate header root element
this.root = page.getByRole('banner');
this.cartButton = this.root.getByRole('button', { name: /shopping cart/i });
this.cartBadge = this.cartButton.getByTestId('cart-items-count');
this.searchInput = this.root.getByRole('searchbox', { name: /search catalog/i });
this.userMenuButton = this.root.getByRole('button', { name: /user profile/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));
}
}
}Now the parent ShopPage composes the 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: /add to cart/i }).click();
}
}This pattern ensures every test operates with unique seeded data (e.g., users or orders generated via API) and automatically purges artifacts afterwards, preventing staging database drift.
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>({
// Fixture creating isolated ephemeral customer via API
temporaryCustomer: async ({ request }, use) => {
// 1. SETUP: Seed unique user via fast backend HTTP call
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: provide entity to the test execution context
await use(customer);
// 3. TEARDOWN: guaranteed cleanup execution post-test
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 };Combining POM and custom fixtures yields readable, robust tests resilient against markup alterations:
import { test, expect } from '../fixtures/test-suite';
test.describe('E-Commerce: Checkout Workflow', () => {
test('customer adds product to cart and completes payment', async ({
shopPage,
checkoutPage,
temporaryCustomer,
}) => {
// 1. Navigate to storefront
await shopPage.navigate();
// 2. Add product and assert cart badge via composed widget
await shopPage.addProductToCart('React 19 Architecture Course');
await shopPage.header.expectCartItemCount(1);
// 3. Navigate to checkout via header
await shopPage.header.openCart();
await checkoutPage.navigate();
// 4. Populate payment details with seeded customer data
await checkoutPage.fillPaymentForm({
cardNumber: '4000 1234 5678 9010',
cardHolder: temporaryCustomer.email.toUpperCase(),
expiryDate: '12/28',
cvv: '789',
});
// 5. Submit order and verify confirmation
await checkoutPage.submitOrder();
await checkoutPage.expectOrderSuccessful();
});
});When POM methods return raw Locator instances, caller tests begin binding to internal DOM structures, violating encapsulation.
// ❌ WRONG: Exposing raw locator to test body
export class CartPage {
getCheckoutButton(): Locator {
return this.page.locator('#btn-checkout-submit');
}
}
// In test:
await cartPage.getCheckoutButton().click();
// ✅ CORRECT: Encapsulate actions and assertions inside POM
export class CartPage {
async proceedToCheckout(): Promise<void> {
await this.page.getByRole('button', { name: 'Proceed to Checkout' }).click();
}
}Creating pages with new PageObject(page) inside beforeEach bypasses Playwright's lazy initialization and complicates dependency graphs across test suites.
// ❌ WRONG: Imperative beforeEach boilerplate
let loginPage: LoginPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
});
// ✅ CORRECT: Dependency injection with test.extend
test('successful login', async ({ loginPage, dashboardPage }) => {
await loginPage.login('[email protected]', 'password');
await dashboardPage.expectLoaded();
});Forgetting await before calling use() or failing to invoke use() prevents Playwright from handing control to the test body, hanging the execution until worker timeouts trigger.
// ❌ WRONG: Missing await before use()
myFixture: async ({ page }, use) => {
const service = new MyService(page);
use(service); // Error: does not await test execution
}
// ✅ CORRECT: Await use() completion
myFixture: async ({ page }, use) => {
const service = new MyService(page);
await use(service); // Suspends execution until test finishes
}Action methods (such as fillForm()) should not embed specific scenario assertions. If an input action method asserts a successful redirect, it cannot be reused in negative validation tests (e.g., verifying invalid email formats).
Define CustomFixtures interface enumerating injected Page Objects, contexts, and domain entities.
Executes before test: instantiates Page Objects, makes backend API calls, or seeds test data.
Yields execution to the test body. The fixture function pauses while the test executes.
The test invokes methods on injected fixtures without touching raw DOM elements.
Code following await use() executes reliably even on test failure: deletes records and releases resources.