< Back

Page Object Model in Playwright: A Complete Guide

Learn how to implement the Page Object Model pattern in Playwright to create maintainable, scalable test automation frameworks.

Playwright is a modern, open-source framework for web automation developed by Microsoft. It supports multiple browsers and programming languages, making it a popular choice for end-to-end testing. With features like auto-waiting and built-in retries, Playwright helps you write stable, cross-browser tests.

What is the Page Object Model (POM)?

Page Object Model (POM) is a test automation design pattern that encapsulates web elements and page-specific operations into classes called "page objects." Instead of scattering selectors and interactions across test scripts, POM centralizes and abstracts them within dedicated classes. This structure offers several advantages:

1. Clarity

Tests read like a high-level scenario ("log in," "create issue," "verify result") rather than step-by-step UI actions.

2. Maintainability

When a locator or workflow changes, you update it in one place (the page object) instead of every test.

3. Reusability

Common actions, such as filling a login form, can be reused by multiple test cases without rewriting locators.

Why Use POM in End-to-End Testing?

Why Use POM in End-to-End Testing?

1. Cleaner Tests

By calling page object methods (e.g., loginPage.login()) rather than handling selectors in each test, your tests remain short and descriptive.

2. Reduced Duplication

You encapsulate repeated UI flows—like login or form submissions—so you only need to write them once.

3. Scalability

As your application grows, adding new page objects is straightforward. Large test suites become easier to navigate and maintain.

Overall, POM makes your test code more robust against UI changes and easier to reason about. This pattern directly addresses one of the biggest challenges faced by test automation engineers - maintaining brittle test scripts that break when the UI changes.

Implementing POM in Playwright (With Jira as an Example)

Below is a simplified structure for a Jira-like scenario, demonstrating how you might model three pages: LoginPage, DashboardPage, and IssuePage. We assume basic familiarity with TypeScript/JavaScript.

Project Structure

tests/
  createIssue.spec.ts
pages/
  jiraLoginPage.ts
  jiraDashboardPage.ts
  jiraIssuePage.ts

jiraLoginPage.ts

import { Page, Locator } from '@playwright/test';
 
export class JiraLoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.locator('#login-form-username');
    this.passwordInput = page.locator('#login-form-password');
    this.loginButton   = page.locator('#login');
  }
 
  async goto() {
    await this.page.goto('<https://your-jira-instance.com/login>');
  }
 
  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await Promise.all([
      this.page.waitForNavigation(),
      this.loginButton.click()
    ]);
  }
}

jiraDashboardPage.ts

import { Page, Locator } from '@playwright/test';
import { JiraIssuePage } from './jiraIssuePage';
 
export class JiraDashboardPage {
  readonly page: Page;
  readonly createIssueButton: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.createIssueButton = page.locator('button:has-text("Create")');
  }
 
  async waitForPageLoad() {
    await this.page.waitForSelector('header:has-text("Dashboard")');
  }
 
  async openCreateIssueModal() {
    await this.createIssueButton.click();
    await this.page.waitForSelector('#create-issue-dialog', { state: 'visible' });
    return new JiraIssuePage(this.page);
  }
}

jiraIssuePage.ts

import { Page, Locator } from '@playwright/test';
 
export class JiraIssuePage {
  readonly page: Page;
  readonly projectField: Locator;
  readonly issueTypeField: Locator;
  readonly summaryField: Locator;
  readonly descriptionField: Locator;
  readonly submitButton: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.projectField = page.locator('#project-field');
    this.issueTypeField = page.locator('#issuetype-field');
    this.summaryField = page.locator('#summary');
    this.descriptionField = page.locator('#description');
    this.submitButton = page.locator('#create-issue-submit');
  }
 
  async createIssue(project: string, issueType: string, summary: string, description: string) {
    await this.projectField.fill(project);
    await this.page.keyboard.press('Enter');
    await this.issueTypeField.fill(issueType);
    await this.page.keyboard.press('Enter');
    await this.summaryField.fill(summary);
    await this.descriptionField.fill(description);
 
    await Promise.all([
      this.page.waitForSelector('text="Issue has been successfully created"'),
      this.submitButton.click()
    ]);
  }
}

Best Practices for Structuring Page Object Classes

1. Dedicated Classes

Create one class per page or component. This keeps your code organized and each class focused.

2. Centralized Locators

Define locators at the top of your class, clearly named to reflect each element's purpose.

3. Granular Methods

Page methods should correspond to a meaningful user action (e.g., loginPage.login() rather than many smaller steps).

4. Avoid Business Logic

Keep page objects focused on UI interactions. Actual assertions or complicated logic should reside in your test files or separate utility layers.

5. Optional Base Page

A base class can hold common helpers and reduce duplication in multiple pages.

For more details on organizing your tests effectively, check out our guide on Test Grouping in Playwright, which complements the Page Object Model approach.


Handling Complex Interactions

