Table of Contents

As test automation suites grow, maintaining them becomes increasingly challenging. Tests become tightly coupled to UI changes, code duplication creeps in, and a single locator update requires edits across dozens of files. The Page Object Model (POM) is a widely adopted design pattern that solves exactly this problem and when combined with Playwright, it creates a powerful foundation for scalable, readable, and maintainable test automation. 

In this blog, we will explore what POM is, why it pairs exceptionally well with Playwright, and how to implement it with clean, production-ready code examples.

What is the Page Object Model? 

The Page Object Model is a design pattern where each web page (or significant component) in your application is represented as a class. This class encapsulates all the locators and actions specific to that page. Tests then interact with these classes rather than directly with the browser – making tests cleaner and decoupled from the UI implementation. 

The core benefits of POM are: 

  • Reusability – locators and actions are defined once and reused across tests. 
  • Maintainability – when a UI element changes, you update only the Page Object class. 
  • Readability – test files read like plain English, focused on business logic. 
  • Separation of concerns – test logic is cleanly separated from page interaction logic. 

Why Playwright + POM is a Powerful Combination 

Playwright is a modern automation framework from Microsoft that supports Chromium, Firefox, and WebKit out of the box. It offers built-in auto-waiting, network interception, and a rich API – all of which integrate seamlessly with the POM pattern. 

Here is why they work so well together: 

  • Playwright’s Page class is passed directly into Page Objects, keeping the design clean. 
  • Auto-waiting eliminates flaky tests, reducing the need for explicit waits inside Page Objects. 
  • TypeScript support (first-class in Playwright) enables type-safe Page Object classes. 
  • Playwright fixtures allow Page Objects to be injected into tests elegantly. 

Setting Up the Project Structure 

A clean folder structure is the backbone of a maintainable POM project. Here is a recommended layout: 

project-root/ 

├── pages/              ← Page Object classes 

│   ├── LoginPage.ts 

│   ├── DashboardPage.ts 

│   └── components/     ← Shared UI components 

│       └── Header.ts 

├── tests/              ← Actual test files 

│   └── login.spec.ts 

├── utils/              ← Helpers & fixtures 

│   └── fixtures.ts 

└── playwright.config.ts 

Writing Your First Page Object 

Let us create a Page Object for a login page. Notice how the class encapsulates all locators and exposes meaningful action methods: 

// pages/LoginPage.ts 

import { Page } from ‘@playwright/test’; 

export class LoginPage { 

  private page: Page; 

  constructor(page: Page) { 

    this.page = page; 

  } 

  async navigate() { 

    await this.page.goto(‘/login’); 

  } 

  async login(username: string, password: string) { 

    await this.page.fill(‘#username’, username); 

    await this.page.fill(‘#password’, password); 

    await this.page.click(‘button[type=”submit”]’); 

  } 

  async getErrorMessage(): Promise<string> { 

    return await this.page.textContent(‘.error-msg’) ?? ”; 

  } 

} 

Using Page Objects in Tests 

Now see how clean and readable the test file becomes when using the Page Object: 

// tests/login.spec.ts 

import { test, expect } from ‘@playwright/test’; 

import { LoginPage } from ‘../pages/LoginPage’; 

test(‘successful login navigates to dashboard’, async ({ page }) => { 

  const loginPage = new LoginPage(page); 

  await loginPage.navigate(); 

  await loginPage.login(‘admin@example.com’, ‘password123’); 

  await expect(page).toHaveURL(‘/dashboard’); 

}); 

test(‘invalid credentials show error message’, async ({ page }) => { 

  const loginPage = new LoginPage(page); 

  await loginPage.navigate(); 

  await loginPage.login(‘wrong@user.com’, ‘wrongpass’); 

  const error = await loginPage.getErrorMessage(); 

  expect(error).toContain(‘Invalid credentials’); 

}); 

The test reads almost like a plain-English user story. There is no mention of selectors or low-level browser interactions – all of that is abstracted inside the Page Object. 

Handling Reusable Components 

Most applications have shared UI elements like navigation bars, headers, and footers that appear across multiple pages. Instead of duplicating their locators in every Page Object, create dedicated component classes: 

// pages/components/Header.ts 

import { Page } from ‘@playwright/test’; 

export class Header { 

  constructor(private page: Page) {} 

  async logout() { 

    await this.page.click(‘[data-testid=”user-menu”]’); 

    await this.page.click(‘text=Logout’); 

  } 

  async getLoggedInUser(): Promise<string> { 

    return await this.page.textContent(‘.username-display’) ?? ”; 

  } 

} 

Page Objects can then compose these components, keeping responsibilities well-separated and the code DRY (Don’t Repeat Yourself). 

Best Practices

Do’s 

  • Keep Page Objects focused on one page or component. 
  • Use descriptive method names that reflect user actions (e.g., login(), submitForm(), selectProduct()). 
  • Return new Page Objects when actions navigate to a different page. 
  • Use data-testid attributes for locators – they are resilient to styling and structural changes. 
  • Leverage Playwright’s built-in locators (getByRole, getByLabel) for accessibility-friendly selectors. 

Don’ts 

  • Never put assertions inside Page Objects -keep assertions in test files. 
  • Avoid hardcoding test data inside Page Objects; pass it as parameters. 
  • Do not create a single monolithic Page Object for the entire application. 
  • Avoid mixing navigation logic and assertion logic in the same method. 

Conclusion 

The Page Object Model is not just a good-to-have pattern-it is essential for any automation suite that needs to scale. When paired with Playwright’s robust API and TypeScript’s type safety, POM transforms test code from a fragile collection of selectors into a clean, maintainable architecture that mirrors how real users interact with an application. 

Whether you are migrating from Selenium or starting fresh with Playwright, adopting POM from day one will save your team hours of maintenance work down the line. Start small – create a Page Object for your most-tested page – and expand from there. 

Happy testing!