The Page Object Model
Move locators out of tests and into objects that model behaviour — with component objects, page-returning navigation, and the mistakes that make a POM worse than none.
Ten tests into a suite, the same locator appears in six files. The Page Object Model moves those locators behind objects that describe what a user can do, so a redesign touches one file.
The duplication
def test_adds_a_todo(driver):
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
WebDriverWait(driver, 10).until(
EC.text_to_be_present_in_element((By.CLASS_NAME, "todo-count"), "1 item left"))
assert len(driver.find_elements(By.CSS_SELECTOR, ".todo-list li")) == 1
def test_completes_a_todo(driver):
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
WebDriverWait(driver, 10).until(
EC.text_to_be_present_in_element((By.CLASS_NAME, "todo-count"), "1 item left"))
driver.find_element(By.CSS_SELECTOR, ".todo-list li .toggle").click()
assert driver.find_element(By.CLASS_NAME, "todo-count").text == "0 items left"
test_todos.py::test_adds_a_todo PASSED [ 50%]
test_todos.py::test_completes_a_todo PASSED [100%]
========================== 2 passed in 5.12s ==========================
Both pass, and .new-todo now appears twice — in a real suite, thirty times. When the app
renames it, thirty files change.
A page object
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import StaleElementReferenceException, NoSuchElementException
class BasePage:
def __init__(self, driver, timeout=10):
self.driver = driver
self.wait = WebDriverWait(
driver, timeout,
ignored_exceptions=(StaleElementReferenceException, NoSuchElementException),
)
def _visible(self, locator):
return self.wait.until(EC.visibility_of_element_located(locator),
message=f"not visible: {locator}")
def _clickable(self, locator):
return self.wait.until(EC.element_to_be_clickable(locator),
message=f"not clickable: {locator}")
def _all(self, locator):
return self.driver.find_elements(*locator)
def _text_becomes(self, locator, text):
self.wait.until(EC.text_to_be_present_in_element(locator, text),
message=f"text never became {text!r} in {locator}")
# pages/todo_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base_page import BasePage
class TodoPage(BasePage):
URL = "https://demo.playwright.dev/todomvc"
NEW_TODO = (By.CLASS_NAME, "new-todo")
ITEMS = (By.CSS_SELECTOR, ".todo-list li")
COUNTER = (By.CLASS_NAME, "todo-count")
CLEAR_COMPLETED = (By.CLASS_NAME, "clear-completed")
FILTER = lambda name: (By.LINK_TEXT, name)
def open(self):
self.driver.get(self.URL)
self._visible(self.NEW_TODO)
return self
def add(self, *titles):
box = self._clickable(self.NEW_TODO)
for title in titles:
box.send_keys(title, Keys.ENTER)
self._text_becomes(self.COUNTER, "item")
return self
def row(self, title):
return self._visible(
(By.XPATH, f"//label[normalize-space()='{title}']/ancestor::li"))
def complete(self, title):
self.row(title).find_element(By.CSS_SELECTOR, ".toggle").click()
return self
def clear_completed(self):
self._clickable(self.CLEAR_COMPLETED).click()
return self
def filter_by(self, name):
self._clickable((By.LINK_TEXT, name)).click()
self.wait.until(lambda d: name.lower() in d.current_url.lower() or name == "All")
return self
@property
def titles(self):
return [e.text for e in self._all((By.CSS_SELECTOR, ".todo-list li label"))]
@property
def counter_text(self):
return self._visible(self.COUNTER).text
def is_completed(self, title):
return "completed" in self.row(title).get_attribute("class")
# test_todos.py
def test_adds_a_todo(driver):
page = TodoPage(driver).open().add("buy milk")
assert page.titles == ["buy milk"]
assert page.counter_text == "1 item left"
def test_completes_a_todo(driver):
page = TodoPage(driver).open().add("buy milk").complete("buy milk")
assert page.is_completed("buy milk")
assert page.counter_text == "0 items left"
def test_filters_to_active(driver):
page = TodoPage(driver).open().add("buy milk", "walk the dog")
page.complete("buy milk").filter_by("Active")
assert page.titles == ["walk the dog"]
pytest test_todos.py -v
test_todos.py::test_adds_a_todo PASSED [ 33%]
test_todos.py::test_completes_a_todo PASSED [ 66%]
test_todos.py::test_filters_to_active PASSED [100%]
========================== 3 passed in 7.44s ==========================
Read the tests: they describe behaviour, not markup. There is no By, no WebDriverWait, no
selector. The third test is three lines and would have been fifteen.
The waits have not disappeared — they moved into add() and _clickable(), where they are
written once and correct everywhere.
Actions return page objects
Methods that stay on the page return self, which is what allows the chaining above. Methods
that navigate should return the page they land on:
class LoginPage(BasePage):
EMAIL = (By.ID, "email")
PASSWORD = (By.ID, "password")
SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
ERROR = (By.CSS_SELECTOR, "[role='alert']")
def login(self, email, password):
self._visible(self.EMAIL).send_keys(email)
self._visible(self.PASSWORD).send_keys(password)
self._clickable(self.SUBMIT).click()
return DashboardPage(self.driver).wait_until_loaded()
def login_expecting_failure(self, email, password):
self._visible(self.EMAIL).send_keys(email)
self._visible(self.PASSWORD).send_keys(password)
self._clickable(self.SUBMIT).click()
return self
@property
def error_text(self):
return self._visible(self.ERROR).text
def test_login_succeeds(driver):
dashboard = LoginPage(driver).open().login("[email protected]", "correct-horse")
assert dashboard.heading == "Dashboard"
def test_login_rejects_a_bad_password(driver):
page = LoginPage(driver).open().login_expecting_failure("[email protected]", "wrong")
assert page.error_text == "Invalid email or password"
test_login.py::test_login_succeeds PASSED [ 50%]
test_login.py::test_login_rejects_a_bad_password PASSED [100%]
========================== 2 passed in 6.02s ==========================
Two methods rather than one with a flag, because the return types genuinely differ. A single
login() that sometimes returns a dashboard and sometimes returns itself is a page object
that lies about the flow.
The self-check on load
class DashboardPage(BasePage):
HEADING = (By.CSS_SELECTOR, "h1")
URL_FRAGMENT = "/dashboard"
def wait_until_loaded(self):
self.wait.until(EC.url_contains(self.URL_FRAGMENT),
message=f"never navigated to {self.URL_FRAGMENT}")
self._visible(self.HEADING)
return self
@property
def heading(self):
return self._visible(self.HEADING).text
selenium.common.exceptions.TimeoutException: Message: never navigated to /dashboard
This is the one assertion that belongs inside a page object. The failure now says “login did not navigate” instead of surfacing three steps later as a mysterious missing element.
Component objects
Repeating structures deserve their own class rather than methods on the page:
class TodoRow:
def __init__(self, driver, element):
self.driver = driver
self.element = element
@property
def title(self):
return self.element.find_element(By.CSS_SELECTOR, "label").text
@property
def is_completed(self):
return "completed" in self.element.get_attribute("class")
def toggle(self):
self.element.find_element(By.CSS_SELECTOR, ".toggle").click()
return self
def delete(self):
ActionChains(self.driver).move_to_element(self.element).perform()
self.element.find_element(By.CSS_SELECTOR, ".destroy").click()
class TodoPage(BasePage):
def rows(self):
return [TodoRow(self.driver, e) for e in self._all(self.ITEMS)]
def row_named(self, title):
return next(r for r in self.rows() if r.title == title)
def test_deletes_a_row(driver):
page = TodoPage(driver).open().add("buy milk", "walk the dog")
page.row_named("buy milk").delete()
assert page.titles == ["walk the dog"]
test_todos.py::test_deletes_a_row PASSED [100%]
========================== 1 passed in 3.88s ==========================
The hover-before-delete detail from lesson 4 is now inside TodoRow.delete(), where nobody
has to remember it.
Beware that TodoRow holds an element handle, so it goes stale if the list re-renders. Fetch
rows immediately before use rather than storing the list — that is why rows() is a method,
not a cached property.
Four ways to get it wrong
Assertions inside page objects. assert_counter_is("1 item left") moves the test’s job
into the object and gives you a failure message from the wrong layer.
Exposing WebElements. A method returning an element leaks the DOM back into tests, which is what the pattern exists to prevent. Return text, booleans and other page objects.
One giant object per page. A 900-line CheckoutPage is as unmaintainable as duplicated
locators. Split by component: AddressForm, PaymentPanel, OrderSummary.
Reimplementing waits per method. Waits belong in the base class, so that _clickable is
correct once. Scattering WebDriverWait(...) through page methods reintroduces the
inconsistency you were removing.
When to skip it
Five tests against a page you own, with stable data-testid attributes, do not need the
indirection — a couple of helper functions is honest and shorter. The pattern pays for itself
at the point where two things are true: several tests share a flow, and the markup changes
often enough that you have already fixed the same locator twice.
Practice
1. Extract a locator used by three tests into a page object.
page = TodoPage(driver).open().add("buy milk")
assert page.counter_text == "1 item left"
1 passed in 3.41s
Rename .new-todo in the object and every test still passes. That single-file change is the
entire return on the pattern.
2. Make a navigating method return the next page object.
dashboard = LoginPage(driver).open().login("[email protected]", "correct-horse")
assert dashboard.heading == "Dashboard"
1 passed in 4.02s
The return type documents the flow, and an editor will autocomplete only what is legal on the dashboard.
3. Add a load self-check and break the navigation.
selenium.common.exceptions.TimeoutException: Message: never navigated to /dashboard
The failure names the actual problem. Without the check, the test fails later on a missing heading and reads like a locator bug.
4. Cache a list of component objects and let the page re-render.
rows = page.rows()
page.add("a third todo")
rows[0].title
selenium.common.exceptions.StaleElementReferenceException: Message: stale element
reference: stale element not found in the current frame
Stored handles go stale. Fetch components at the moment of use — the next lesson is about living with this exception generally.
Next: flaky tests — staleness, retries, and the failures that only happen in CI.