Skip to main content
Selenium intermediate Lesson 6 of 10

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.

Frequently Asked Questions

What is the Page Object Model?
A pattern where each page or component is a class exposing the actions a user can take, with locators as private details. Tests call methods like `login(user, password)` instead of finding elements, so a markup change is fixed in one file rather than in every test.
Should page objects contain assertions?
Generally no. A page object models what the page can do and expose; the test decides what should be true. The exception is a self-check on load — verifying you are on the expected page — which belongs in the object because every test needs it.
Should a page object method return another page object?
Yes, when the action navigates. `login()` returning a `DashboardPage` documents the flow and gives the test autocomplete for what is legal next. Actions that stay on the page should return `self` so calls can be chained.
Is the Page Object Model always worth it?
Not for a handful of tests against a page you control. It pays off once several tests share a flow, or when locators change often. Applied too early it adds indirection without removing duplication.