Frameworks & Patterns
Page Object Model

Page Object Model: Design Pattern for Maintainable Test Automation

Parul Dhingra - Senior Quality Analyst
Parul Dhingra13+ Years ExperienceHire Me

Senior Quality Analyst

Updated: 1/23/2026

As test automation suites grow, they become increasingly difficult to maintain. The same selectors appear in multiple tests, UI changes break dozens of test files, and tests become hard to read. The Page Object Model (POM) solves these problems by creating an abstraction layer between tests and the UI.

This guide covers POM from basic concepts to advanced patterns, with examples in Selenium, Playwright, and Cypress.

What is Page Object Model?

Page Object Model is a design pattern that creates an object-oriented class for each page or component of your application. Each class contains:

  • Locators: Element selectors (private)
  • Methods: Actions users can perform (public)
  • State: Page-specific data when needed

The pattern separates "what" (test logic) from "how" (UI interaction):

┌─────────────────────────────────────────────┐
│                   Tests                      │
│   test("user can login", () => { ... })     │
└──────────────────────┬──────────────────────┘
                       │ Uses
┌──────────────────────▼──────────────────────┐
│               Page Objects                   │
│   LoginPage, DashboardPage, CartPage        │
└──────────────────────┬──────────────────────┘
                       │ Interacts with
┌──────────────────────▼──────────────────────┐
│              Application UI                  │
│   HTML elements, buttons, forms, etc.       │
└─────────────────────────────────────────────┘

Why Use Page Objects?

Without Page Objects

// Test 1: tests/login.test.js
test('login with valid credentials', async () => {
  await page.goto('/login');
  await page.fill('#username', 'user@example.com');
  await page.fill('#password', 'password123');
  await page.click('button[type="submit"]');
  await expect(page.locator('.dashboard-header')).toBeVisible();
});
 
// Test 2: tests/checkout.test.js
test('checkout requires login', async () => {
  await page.goto('/checkout');
  // Same selectors duplicated
  await page.fill('#username', 'user@example.com');
  await page.fill('#password', 'password123');
  await page.click('button[type="submit"]');
  // ...
});

Problems:

  • Duplicated selectors across tests
  • If #username changes to #email, update multiple files
  • Tests are hard to read
  • No reusable components

With Page Objects

// pages/LoginPage.js
class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameInput = page.locator('#username');
    this.passwordInput = page.locator('#password');
    this.submitButton = page.locator('button[type="submit"]');
  }
 
  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}
 
// tests/login.test.js
test('login with valid credentials', async () => {
  const loginPage = new LoginPage(page);
  await page.goto('/login');
  await loginPage.login('user@example.com', 'password123');
  await expect(page.locator('.dashboard-header')).toBeVisible();
});

Benefits:

  • Single source of truth for selectors
  • UI changes require updating one file
  • Tests are more readable
  • Reusable across test suites

The Page Object Model is recommended by Selenium, Playwright, and Cypress documentation as a best practice for organizing test code.

Basic Implementation

Structure

tests/
├── pages/
│   ├── BasePage.js
│   ├── LoginPage.js
│   ├── DashboardPage.js
│   └── components/
│       ├── HeaderComponent.js
│       └── ModalComponent.js
├── tests/
│   ├── login.test.js
│   └── dashboard.test.js
└── fixtures/
    └── testData.js

Base Page Class

// pages/BasePage.js
class BasePage {
  constructor(page) {
    this.page = page;
  }
 
  async navigate(path) {
    await this.page.goto(path);
  }
 
  async getTitle() {
    return await this.page.title();
  }
 
  async waitForLoad() {
    await this.page.waitForLoadState('networkidle');
  }
}
 
module.exports = BasePage;

Page Class Example

// pages/LoginPage.js
const BasePage = require('./BasePage');
 
class LoginPage extends BasePage {
  constructor(page) {
    super(page);
 
    // Locators
    this.emailInput = page.locator('[data-testid="email-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
    this.forgotPasswordLink = page.locator('text=Forgot password?');
  }
 
  // Actions
  async goto() {
    await this.navigate('/login');
  }
 
  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
 
  async getErrorMessage() {
    return await this.errorMessage.textContent();
  }
 
  async clickForgotPassword() {
    await this.forgotPasswordLink.click();
  }
 
  // State checks
  async isErrorVisible() {
    return await this.errorMessage.isVisible();
  }
}
 
module.exports = LoginPage;

Selenium Page Objects

Java Implementation

// pages/LoginPage.java
public class LoginPage {
    private WebDriver driver;
 
