
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.
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:
Tests interact with Page Objects rather than directly with elements. When the UI changes, you update the Page Object; tests remain unchanged.
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.
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()
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()
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()
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
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

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.
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');
});
});
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');
});
Getters re-query the DOM each time, avoiding stale references:
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');
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();
While POM improves test organization, it introduces costs that compound over time.
Every page requires:
A medium complexity application with 50 pages generates thousands of lines of Page Object code before a single test runs.
When UI changes occur:
Organizations report spending 40% to 60% of automation effort on maintenance, with significant time in Page Object updates.
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.
Page Objects add a layer of indirection. Debugging requires:
This mental overhead slows troubleshooting compared to simpler test structures.
AI native platforms eliminate Page Object complexity by removing the need for element locators entirely.
Instead of defining locators and methods, describe actions in natural language:
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)
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.
When UI changes occur:
Virtuoso QA achieves approximately 95% self healing accuracy, eliminating the maintenance burden that makes Page Objects necessary in the first place.
Organizations with existing Selenium or Cypress test suites can migrate using Virtuoso QA's GENerator, which converts framework code into natural language journeys:
Migrated tests gain self healing capabilities immediately without rewriting from scratch.
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:
The question is not whether Page Objects are useful. They are. The question is whether you want to maintain them forever.

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.
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.
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.
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.
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.
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.
Try Virtuoso QA in Action
See how Virtuoso QA transforms plain English into fully executable tests within seconds.