Skip to main content

What is the Page Object Model?

The Page Object Model (POM) is a design pattern that creates an object repository for web UI elements. Instead of scattering selectors and actions throughout your tests, POM encapsulates them in dedicated classes.

Benefits of POM

Maintainability

UI changes only require updates in one place - the page object class.

Reusability

Multiple tests can use the same page methods without code duplication.

Readability

Tests read like business workflows rather than technical implementations.

Type Safety

TypeScript ensures you call the right methods with correct parameters.

Architecture Overview

This framework implements a three-layer POM architecture:
┌─────────────────────────────────┐
│      Test Specifications        │  ← High-level test logic
│   (LoginPageSpec.cy.ts)         │
└────────────┬────────────────────┘
             │ uses

┌─────────────────────────────────┐
│       Page Objects              │  ← Page-specific methods
│   (LoginPage.ts, HomePage.ts)   │
└────────────┬────────────────────┘
             │ extends

┌─────────────────────────────────┐
│        Base Page                │  ← Shared functionality
│      (BasePage.ts)               │
└─────────────────────────────────┘

Base Page Class

The BasePage class provides common functionality inherited by all page objects:
BasePage.ts
export default class BasePage {
    public inventoryItem: string

    constructor() {
        this.inventoryItem = '[data-test="inventory-item"]'
    }

    protected goToUrl(url: string): void {
        cy.visit(url)
    }
}

Why Use a Base Page?

  • Shared selectors: Common elements like inventoryItem available to all pages
  • Utility methods: Navigation, waiting, and other common operations
  • Consistency: All page objects follow the same structure
  • DRY principle: Don’t repeat yourself across multiple page objects
Use protected for methods that should only be called by page objects themselves, and public for methods that tests will call directly.

Page Object Implementation

Let’s examine the LoginPage class to understand the pattern:
LoginPage.ts
import BasePage from "./BasePage";

export default class LoginPage extends BasePage {
    // Private selectors - encapsulated from tests
    private usernameField: string
    private passwordField: string
    private loginBtnField: string
    private errorMsgAlert: string
    private closeErrorBtn: string
    private loginMainPage: string

    constructor() {
        super()  // Call BasePage constructor
        this.usernameField = '#user-name'
        this.passwordField = '#password'
        this.loginBtnField = '#login-button'
        this.errorMsgAlert = '[data-test="error"]'
        this.closeErrorBtn = '[data-test="error-button"]'
        this.loginMainPage = '.login_container'
    }

    // Public action methods
    public typeUsername(username: string) {
        cy.get(this.usernameField).type(username)
    }

    public typePassword(password: string) {
        cy.get(this.passwordField).type(password)
    }

    public clickOnLoginButton() {
        cy.get(this.loginBtnField).click({force: true})
    }

    // Public verification methods
    public verifyLoginMainPage() {
        cy.get(this.loginMainPage).should('be.visible')
    }

    public verifyErrorMessage(message: string) {
        cy.get(this.errorMsgAlert).should('have.text', message)
    }

    // Composite action - combines multiple steps
    public userLogin(username: string, password: string) {
        this.typeUsername(username)
        this.typePassword(password)
        this.clickOnLoginButton()
    }
}

Key Components

1

Selectors as Private Properties

Selectors are stored as private properties, hiding implementation details from tests. If the UI changes, you only update the selector here.
2

Public Action Methods

Methods like typeUsername() perform actions on the page. They provide a clean interface for tests.
3

Public Verification Methods

Methods like verifyLoginMainPage() assert expected states. They keep assertions readable and maintainable.
4

Composite Methods

Methods like userLogin() combine multiple actions for common workflows, reducing test code.

Using Page Objects in Tests

Here’s how page objects are used in actual test files:
LoginPageSpec.cy.ts
import LoginPage from "../../support/pages/LoginPage";
import HomePage from "../../support/pages/HomePage";

let loginPage: LoginPage
let homePage: HomePage

