6

๐Ÿงน Cleaner Tests with Page Object Model (POM) in Playwright

Improve Playwright test readability and maintainability by implementing the Page Object Model (POM) design pattern. Learn with a practical TypeScript example.

Introduction

As your Playwright test suite grows, you might find yourself repeating the same selectors and sequences of actions across multiple tests. This can lead to tests that are hard to read and even harder to maintain. If a UI element changes, you could end up updating dozens of files!

That's where the Page Object Model (POM) design pattern comes to the rescue. Let's break down what it is and how to implement a basic POM in your Playwright TypeScript projects for cleaner, more maintainable tests.

What is the Page Object Model (POM)?

The Page Object Model is a design pattern where you create an object (a class) for each page (or significant component) of your web application. This class encapsulates:

  1. Locators: Definitions of the elements on that page.
  2. Methods: Functions that represent user interactions with those elements (e.g., login(username, password), navigateToProductDetails(), searchForItem(term)).

Your actual test scripts then use these Page Object methods instead of interacting directly with page.locator() or page.getByRole() calls.

Why Bother with POM? The Benefits!

  • Readability: Your tests become much easier to read and understand because they describe user journeys at a higher level (e.g., loginPage.loginAsUser('test', 'password'); is clearer than a series of fill and click commands).
  • Maintainability: If a UI element's locator changes, you only need to update it in one place โ€“ the relevant Page Object class. No more hunting through countless test files!
  • Reusability: Common actions and locators are defined once and can be reused across many tests.
  • Reduced Duplication: Say goodbye to copying and pasting locators and interactions.
  • Clear Separation of Concerns: Test logic (what to test, assertions) is separated from page interaction logic (how to interact with elements).

Let's Build a Basic POM: A Login Page Example

Imagine we have a simple login page with an email field, a password field, and a login button.

1. Create a Page Object Directory: It's good practice to keep your Page Objects organized. Create a new directory, for example, page-objects (or poms, pages, etc.) in your project root or alongside your tests folder.

my-playwright-project/
โ”œโ”€โ”€ page-objects/
โ”‚   โ””โ”€โ”€ LoginPage.ts
โ”œโ”€โ”€ tests/
โ”‚   โ””โ”€โ”€ login.spec.ts
โ”œโ”€โ”€ playwright.config.ts
โ””โ”€โ”€ package.json

2. Create the LoginPage.ts Page Object:

// page-objects/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';
 
export class LoginPage {
  // 'readonly' makes sure these properties are only assigned in the constructor
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator; // Optional: for displaying login errors
 
  constructor(page: Page) {
    this.page = page;
    // Using getByLabel for better accessibility and resilience
    this.emailInput = page.getByLabel('Email'); 
    this.passwordInput = page.getByLabel('Password');
    // Using getByRole for the button
    this.loginButton = page.getByRole('button', { name: 'Login' }); 
    // Example for an error message (you might use getByTestId or another locator)
    this.errorMessage = page.locator('.error-message'); 
  }
 
  async navigate() {
    await this.page.goto('/login'); // Assuming /login is your login page URL
  }
 
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
 
  async getErrorMessageText() {
    return this.errorMessage.textContent();
  }
}

Breakdown of LoginPage.ts:

  • We import Page and Locator types from Playwright.
  • The class LoginPage takes a Page object in its constructor. This page instance is passed from your test.
  • Locators: We define locators like emailInput, passwordInput, loginButton as readonly properties. We initialize them in the constructor using Playwright's recommended locators.
  • Methods:
    • navigate(): A helper to go to the login page.
    • login(email, password): This method encapsulates the actions of filling the email, filling the password, and clicking the login button. This is the core of the interaction logic.
    • getErrorMessageText(): An example of a method to retrieve information from the page.

3. Using the LoginPage in a Test (tests/login.spec.ts):

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects/LoginPage'; // Adjust path as needed
 
test.describe('Login Functionality', () => {
  let loginPage: LoginPage; // Declare loginPage here
 
  test.beforeEach(async ({ page }) => {
    // Initialize LoginPage before each test
    loginPage = new LoginPage(page);
    await loginPage.navigate();
  });
 
  test('should allow login with valid credentials', async ({ page }) => {
    await loginPage.login('testuser@example.com', 'Password123');
    // Add assertions here to verify successful login, e.g.:
    // await expect(page).toHaveURL('/dashboard');
    // await expect(page.getByRole('heading', { name: 'Welcome User' })).toBeVisible();
    console.log('Simulating successful login check...'); // Placeholder for actual assertions
  });
 
  test('should show error message with invalid credentials', async () => {
    await loginPage.login('invalid@example.com', 'wrongpassword');
    const errorMessage = await loginPage.getErrorMessageText();
    expect(errorMessage).toContain('Invalid username or password'); // Or whatever your error message is
  });
});

Breakdown of login.spec.ts:

  • We import our LoginPage.
  • In test.beforeEach, we create an instance of LoginPage and navigate to the login page. This setup runs before each test in the suite.
  • Test Cases: Notice how clean the test cases are!
    • await loginPage.login('testuser@example.com', 'Password123'); is much more descriptive than seeing individual fill and click calls here.
    • Assertions are still done in the test file, as the Page Object should primarily focus on interactions and element access, not assertions (though simple state-checking methods like isErrorMessageVisible() can sometimes be useful in a PO).

Key Takeaways for POM

  • One Page/Component, One Class: Keep it granular.
  • Pass the page Fixture: Your Page Objects need the page context from your tests.
  • Locators in the Constructor: Define your element locators once.
  • Methods for User Actions: Encapsulate sequences of interactions.
  • Tests Use PO Methods: Your tests become orchestrators of high-level user actions.

Is POM Always Necessary?

For very small projects with only a handful of tests, POM might feel like overkill. However, as soon as your project starts to grow, or if you anticipate it growing, adopting POM early will save you a massive amount of time and effort in the long run.

It's a foundational pattern for building scalable and maintainable test automation suites.

What's Next?

  • Start refactoring one of your existing test files to use a Page Object.
  • Think about how you can create Page Objects for other key areas of your application.
  • Explore advanced POM techniques like Base Pages or Component Objects for shared UI elements (like headers or footers).

Using POM will significantly level up your Playwright testing game!