Blog

Page Object Model in Selenium & Cypress: Complete Guide

Published on
January 22, 2026
Rishabh Kumar
Marketing Lead

Learn how to implement POM in Selenium and Cypress with code examples, best practices, and modern AI-powered alternatives that eliminate locator maintenance.

The Page Object Model (POM) has been the gold standard for organizing test automation code since the early days of Selenium. It separates page structure from test logic, making tests more maintainable and readable. But POM comes with costs: boilerplate code, class proliferation, and ongoing maintenance as applications evolve. This guide covers POM implementation in both Selenium and Cypress, explains best practices, and reveals when modern alternatives eliminate the need for Page Objects entirely.

What is the Page Object Model?

The Page Object Model is a design pattern that creates an abstraction layer between test code and page structure. Each page (or significant component) in your application becomes a class containing:

  • Element locators for that page
  • Methods representing actions users can perform
  • Methods returning page state information

Tests interact with Page Objects rather than directly with elements. When the UI changes, you update the Page Object; tests remain unchanged.

The Problem POM Solves

Without Page Objects, tests directly embed element locators:

# Without POM - locators scattered throughout tests
def test_login():
    driver.find_element(By.ID, "username").send_keys("testuser")
    driver.find_element(By.ID, "password").send_keys("password123")
    driver.find_element(By.CSS_SELECTOR, ".login-btn").click()
    assert driver.find_element(By.CLASS_NAME, "welcome-msg").is_displayed()

def test_failed_login():
    driver.find_element(By.ID, "username").send_keys("invalid")
    driver.find_element(By.ID, "password").send_keys("wrong")
    driver.find_element(By.CSS_SELECTOR, ".login-btn").click()
    assert driver.find_element(By.CLASS_NAME, "error-msg").is_displayed()

When the login button selector changes from .login-btn to .submit-button, every test using that selector requires modification. With dozens or hundreds of tests, this becomes unsustainable.

How POM Solves It

Page Objects centralize locators and actions:

# With POM - locators centralized
class LoginPage:
    def __init__(self, driver):
        self.driver = driver
        self.username_field = (By.ID, "username")
        self.password_field = (By.ID, "password")
        self.login_button = (By.CSS_SELECTOR, ".login-btn")
        self.welcome_message = (By.CLASS_NAME, "welcome-msg")
        self.error_message = (By.CLASS_NAME, "error-msg")
    
    def enter_username(self, username):
        self.driver.find_element(*self.username_field).send_keys(username)
    
    def enter_password(self, password):
        self.driver.find_element(*self.password_field).send_keys(password)
    
    def click_login(self):
        self.driver.find_element(*self.login_button).click()
    
    def is_welcome_displayed(self):
        return self.driver.find_element(*self.welcome_message).is_displayed()
    
    def is_error_displayed(self):
        return self.driver.find_element(*self.error_message).is_displayed()

Tests become cleaner and selector changes require single-point updates:

def test_login(login_page):
    login_page.enter_username("testuser")
    login_page.enter_password("password123")
    login_page.click_login()
    assert login_page.is_welcome_displayed()

Page Object Model in Selenium

Basic Selenium POM Structure

A standard Selenium Page Object includes:

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

class BasePage:
    """Base class for all page objects"""
    
    def __init__(self, driver):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)
    
    def find_element(self, locator):
        return self.wait.until(EC.presence_of_element_located(locator))
    
    def click(self, locator):
        self.wait.until(EC.element_to_be_clickable(locator)).click()
    
    def enter_text(self, locator, text):
        element = self.find_element(locator)
        element.clear()
        element.send_keys(text)
    
    def get_text(self, locator):
        return self.find_element(locator).text

Page specific classes extend the base:

