What Is the Page Object Model?
The Page Object Model (POM) is the most widely used design pattern in UI test automation. It creates a class for each page or component of your application, encapsulating all interactions with that page.
Without POM — selectors and actions are scattered across tests:
test('user can login', async ({ page }) => {
await page.fill('#email', 'admin@test.com');
await page.fill('#password', 'secret');
await page.click('button.login-btn');
await expect(page.locator('.welcome-msg')).toHaveText('Welcome, Admin');
});
With POM — page details are encapsulated:
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('admin@test.com', 'secret');
const dashboard = new DashboardPage(page);
await expect(dashboard.welcomeMessage).toHaveText('Welcome, Admin');
});
Core Principles
1. One Class Per Page (or Component)
Each page of your application gets its own class:
pages/
LoginPage.ts
DashboardPage.ts
ProductListPage.ts
ProductDetailPage.ts
CheckoutPage.ts
CartPage.ts
2. Selectors Are Private
Element selectors are implementation details that should not leak into tests:
class LoginPage {
// Private — only used inside this class
#emailInput = '[data-testid="email"]';
#passwordInput = '[data-testid="password"]';
#submitBtn = '[data-testid="login-submit"]';
#errorMessage = '.login-error';
constructor(page) {
this.page = page;
}
}
3. Public Methods Represent User Actions
Methods should be named after what the user does, not what the code does:
class LoginPage {
// Good — user-centric method names
async login(email, password) { /* ... */ }
async forgotPassword(email) { /* ... */ }
async loginWithGoogle() { /* ... */ }
// Bad — implementation-centric names
async fillEmailAndClickSubmit() { /* ... */ }
async clickGoogleButton() { /* ... */ }
}
4. No Assertions in Page Objects
Page objects perform actions and return data. Tests make assertions:
// Page object — returns data, no assertions
class DashboardPage {
get welcomeMessage() {
return this.page.locator('.welcome-msg');
}
async getUserName() {
return await this.page.textContent('.user-name');
}
async getNotificationCount() {
const text = await this.page.textContent('.notification-badge');
return parseInt(text);
}
}
// Test — makes assertions
test('dashboard shows user name', async ({ page }) => {
const dashboard = new DashboardPage(page);
await expect(dashboard.welcomeMessage).toContainText('Admin');
const count = await dashboard.getNotificationCount();
expect(count).toBeGreaterThan(0);
});
Complete Page Object Example
// pages/LoginPage.ts
export class LoginPage {
#emailInput = '[data-testid="email"]';
#passwordInput = '[data-testid="password"]';
#submitBtn = '[data-testid="login-submit"]';
#errorMessage = '[data-testid="login-error"]';
#googleOAuth = '[data-testid="google-login"]';
constructor(page) {
this.page = page;
}
async goto() {
await this.page.goto('/login');
}
async login(email, password) {
await this.page.fill(this.#emailInput, email);
await this.page.fill(this.#passwordInput, password);
await this.page.click(this.#submitBtn);
}
async loginWithGoogle() {
await this.page.click(this.#googleOAuth);
}
get errorMessage() {
return this.page.locator(this.#errorMessage);
}
async isLoaded() {
await this.page.waitForSelector(this.#emailInput);
}
}
Method Chaining
Method chaining allows fluent test syntax by returning page objects from navigation methods:
class LoginPage {
async login(email, password) {
await this.page.fill(this.#emailInput, email);
await this.page.fill(this.#passwordInput, password);
await this.page.click(this.#submitBtn);
return new DashboardPage(this.page); // Return next page
}
}
class DashboardPage {
async navigateToProfile() {
await this.page.click('#profile-link');
return new ProfilePage(this.page);
}
}
// Fluent test
test('user navigates to profile', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
const dashboard = await loginPage.login('admin@test.com', 'secret');
const profile = await dashboard.navigateToProfile();
await expect(profile.nameField).toHaveValue('Admin User');
});
Component Objects
For UI elements that appear on multiple pages (navigation bar, footer, modals), create component objects:
// components/NavBar.ts
class NavBar {
constructor(page) {
this.page = page;
}
async searchFor(query) {
await this.page.fill('#search', query);
await this.page.press('#search', 'Enter');
}
async navigateTo(section) {
await this.page.click(`nav a[href="/${section}"]`);
}
async logout() {
await this.page.click('#user-menu');
await this.page.click('#logout');
}
}
// Compose into page objects
class DashboardPage {
constructor(page) {
this.page = page;
this.navBar = new NavBar(page); // Composition
}
}
Advanced POM Patterns
Base Page Class
class BasePage {
constructor(page) {
this.page = page;
this.navBar = new NavBar(page);
this.footer = new Footer(page);
}
async goto(path) {
await this.page.goto(path);
await this.waitForPageLoad();
}
async waitForPageLoad() {
await this.page.waitForLoadState('domcontentloaded');
}
async getPageTitle() {
return await this.page.title();
}
async takeScreenshot(name) {
await this.page.screenshot({ path: `screenshots/${name}.png` });
}
}
Handling Dynamic Elements
class ProductListPage extends BasePage {
// Dynamic selector based on product name
productCard(name) {
return this.page.locator(`.product-card:has-text("${name}")`);
}
async addToCart(productName) {
const card = this.productCard(productName);
await card.locator('.add-to-cart-btn').click();
}
async getPrice(productName) {
const card = this.productCard(productName);
const priceText = await card.locator('.price').textContent();
return parseFloat(priceText.replace('$', ''));
}
async getProductCount() {
return await this.page.locator('.product-card').count();
}
}
Handling Waits and Loading States
class CheckoutPage extends BasePage {
async submitPayment(cardNumber) {
await this.page.fill('#card-number', cardNumber);
await this.page.fill('#expiry', '12/28');
await this.page.fill('#cvv', '123');
// Wait for the payment to process
const [response] = await Promise.all([
this.page.waitForResponse(resp =>
resp.url().includes('/api/payment') && resp.status() === 200
),
this.page.click('#pay-button')
]);
return new OrderConfirmationPage(this.page);
}
}
POM with Playwright Fixtures
Playwright supports POM natively through fixtures:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
}
});
// test file
test('user sees dashboard', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('admin@test.com', 'secret');
await expect(dashboardPage.welcomeMessage).toBeVisible();
});
POM Anti-Patterns
1. God Page Object
A page object with 50+ methods is too large. Split into smaller, focused objects.
2. Assertions in Page Objects
Keep assertions in tests. Page objects return data; tests verify it.
3. Exposing Selectors
Tests should never reference selectors directly. If they do, the encapsulation is broken.
4. Shared Mutable State
Page objects should not store test-specific state. Each test creates fresh page object instances.
Exercise: Build a Complete POM
Create page objects for an e-commerce checkout flow:
LoginPage— goto(), login(), loginWithGoogle(), errorMessageProductListPage— searchProduct(), filterByCategory(), addToCart(), getProductCount()CartPage— getItems(), updateQuantity(), removeItem(), getTotal(), proceedToCheckout()CheckoutPage— enterShipping(), enterPayment(), placeOrder()OrderConfirmationPage— getOrderNumber(), getTotal(), getEstimatedDelivery()NavBar(component) — search(), navigateTo(), cartCount(), logout()
Write 3 test cases that use these page objects.
Key Takeaways
- POM separates page details from test logic — one update point per UI change
- Keep selectors private, expose user-action methods
- No assertions in page objects — return data, let tests assert
- Use component objects for shared UI elements (nav, footer, modals)
- Method chaining makes tests fluent and readable
- Playwright fixtures integrate naturally with POM