15 Days of Playwright - Day 8: Page Object Model

Page Object Model is a design pattern that helps organize test code by creating classes for each page, encapsulating elements and actions in one place.

15 Days of Playwright - Day 8: Page Object Model
Jhonatas Matos

Jhonatas Matos

Page Object Model is a design pattern that helps organize test code by creating classes for each page, encapsulating elements and actions in one place. This approach reduces code duplication and makes tests easier to maintain when the UI changes.

Today, I will explore how to implement POM with Playwright, covering basic patterns, best practices, and real-world examples.

Understanding Page Object Model

Page Object Model creates a separation between test code and page implementation. Each page gets its own class that contains all elements and actions. According to Playwright's documentation, this pattern "encapsulates page details away from tests."

Basic Page Object Structure

A Page Object is essentially a class that represents a page in your application. It encapsulates all the elements and actions that can be performed on that page. Here's what each part does:

// The class represents a specific page in your application
class LoginPage {
  // Constructor receives the page instance and defines all element locators
  constructor(page) {
    this.page = page;
    this.usernameInput = page.locator('#user-name');
    this.passwordInput = page.locator('#password');
    this.loginButton = page.locator('#login-button');
  }

  // Methods represent actions users can perform on this page
  async navigate() {
    await this.page.goto('https://www.saucedemo.com/');
  }

  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

Key components:

  • Constructor: Defines all element locators for the page
  • Methods: Represent user actions (navigation, form submissions, etc.)
  • Encapsulation: All page-specific code lives in one place

This structure keeps your test code clean and separates the "what" (test logic) from the "how" (page implementation).

Using Page Objects in Tests

Once you've created your page objects, using them in tests is straightforward. The page object handles all the interaction details, making your tests cleaner and more focused on the test logic itself.

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('Successful login using POM', async ({ page }) => {
  // Create an instance of the LoginPage
  const loginPage = new LoginPage(page);
  
  // Use the page object methods instead of raw selectors
  await loginPage.navigate();
  await loginPage.login('standard_user', 'secret_sauce');
  
  // Assertions remain in the test
  await expect(page.locator('.title')).toHaveText('Products');
});

What this achieves:

  • Clean Tests: No more duplicated selectors across multiple test files
  • Maintainability: UI changes only require updates in one place (the page object)
  • Readability: Tests focus on the "what" rather than the "how"
  • Reusability: The same login method can be used across all tests

This separation makes your test suite more scalable and easier to maintain as your application grows.

Component Objects

For reusable UI components, create separate component classes. Playwright recommends this approach for complex applications with shared UI elements.

Header Component

class HeaderComponent {
  constructor(page) {
    this.page = page;
    this.cartIcon = page.locator('.shopping_cart_link');
    this.cartBadge = page.locator('.shopping_cart_badge');
  }

  async getCartItemCount() {
    return await this.cartBadge.textContent();
  }

  async goToCart() {
    await this.cartIcon.click();
  }
}

Using Components in Page Objects

class InventoryPage {
  constructor(page) {
    this.page = page;
    this.header = new HeaderComponent(page);
    this.productItems = page.locator('.inventory_item');
  }

  async addItemToCart(itemIndex) {
    await this.productItems.nth(itemIndex)
      .locator('.btn_inventory').click();
  }
}

Advanced Patterns

Base Page Class

As your test suite grows, you might find common functionality used across multiple pages. Instead of duplicating code, you can create a base page class that provides shared methods.

// Base class contains common functionality used by all pages
class BasePage {
  constructor(page) {
    this.page = page;
  }

  // Shared utility methods
  async waitForTimeout(delay) {
    await this.page.waitForTimeout(delay);
  }

  async getTitle() {
    return await this.page.title();
  }
}

// Other pages extend the base class to inherit common functionality
class LoginPage extends BasePage {
  constructor(page) {
    super(page); // Call parent constructor
    this.usernameInput = page.locator('#user-name');
    this.passwordInput = page.locator('#password');
  }
  
  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.page.locator('#login-button').click();
    await this.waitForTimeout(1000); // Using inherited method
  }
}