class ProductPage(BasePage):
    # Locators
    PRODUCT_TITLE = (By.CSS_SELECTOR, ".product-title")
    PRICE = (By.CSS_SELECTOR, ".product-price")
    ADD_TO_CART = (By.ID, "add-to-cart")
    QUANTITY_INPUT = (By.ID, "quantity")
    CART_CONFIRMATION = (By.CSS_SELECTOR, ".cart-confirmation")
    
    def get_product_title(self):
        return self.get_text(self.PRODUCT_TITLE)
    
    def get_price(self):
        return self.get_text(self.PRICE)
    
    def set_quantity(self, quantity):
        self.enter_text(self.QUANTITY_INPUT, str(quantity))
    
    def add_to_cart(self):
        self.click(self.ADD_TO_CART)
    
    def is_confirmation_displayed(self):
        return self.find_element(self.CART_CONFIRMATION).is_displayed()

Selenium POM Best Practices

1. Return Page Objects from Navigation Methods

When actions navigate to new pages, return the corresponding Page Object:

class LoginPage(BasePage):
    def login(self, username, password):
        self.enter_text(self.USERNAME, username)
        self.enter_text(self.PASSWORD, password)
        self.click(self.LOGIN_BUTTON)
        return DashboardPage(self.driver)  # Return new page object

This enables fluent chaining:

dashboard = LoginPage(driver).login("user", "pass")
dashboard.navigate_to_settings()

2. Avoid Assertions in Page Objects

Page Objects should describe page capabilities, not test expectations. Keep assertions in test files:

# Good - assertion in test
def test_product_price():
    product_page = ProductPage(driver)
    assert product_page.get_price() == "$29.99"

# Bad - assertion in Page Object
class ProductPage:
    def verify_price(self, expected):
        assert self.get_price() == expected  # Don't do this

3. Handle Dynamic Elements

For elements that appear conditionally or after delays:

class SearchResults(BasePage):
    RESULTS_CONTAINER = (By.ID, "results")
    RESULT_ITEMS = (By.CSS_SELECTOR, ".result-item")
    NO_RESULTS = (By.CSS_SELECTOR, ".no-results")
    
    def wait_for_results(self):
        self.wait.until(
            EC.presence_of_element_located(self.RESULTS_CONTAINER)
        )
    
    def get_result_count(self):
        self.wait_for_results()
        return len(self.driver.find_elements(*self.RESULT_ITEMS))
    
    def has_no_results(self):
        try:
            self.driver.find_element(*self.NO_RESULTS)
            return True
        except NoSuchElementException:
            return False
CTA Banner

Page Object Model in Cypress

Cypress does not require POM the way Selenium does because its command chaining and automatic retry reduce many problems POM addresses. However, many teams still apply the pattern for organization.

1. Cypress POM Implementation

Cypress Page Objects typically use JavaScript classes or plain objects:

// cypress/pages/LoginPage.js
class LoginPage {
    // Element selectors
    get usernameField() {
        return cy.get('#username');
    }
    
    get passwordField() {
        return cy.get('#password');
    }
    
    get loginButton() {
        return cy.get('.login-btn');
    }
    
    get errorMessage() {
        return cy.get('.error-msg');
    }
    
    // Actions
    visit() {
        cy.visit('/login');
    }
    
    enterUsername(username) {
        this.usernameField.clear().type(username);
        return this;
    }
    
    enterPassword(password) {
        this.passwordField.clear().type(password);
        return this;
    }
    
    submit() {
        this.loginButton.click();
    }
    
    login(username, password) {
        this.enterUsername(username);
        this.enterPassword(password);
        this.submit();
    }
}

export default new LoginPage();

Tests import and use the Page Object:

// cypress/e2e/login.cy.js
import loginPage from '../pages/LoginPage';

describe('Login Tests', () => {
    beforeEach(() => {
        loginPage.visit();
    });
    
    it('should login successfully', () => {
        loginPage.login('testuser', 'password123');
        cy.url().should('include', '/dashboard');
    });
    
    it('should show error for invalid credentials', () => {
        loginPage.login('invalid', 'wrong');
        loginPage.errorMessage.should('be.visible');
    });
});