Modern web apps often include dynamic elements, AJAX updates, and modals. POM works well with Playwright's built-in waiting features:

  • Auto-Waiting: Playwright's Locator methods (like .fill(), .click()) automatically wait for elements to be visible and enabled.
  • Explicit Waits: If pages load data asynchronously, use page.waitForSelector() or waitForResponse() in your page object methods to ensure elements are ready.
  • Conditional Elements: For things like tooltips or dropdowns that appear on hover, encapsulate the hover and wait steps in one method.

By placing these waits in your page objects, your test files remain simple while still handling complex UI scenarios reliably. This approach can help reduce test flakiness, one of the most frustrating aspects of test automation.

Example Test Using POM (Jira Workflow)

Below is how a test might look, bringing the page objects together:

import { test, expect } from '@playwright/test';
import { JiraLoginPage } from '../pages/jiraLoginPage';
import { JiraDashboardPage } from '../pages/jiraDashboardPage';
 
test('Create a new Jira issue', async ({ page }) => {
  const loginPage = new JiraLoginPage(page);
  const dashboardPage = new JiraDashboardPage(page);
 
  // Step 1: Login
  await loginPage.goto();
  await loginPage.login('testuser', 'testpassword');
 
  // Step 2: Navigate to Dashboard
  await dashboardPage.waitForPageLoad();
 
  // Step 3: Create Issue
  const issuePage = await dashboardPage.openCreateIssueModal();
  await issuePage.createIssue(
    'MyProject',
    'Task',
    'POM Test Issue',
    'Created by an automated test using POM.'
  );
 
  // Step 4: Verify success
  await expect(page.locator('text="Issue has been successfully created"')).toBeVisible();
});

In this test:

  • We instantiate the JiraLoginPage and JiraDashboardPage objects with the same Playwright page.
  • Each page object method abstracts the necessary selectors and interactions.
  • Assertions remain in the test layer.

Alternatives to the POM Approach

which design pattern should be used for test automation?

Although POM is a widely adopted pattern, there are a few notable alternatives and variations you may consider:

1. Screenplay Pattern

The Screenplay Pattern models user interactions as "tasks" and "interactions." Instead of classes for each page, you define "actors" performing tasks like Navigate or EnterCredentials. This shifts focus from pages to user actions, often improving test readability. However, it can be more complex to set up.

2. Component/Widget Objects

Some teams prefer smaller "widget" objects for specific components—like a date picker or search box—and reuse them across pages. This is somewhat of an extension of POM but more granular. Instead of large page files, you focus on reusable UI fragments. It still shares the same principles of encapsulation but organizes your code differently.

3. Direct Locator-Based Tests

You could write tests directly with locators and no abstraction, especially for small or short-lived projects. This can be faster to implement initially, but typically becomes hard to maintain at scale. It's most suitable for tiny projects or proof-of-concept tests where longevity isn't a concern.

4. BDD with Gherkin

Behavior-driven development (BDD) tools like Cucumber let you write human-readable scenarios in Gherkin and map steps to code. While still possible to incorporate POM inside step definitions, some teams build minimal or no "page object" layers and rely heavily on step definitions. This can be effective for collaboration but may mix business logic and UI details if not carefully managed.

Whether you stick to POM or opt for alternatives can depend on project size, team preference, and code maintenance requirements. Many advanced teams even combine patterns, for instance, by using smaller component objects within a broader POM approach, or layering Screenplay tasks on top of page objects.

For teams exploring modern testing approaches, AI-powered testing tools can complement these patterns by helping to generate and maintain tests, especially when combined with session recording for E2E testing.

Common Mistakes and How to Avoid Them

1. Mixing Assertions in Page Objects

Keep verifications in the test layer. Page objects are for actions, while tests handle pass/fail checks.

2. Duplicate Locators

Avoid repeating selectors across multiple pages. If you have a common navigation bar, make a shared component or base page.

3. Overly Large Methods

Don't cram too many steps into a single method. Each should represent one meaningful user action.

4. Bypassing the POM

Writing locators directly in the test defeats the purpose. Make sure all UI interactions live in page objects.

5. Ignoring Dynamic Waits

If a page uses AJAX or dynamic content, incorporate explicit waits (or rely on Playwright's built-in locator waits) to prevent flaky tests.

Final Thoughts

Page Object Model is a proven method for organizing tests in Playwright. By separating UI selectors and actions into page objects, you gain significant advantages in code readability, maintainability, and scalability. Combined with Playwright's powerful waiting features, POM-based tests become clear, robust, and efficient. In most scenarios, POM strikes a great balance between simplicity and maintainability. If you're building long-lived end-to-end tests in Playwright—especially for larger applications like Jira—adopting a well-structured POM approach will likely pay off in the long run.

For additional insights on test maintenance challenges and solutions, explore our article on Test Automation Challenges Engineers Face, which addresses common pain points in maintaining automated test suites.

Ready to improve your Playwright testing approach? Consider exploring Posium's AI-powered testing tools, which can help automatically generate tests while adhering to patterns like Page Object Model.

Written by

Naomi Chopra