When to use inheritance:

  • When multiple pages share common functionality
  • For utility methods used across different page types
  • To enforce consistent patterns across all pages

Consider composition over inheritance for more complex scenarios, as it provides better flexibility.

Real-World Example

Complete Test Flow with POM

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { InventoryPage } from '../pages/inventory-page';
import { CartPage } from '../pages/cart-page';

test('Complete purchase flow with POM', async ({ page }) => {
  // Login
  const loginPage = new LoginPage(page);
  await loginPage.navigate();
  await loginPage.login('standard_user', 'secret_sauce');

  // Add items to cart
  const inventoryPage = new InventoryPage(page);
  await inventoryPage.addItemToCart(0);
  await inventoryPage.addItemToCart(1);
  
  // Verify cart count
  const cartCount = await inventoryPage.header.getCartItemCount();
  expect(cartCount).toBe('2');

  // Go to cart
  await inventoryPage.header.goToCart();
  
  // Verify cart items
  const cartPage = new CartPage(page);
  const itemNames = await cartPage.getItemNames();
  expect(itemNames).toHaveLength(2);
});

Best Practices

Based on Playwright's official recommendations, here are the key practices:

CategoryBest Practice (Do)Anti-Pattern (Avoid)Why It Matters
Page Object SizeCreate small, focused page objectsOverly large page objectsSmaller classes are easier to maintain and understand
Method DesignUse descriptive method namesVague or technical method namesClear names make tests more readable and intentional
Method ReturnsReturn values from page object methodsVoid methods with side effectsReturning values enables better assertions and test flexibility
ResponsibilityKeep test logic out of page objectsTest assertions in page objectsSeparation of concerns makes both tests and page objects more maintainable
ArchitecturePrefer compositionComplex inheritance hierarchiesComposition is more flexible and easier to understand than deep inheritance
Business LogicUI interactions onlyBusiness logic in page objectsPage objects should handle how to interact, not what the business rules are

Project Structure

Maintaining a clear folder structure is crucial for scalable test suites. Here's a recommended organization:

tests/
  pages/           // All page objects
    login-page.js
    inventory-page.js  
    cart-page.js
    components/    // Reusable UI components
      header-component.js
      modal-component.js
  specs/           // Test files
    login.spec.js
    cart.spec.js

Why this structure works:

  • Separation of concerns: Page objects separated from test specs
  • Easy navigation: Clear where to find page vs test code
  • Scalability: Easy to add new pages/components
  • Maintainability: Related files are grouped together

This structure helps teams collaborate effectively and keeps your codebase organized as it grows.

Pratical Example

Login Page Implementation

class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameInput = page.locator('#user-name');
    this.passwordInput = page.locator('#password');
    this.loginButton = page.locator('#login-button');
    this.errorMessage = page.locator('[data-test="error"]');
  }

  async navigate() {
    await this.page.goto('https://www.saucedemo.com/');
  }

  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async getErrorMessage() {
    return await this.errorMessage.textContent();
  }
}

module.exports = { LoginPage };

Test Using Login Page

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test.describe('Login Tests with POM', () => {
  test('Successful login', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login('standard_user', 'secret_sauce');
    await expect(page.locator('.title')).toHaveText('Products');
  });

  test('Failed login shows error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login('invalid_user', 'wrong_password');
    
    const errorMessage = await loginPage.getErrorMessage();
    expect(errorMessage).toContain('Username and password do not match');
  });
});

See the complete Page Object Model implementation:

Link to GitHub projectGitHub Octocat

Conclusion

Page Object Model provides a structured approach to test organization in Playwright. By creating classes for each page and component, you can reduce code duplication, improve maintainability, and create cleaner test code.

Key benefits include:

  • Reduced code duplication through centralized selectors
  • Improved test readability with meaningful method names
  • Easier maintenance with single responsibility classes
  • Better collaboration through consistent patterns

With POM, your test suite becomes more scalable and easier to maintain as your application grows.

Official Documentation: For more advanced patterns and detailed examples, refer to the Playwright Page Object Model documentation.


Thank you for reading and see you in the next lesson! ☕💜