2. Cypress Custom Commands Alternative

Many Cypress users prefer custom commands over Page Objects:

// cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
    cy.get('#username').type(username);
    cy.get('#password').type(password);
    cy.get('.login-btn').click();
});

Cypress.Commands.add('addToCart', (productName, quantity) => {
    cy.contains('.product-card', productName).within(() => {
        cy.get('.quantity-input').clear().type(quantity);
        cy.get('.add-to-cart').click();
    });
});

Usage:

it('should complete purchase', () => {
    cy.login('testuser', 'password');
    cy.addToCart('Blue Widget', 2);
    cy.get('.cart-total').should('contain', '$59.98');
});

Cypress POM Best Practices

1. Use Getter Methods for Elements

Getters re-query the DOM each time, avoiding stale references:

2. Return "this" for Method Chaining

Enable fluent interfaces by returning "this":

enterEmail(email) {
    cy.get('#email').type(email);
    return this;
}

enterPassword(password) {
    cy.get('#password').type(password);
    return this;
}

// Usage
loginPage.enterEmail('test@example.com').enterPassword('secret');

3. Handle Component Reuse

For components appearing across multiple pages, create component classes:

// cypress/components/NavigationBar.js
class NavigationBar {
    clickHome() {
        cy.get('.nav-home').click();
    }
    
    clickProducts() {
        cy.get('.nav-products').click();
    }
    
    search(term) {
        cy.get('.nav-search').type(term).type('{enter}');
    }
}

export default new NavigationBar();

The Hidden Costs of Page Object Model

While POM improves test organization, it introduces costs that compound over time.

1. Boilerplate Proliferation

Every page requires:

  • A Page Object class file
  • Locator definitions for each element
  • Methods for each action
  • Wait handling for dynamic content

A medium complexity application with 50 pages generates thousands of lines of Page Object code before a single test runs.

2. Maintenance Burden

When UI changes occur:

  1. Identify affected Page Objects
  2. Update locators
  3. Potentially modify method signatures
  4. Update tests if return types change
  5. Run tests to verify fixes

Organizations report spending 40% to 60% of automation effort on maintenance, with significant time in Page Object updates.

3. Locator Fragility

POM centralizes locators but does not make them resilient. A Page Object with:

LOGIN_BUTTON = (By.CSS_SELECTOR, ".auth-form > div:nth-child(3) > button")

Still breaks when the form structure changes. The single-point-of-update benefit helps, but brittle locators remain brittle.

4. Abstraction Overhead

Page Objects add a layer of indirection. Debugging requires:

  1. Understanding the test failure
  2. Navigating to the Page Object
  3. Examining the locator
  4. Checking the method implementation
  5. Potentially reviewing the base class

This mental overhead slows troubleshooting compared to simpler test structures.

The Modern Alternative: Natural Language Testing

AI native platforms eliminate Page Object complexity by removing the need for element locators entirely.

How It Works

Instead of defining locators and methods, describe actions in natural language:

Traditional Page Object approach:

class CheckoutPage:
    SHIPPING_ADDRESS = (By.ID, "shipping-address")
    CARD_NUMBER = (By.NAME, "cardNumber")
    EXPIRY = (By.NAME, "expiry")
    CVV = (By.NAME, "cvv")
    PLACE_ORDER = (By.CSS_SELECTOR, ".place-order-btn")
    
    def enter_shipping_address(self, address):
        self.enter_text(self.SHIPPING_ADDRESS, address)
    
    def enter_card_details(self, number, expiry, cvv):
        self.enter_text(self.CARD_NUMBER, number)
        self.enter_text(self.EXPIRY, expiry)
        self.enter_text(self.CVV, cvv)
    
    def place_order(self):
        self.click(self.PLACE_ORDER)

Natural Language approach:

Enter "123 Main Street, City, 12345" in the shipping address field
Enter "4111111111111111" in the card number field
Enter "12/25" in the expiry field
Enter "123" in the CVV field
Click the Place Order button
Verify the order confirmation displays

No Page Objects. No locators. No boilerplate. The platform identifies elements through intelligent analysis of text, position, semantic attributes, and context.

Self Healing Eliminates Maintenance

When UI changes occur:

  • Page Object approach: Update locators manually, verify fix, redeploy
  • AI native approach: Self healing automatically adapts to changes. The platform recognizes "Place Order button" through multiple signals. When the button's class changes, it remains identifiable through text content, position, and surrounding context.

Virtuoso QA achieves approximately 95% self healing accuracy, eliminating the maintenance burden that makes Page Objects necessary in the first place.

Migrating Existing Tests

Organizations with existing Selenium or Cypress test suites can migrate using Virtuoso QA's GENerator, which converts framework code into natural language journeys:

  • Selenium Page Objects → Natural language steps
  • Cypress custom commands → Natural language steps
  • Test logic preserved, locator complexity eliminated

Migrated tests gain self healing capabilities immediately without rewriting from scratch.

Conclusion - Choose the Right Approach

Page Object Model served test automation well for over a decade. It remains the best practice for teams committed to Selenium or Cypress. But POM is a workaround for a fundamental limitation: element identification through brittle locators.

AI native testing addresses the root problem. Natural language element identification and self healing eliminate the need for locator management. Page Objects become unnecessary when elements identify themselves through what users see rather than DOM structure.

Virtuoso QA enables:

  • Natural Language Programming: Describe tests in plain English
  • Self Healing: Automatic adaptation when UI changes (95% accuracy)
  • Zero Page Objects: No locator management, no boilerplate
  • 10x Faster Test Creation: Focus on what to test, not how to locate
  • GENerator Migration: Convert existing Selenium and Cypress tests

The question is not whether Page Objects are useful. They are. The question is whether you want to maintain them forever.

CTA Banner

Frequently Asked Questions

Should I use Page Object Model for new Selenium projects?

If you are committed to Selenium, yes. POM remains the best practice for organizing Selenium tests. However, consider whether the ongoing maintenance burden justifies the approach. For long-term projects, AI native alternatives may provide better ROI despite the learning curve.

Is Page Object Model necessary in Cypress?

Less necessary than in Selenium. Cypress's automatic retry, command chaining, and built-in organization reduce many problems POM addresses. Many teams use custom commands instead of full Page Objects. Evaluate whether POM complexity adds value for your specific project.

How do I organize Page Objects for large applications?

Use a hierarchical structure mirroring your application: base classes for common functionality, page specific classes inheriting from bases, component classes for reusable UI elements. Group related Page Objects in directories matching application sections. Keep Page Objects focused on single pages or components.

Can Page Objects and AI native testing coexist?

Yes. Organizations often run both approaches during migration. Existing Page Object tests continue operating while new tests use natural language. Critical tests migrate first based on maintenance burden. Over time, the proportion shifts toward AI native as benefits compound.

What happens to Page Objects when my application changes significantly?

Significant changes require corresponding Page Object updates. The single-point-of-update benefit helps, but major UI redesigns still demand substantial effort. This is where self healing provides dramatic advantages by adapting automatically to changes that would require extensive Page Object modification.

How does Natural Language testing handle complex interactions?

Natural language handles complexity through descriptive specificity. Instead of encoding complex selectors, describe elements contextually: "Click the Edit button in the row containing Order 12345" or "Select Premium from the Plan dropdown in the Billing section." The platform interprets context to identify correct elements.

Related Reads

Subscribe to our Newsletter

Codeless Test Automation

Try Virtuoso QA in Action

See how Virtuoso QA transforms plain English into fully executable tests within seconds.

Try Interactive Demo
Schedule a Demo