    // Locators using @FindBy
    @FindBy(id = "username")
    private WebElement usernameInput;
 
    @FindBy(id = "password")
    private WebElement passwordInput;
 
    @FindBy(css = "button[type='submit']")
    private WebElement submitButton;
 
    @FindBy(className = "error-message")
    private WebElement errorMessage;
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
        PageFactory.initElements(driver, this);
    }
 
    public void navigateTo() {
        driver.get("https://example.com/login");
    }
 
    public DashboardPage login(String username, String password) {
        usernameInput.sendKeys(username);
        passwordInput.sendKeys(password);
        submitButton.click();
        return new DashboardPage(driver);
    }
 
    public String getErrorMessage() {
        return errorMessage.getText();
    }
 
    public boolean isErrorDisplayed() {
        try {
            return errorMessage.isDisplayed();
        } catch (NoSuchElementException e) {
            return false;
        }
    }
}

Using in Tests

// tests/LoginTest.java
public class LoginTest {
    private WebDriver driver;
    private LoginPage loginPage;
 
    @BeforeEach
    public void setup() {
        driver = new ChromeDriver();
        loginPage = new LoginPage(driver);
    }
 
    @Test
    public void testSuccessfulLogin() {
        loginPage.navigateTo();
        DashboardPage dashboard = loginPage.login("user@test.com", "password");
 
        assertTrue(dashboard.isWelcomeMessageDisplayed());
    }
 
    @Test
    public void testInvalidCredentials() {
        loginPage.navigateTo();
        loginPage.login("wrong@test.com", "wrongpassword");
 
        assertTrue(loginPage.isErrorDisplayed());
        assertEquals("Invalid credentials", loginPage.getErrorMessage());
    }
 
    @AfterEach
    public void teardown() {
        driver.quit();
    }
}

Playwright Page Objects

TypeScript Implementation

// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
 
export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.loginButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }
 
  async goto() {
    await this.page.goto('/login');
  }
 
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
 
  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

Playwright Fixtures for Page Objects

// fixtures/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
 
type Pages = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};
 
export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});
 
export { expect } from '@playwright/test';

Using Fixtures

// tests/login.spec.ts
import { test, expect } from '../fixtures/fixtures';
 
test.describe('Login', () => {
  test('successful login redirects to dashboard', async ({ loginPage, dashboardPage }) => {
    await loginPage.goto();
    await loginPage.login('user@example.com', 'password123');
 
    await expect(dashboardPage.welcomeMessage).toBeVisible();
  });
 
  test('invalid credentials show error', async ({ loginPage }) => {
    await loginPage.goto();
    await loginPage.login('wrong@example.com', 'wrongpass');
 
    await loginPage.expectError('Invalid credentials');
  });
});

Cypress Page Objects

JavaScript Implementation

// cypress/pages/LoginPage.js
class LoginPage {
  elements = {
    emailInput: () => cy.get('[data-cy="email-input"]'),
    passwordInput: () => cy.get('[data-cy="password-input"]'),
    loginButton: () => cy.get('[data-cy="login-button"]'),
    errorMessage: () => cy.get('[data-cy="error-message"]'),
  };
 
  visit() {
    cy.visit('/login');
  }
 
  typeEmail(email) {
    this.elements.emailInput().clear().type(email);
  }
 
  typePassword(password) {
    this.elements.passwordInput().clear().type(password);
  }
 
  clickLogin() {
    this.elements.loginButton().click();
  }
 
  login(email, password) {
    this.typeEmail(email);
    this.typePassword(password);
    this.clickLogin();
  }
 
  assertErrorMessage(message) {
    this.elements.errorMessage().should('contain.text', message);
  }
 
  assertErrorVisible() {
    this.elements.errorMessage().should('be.visible');
  }
}
 
export default new LoginPage();

Using in Cypress Tests

// cypress/e2e/login.cy.js
import LoginPage from '../pages/LoginPage';
 
describe('Login', () => {
  beforeEach(() => {
    LoginPage.visit();
  });
 
  it('logs in successfully with valid credentials', () => {
    LoginPage.login('user@example.com', 'password123');
 
    cy.url().should('include', '/dashboard');
    cy.get('.welcome-message').should('be.visible');
  });
 
  it('shows error for invalid credentials', () => {
    LoginPage.login('wrong@example.com', 'wrongpass');
 
    LoginPage.assertErrorVisible();
    LoginPage.assertErrorMessage('Invalid credentials');
  });
});

