
Page Object Model: Design Pattern for Maintainable Test Automation
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.
Table Of Contents-
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
#usernamechanges 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.jsBase 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 onlyUse 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?