describe('User Authentication', () => {
  beforeEach(() => {
    cy.fixture('users').as('userData')
    
    // Initialize page objects
    loginPage = new LoginPage()
    homePage = new HomePage()
    
    cy.visit('/')
  });

  it('Should log in successfully with valid credentials', () => {
    cy.get('@userData').then((user: any) => {
      // Test reads like plain English
      loginPage.verifyLoginMainPage()
      loginPage.typeUsername(user.standard)
      loginPage.typePassword(user.password)
      loginPage.clickOnLoginButton()

      homePage.verifyHomePageIsVisible()
    })
  });

  it('Should not log in with a locked user credentials', () => {
    cy.get('@userData').then((user: any) => {
      cy.get('@loginData').then((login: any) => {
        loginPage.verifyLoginMainPage()
        loginPage.typeUsername(user.locked)
        loginPage.typePassword(user.password)
        loginPage.clickOnLoginButton()
        loginPage.verifyErrorMessage(login.errorMsg)
        loginPage.closeErrorMessage()
        loginPage.verifyErrorMessageIsHidden()
      })
    })
  });
});

Compare: With vs Without POM

loginPage.typeUsername(user.standard)
loginPage.typePassword(user.password)
loginPage.clickOnLoginButton()
homePage.verifyHomePageIsVisible()
Notice how the POM version reads like a user story, while the non-POM version exposes technical details. If the username field selector changes, you’d need to update every test without POM.

Advanced Page Object Pattern

The HomePage class demonstrates more advanced patterns:
HomePage.ts
import BasePage from "./BasePage";

export default class HomePage extends BasePage {
    private inventoryContainer: string
    private prodSortContainer: string

    constructor() {
        super()
        this.inventoryContainer = '#inventory_container'
        this.prodSortContainer = '[data-test="product-sort-container"]'
    }

    // Action method
    public selectFilterAtoZ() {
        cy.get(this.prodSortContainer).select('Name (A to Z)')
    }

    // Verification with complex logic
    public verifyFilterAtoZ() {
        cy.get(this.inventoryItem).then(($els) => {
            const names = [...$els].map(el => el.innerText.trim())
            const sorted = [...names].sort((a, b) => a.localeCompare(b))
            expect(names).to.deep.equal(sorted)
        })
    }

    // Dynamic action method
    public addProductToCart(productIndex: number) {
        cy.get(this.inventoryItem).eq(productIndex)
          .find('button').click({force: true})
    }

    // Batch operation
    public addAllProductsToCart() {
        cy.get(this.inventoryItem).each(($el) => {
            cy.wrap($el).find('button').click({force: true})
        })
    }
}

Advanced Patterns

  • Complex verifications: verifyFilterAtoZ() performs array sorting and deep equality checks
  • Parameterized actions: addProductToCart(index) accepts parameters for flexibility
  • Batch operations: addAllProductsToCart() iterates over elements
  • Inherited selectors: Uses inventoryItem from BasePage

Component Objects

For reusable UI components, create component objects:
Navbar.ts
export default class Navbar {
    private menuButton: string
    private logoutLink: string

    constructor() {
        this.menuButton = '#react-burger-menu-btn'
        this.logoutLink = '#logout_sidebar_link'
    }

    public userLogout() {
        cy.get(this.menuButton).click()
        cy.get(this.logoutLink).click()
    }
}
Components can be used across multiple pages:
import Navbar from "../../support/components/Navbar";

let navbar = new Navbar()
navbar.userLogout()

Best Practices

Selectors should never be accessed directly from tests. Always use public methods.
// ✅ Good
loginPage.typeUsername('user')

// ❌ Bad
cy.get(loginPage.usernameField).type('user')
Method names should describe what they do, not how they do it.
// ✅ Good
public verifyLoginMainPage()

// ❌ Bad
public checkLoginContainer()
Keep methods focused on a single responsibility.
// ✅ Good
public typeUsername(username: string)
public clickOnLoginButton()

// ❌ Bad
public loginAndVerify(username: string, password: string)
Exception: Composite methods for common workflows are acceptable.
Enable fluent interfaces by returning this:
public typeUsername(username: string): this {
    cy.get(this.usernameField).type(username)
    return this
}

// Usage
loginPage
  .typeUsername('user')
  .typePassword('pass')
  .clickOnLoginButton()

When NOT to Use POM

POM is designed for UI testing. For API tests, use direct cy.request() calls instead:
// API tests don't need page objects
it('Should receive a 200 status', () => {
  cy.request({
    method: 'GET',
    url: '/booking',
  }).its('status').should('eq', 200)
})

Next Steps

Creating Page Objects

Step-by-step guide to building page objects

Writing UI Tests

Apply POM in your UI tests

Test Organization

Structure your test suite effectively

TypeScript Usage

Leverage TypeScript in page objects

Build docs developers (and LLMs) love