Advanced Patterns

Component Objects

For reusable UI components:

// pages/components/HeaderComponent.js
class HeaderComponent {
  constructor(page) {
    this.page = page;
    this.logo = page.locator('[data-testid="logo"]');
    this.searchInput = page.locator('[data-testid="search"]');
    this.userMenu = page.locator('[data-testid="user-menu"]');
    this.logoutButton = page.locator('[data-testid="logout"]');
  }
 
  async search(query) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
  }
 
  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }
}
 
// pages/DashboardPage.js
class DashboardPage {
  constructor(page) {
    this.page = page;
    this.header = new HeaderComponent(page);
    // ... other locators
  }
}

Page Transitions

Return new page objects when navigating:

class LoginPage {
  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
    return new DashboardPage(this.page);
  }
}
 
// Test
const dashboard = await loginPage.login('user@test.com', 'pass');
await dashboard.assertWelcomeVisible();

Factory Pattern

Create page objects dynamically:

// pages/PageFactory.js
class PageFactory {
  constructor(page) {
    this.page = page;
  }
 
  getLoginPage() {
    return new LoginPage(this.page);
  }
 
  getDashboardPage() {
    return new DashboardPage(this.page);
  }
 
  getCheckoutPage() {
    return new CheckoutPage(this.page);
  }
}

Common Mistakes

1. Assertions in Page Objects

// Bad - assertions belong in tests
class LoginPage {
  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
    await expect(this.page).toHaveURL('/dashboard'); // ❌
  }
}
 
// Good - return state, assert in tests
class LoginPage {
  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}
 
// Test
await loginPage.login(email, password);
await expect(page).toHaveURL('/dashboard'); // ✅

2. Exposing Locators Directly

// Bad - locators exposed
class LoginPage {
  usernameSelector = '#username'; // ❌
 
  async getUsername() {
    return this.page.locator(this.usernameSelector);
  }
}
 
// Good - encapsulate interactions
class LoginPage {
  #usernameInput = this.page.locator('#username'); // Private
 
  async fillUsername(username) {
    await this.#usernameInput.fill(username);
  }
}

3. Too Many Page Objects

// Bad - one page object per element
class LoginButtonComponent { }
class UsernameInputComponent { }
 
// Good - logical groupings
class LoginPage {
  // Contains all login-related elements
}

Best Practices

Use Meaningful Method Names

// Good - describes user action
await loginPage.submitCredentials(email, password);
await cartPage.proceedToCheckout();
await productPage.addToCart();
 
// Avoid - implementation details
await loginPage.clickSubmitButton();
await cartPage.clickCheckoutLink();

Keep Page Objects Focused

// Each page object handles one page/component
class LoginPage { }      // Login page only
class RegistrationPage { } // Registration only
class HeaderComponent { }  // Header only

Use Data-TestId Attributes

<!-- In your application -->
<input data-testid="email-input" type="email" />
<button data-testid="submit-button">Submit</button>
// In page objects
this.emailInput = page.locator('[data-testid="email-input"]');
this.submitButton = page.locator('[data-testid="submit-button"]');

Document Complex Interactions

/**
 * Completes the checkout process
 * @param {PaymentInfo} payment - Payment details
 * @returns {ConfirmationPage} - Returns confirmation page on success
 * @throws {Error} - If payment fails
 */
async completeCheckout(payment) {
  await this.enterPaymentDetails(payment);
  await this.confirmOrder();
  return new ConfirmationPage(this.page);
}

The Page Object Model transforms test automation from fragile scripts into maintainable, readable code. By encapsulating UI interactions behind clean interfaces, your tests become resilient to UI changes and easier to understand for the entire team.

Quiz on Page Object Model

Your Score: 0/10

Question: What is the primary purpose of the Page Object Model pattern?

Continue Reading

Frequently Asked Questions (FAQs) / People Also Ask (PAA)

Should I create one page object per page or per feature?

Can I use Page Object Model with API testing?

How do I handle dynamic elements in page objects?

Should page objects inherit from a base class?

How do I test pages that require login?

What's the difference between Page Object Model and Screenplay Pattern?

How do I organize page objects in a large project?

Should methods return this for method chaining?