The Page Object Model
Structuring UI tests with page objects so a changed selector is a one-line fix, with a full pytest example.
The problem the Page Object Model solves
Imagine 40 tests that each contain driver.find_element(By.ID, "username"). A developer renames the field to email, and 40 tests break. Fixing them means 40 edits — and missing one means a failing build.
The Page Object Model (POM) puts each page's locators and actions in one class. Tests talk to the page object, never to raw selectors. The rename becomes a one-line fix.
The structure
tests/
conftest.py ← browser fixture
pages/
base_page.py ← shared helpers (waits, open, title)
login_page.py ← locators + actions for /login
secure_page.py ← locators + actions for /secure
test_login.py ← tests read like user stories
A base page with shared helpers
# pages/base_page.py
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
BASE_URL = "https://the-internet.herokuapp.com"
class BasePage:
path = "/"
def __init__(self, driver: WebDriver, timeout: int = 10):
self.driver = driver
self.wait = WebDriverWait(driver, timeout)
def open(self):
self.driver.get(BASE_URL + self.path)
return self
def find_visible(self, locator):
return self.wait.until(EC.visibility_of_element_located(locator))
def click(self, locator):
self.wait.until(EC.element_to_be_clickable(locator)).click()
def type(self, locator, text: str):
field = self.find_visible(locator)
field.clear()
field.send_keys(text)
Page objects
# pages/login_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
from pages.secure_page import SecurePage
class LoginPage(BasePage):
path = "/login"
USERNAME = (By.ID, "username")
PASSWORD = (By.ID, "password")
SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
FLASH = (By.ID, "flash")
def login(self, username: str, password: str):
self.type(self.USERNAME, username)
self.type(self.PASSWORD, password)
self.click(self.SUBMIT)
def login_as_valid_user(self) -> SecurePage:
self.login("tomsmith", "SuperSecretPassword!")
return SecurePage(self.driver)
def flash_message(self) -> str:
return self.find_visible(self.FLASH).text
# pages/secure_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from pages.base_page import BasePage
class SecurePage(BasePage):
path = "/secure"
FLASH = (By.ID, "flash")
LOGOUT = (By.CSS_SELECTOR, "a[href='/logout']")
def is_loaded(self) -> bool:
return self.wait.until(EC.url_contains(self.path))
def flash_message(self) -> str:
return self.find_visible(self.FLASH).text
def logout(self):
self.click(self.LOGOUT)
Notice that login_as_valid_user() returns the next page object. Tests can chain naturally: LoginPage(driver).open().login_as_valid_user().logout().
The fixture and tests
# conftest.py
import pytest
from selenium import webdriver
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1366,768")
browser = webdriver.Chrome(options=options)
yield browser
browser.quit()
# test_login.py — run with: pytest -v
from pages.login_page import LoginPage
def test_valid_user_reaches_secure_area(driver):
secure = LoginPage(driver).open().login_as_valid_user()
assert secure.is_loaded()
assert "You logged into a secure area!" in secure.flash_message()
def test_wrong_password_is_rejected(driver):
login = LoginPage(driver).open()
login.login("tomsmith", "not-the-password")
assert "Your password is invalid!" in login.flash_message()
def test_user_can_log_out(driver):
secure = LoginPage(driver).open().login_as_valid_user()
secure.logout()
assert "You logged out of the secure area!" in LoginPage(driver).flash_message()
The tests contain no selectors and no waits — they read like the test cases a manual tester would write.
POM rules of thumb
- Page objects don't assert. They return data (
flash_message()) and the test decides what's correct. This keeps page objects reusable for positive and negative tests. - One class per page or major component. Repeated widgets (header, date picker, modal) become their own component objects.
- Expose user intentions, not clicks.
login_as_valid_user()is better thanclick_submit_button(). - Keep waits in the base page. Tests should never contain
WebDriverWaitorsleep. - Return the next page when an action navigates.
The same pattern works in Cypress and Playwright. Next: running one test against many inputs with data-driven and BDD testing.