01 · Page Object Model in Python¶
Level 1 wrote locators directly inside test functions. That works for ten tests. It falls apart at a hundred: change one CSS class in the app and you're grepping through dozens of test files to fix the same broken locator ten times over. The Page Object Model (POM) is the fix — one class per page (or component), owning that page's locators and actions, so a UI change means one edit in one place.
1. The problem, concretely¶
Without POM, every test re-describes the page:
def test_submitting_form_shows_confirmation(driver, wait, base_url):
driver.get(f"{base_url}/web-form.html")
driver.find_element(By.NAME, "my-text").send_keys("QA automation")
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button"))).click()
message = wait.until(EC.visibility_of_element_located((By.ID, "message")))
assert message.text == "Received!"
def test_disabled_input_cannot_be_edited(driver, base_url):
driver.get(f"{base_url}/web-form.html")
disabled_field = driver.find_element(By.NAME, "my-disabled")
assert not disabled_field.is_enabled()
Both tests know the URL, both know By.NAME, "my-text" is the text box, both
know how to find the submit button. If the developer renames my-text to
text-input, you fix it in every test that touches it.
2. A minimal page object¶
A page object exposes actions ("submit the form", "select this dropdown
option") and state ("what does the message say"). It never contains
assert — that stays in the test.
# pages/web_form_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait, Select
from selenium.webdriver.support import expected_conditions as EC
class WebFormPage:
URL = "/web-form.html"
# Locators — the ONLY place they're allowed to appear
TEXT_INPUT = (By.NAME, "my-text")
PASSWORD_INPUT = (By.NAME, "my-password")
TEXTAREA = (By.NAME, "my-textarea")
DISABLED_INPUT = (By.NAME, "my-disabled")
DROPDOWN = (By.NAME, "my-select")
CHECKBOX_2 = (By.ID, "my-check-2")
SUBMIT_BUTTON = (By.CSS_SELECTOR, "button")
MESSAGE = (By.ID, "message")
def __init__(self, driver, base_url):
self.driver = driver
self.base_url = base_url
self.wait = WebDriverWait(driver, timeout=10)
def load(self):
self.driver.get(f"{self.base_url}{self.URL}")
return self
def enter_text(self, value):
self.driver.find_element(*self.TEXT_INPUT).send_keys(value)
return self
def enter_password(self, value):
self.driver.find_element(*self.PASSWORD_INPUT).send_keys(value)
return self
def select_dropdown_option(self, visible_text):
Select(self.driver.find_element(*self.DROPDOWN)).select_by_visible_text(visible_text)
return self
def check_checkbox(self):
self.driver.find_element(*self.CHECKBOX_2).click()
return self
def submit(self):
self.wait.until(EC.element_to_be_clickable(self.SUBMIT_BUTTON)).click()
return self
def is_text_input_disabled(self):
return not self.driver.find_element(*self.DISABLED_INPUT).is_enabled()
def get_confirmation_message(self):
return self.wait.until(EC.visibility_of_element_located(self.MESSAGE)).text
Notice every action method returns self (or, when navigation happens, the
next page — section 4). That's a fluent interface, and it's what makes
tests read like a script rather than a wall of find_element calls.
3. The test, rewritten¶
# tests/test_web_form_pom.py
import pytest
from pages.web_form_page import WebFormPage
@pytest.fixture
def form_page(driver, base_url):
return WebFormPage(driver, base_url).load()
def test_submitting_form_shows_confirmation(form_page):
form_page.enter_text("QA automation").submit()
assert form_page.get_confirmation_message() == "Received!"
def test_disabled_input_cannot_be_edited(form_page):
assert form_page.is_text_input_disabled()
def test_dropdown_selection_persists_after_submit(form_page):
form_page.select_dropdown_option("Two").submit()
assert "my-select=2" in form_page.driver.current_url
tests/test_web_form_pom.py::test_submitting_form_shows_confirmation PASSED [ 33%]
tests/test_web_form_pom.py::test_disabled_input_cannot_be_edited PASSED [ 66%]
tests/test_web_form_pom.py::test_dropdown_selection_persists_after_submit PASSED [100%]
============================== 3 passed in 4.87s ===============================
The test file no longer imports By, never calls find_element, and reads as
a sequence of user actions and assertions. When the developer renames
my-text, exactly one line changes: TEXT_INPUT in web_form_page.py.
4. A base page for shared behaviour¶
Every page object needs driver, wait, and usually the same handful of
helpers (safe click, get text, wait for a toast). Put those in a BasePage
and inherit.
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class BasePage:
def __init__(self, driver, base_url):
self.driver = driver
self.base_url = base_url
self.wait = WebDriverWait(driver, timeout=10)
def click(self, locator):
self.wait.until(EC.element_to_be_clickable(locator)).click()
def type_into(self, locator, text, clear_first=True):
field = self.wait.until(EC.visibility_of_element_located(locator))
if clear_first:
field.clear()
field.send_keys(text)
def text_of(self, locator):
return self.wait.until(EC.visibility_of_element_located(locator)).text
def is_current_url(self, fragment):
return fragment in self.driver.current_url
# pages/web_form_page.py
from selenium.webdriver.common.by import By
from .base_page import BasePage
class WebFormPage(BasePage):
URL = "/web-form.html"
TEXT_INPUT = (By.NAME, "my-text")
SUBMIT_BUTTON = (By.CSS_SELECTOR, "button")
MESSAGE = (By.ID, "message")
def load(self):
self.driver.get(f"{self.base_url}{self.URL}")
return self
def submit_text(self, value):
self.type_into(self.TEXT_INPUT, value)
self.click(self.SUBMIT_BUTTON)
return self
def confirmation_message(self):
return self.text_of(self.MESSAGE)
WebFormPage no longer knows how to wait or click — it only knows what to
click. That split (base page = mechanics, page object = page-specific
vocabulary) is what keeps a 40-page framework maintainable.
5. Chaining across pages¶
When an action navigates somewhere new, return the next page object instead
of self. This makes multi-page flows type-checkable and self-documenting.
# pages/login_page.py
from selenium.webdriver.common.by import By
from .base_page import BasePage
from .dashboard_page import DashboardPage
class LoginPage(BasePage):
URL = "/login"
EMAIL = (By.ID, "email")
PASSWORD = (By.ID, "password")
SIGN_IN = (By.ID, "signin")
FIELD_ERROR = (By.CSS_SELECTOR, ".field-error")
def load(self):
self.driver.get(f"{self.base_url}{self.URL}")
return self
def login(self, email, password) -> DashboardPage:
"""Successful login navigates away — return the page the user lands on."""
self.type_into(self.EMAIL, email)
self.type_into(self.PASSWORD, password)
self.click(self.SIGN_IN)
return DashboardPage(self.driver, self.base_url)
def login_expecting_failure(self, email, password) -> str:
"""Failed login stays on this page — return the error, not a page object."""
self.type_into(self.EMAIL, email)
self.type_into(self.PASSWORD, password)
self.click(self.SIGN_IN)
return self.text_of(self.FIELD_ERROR)
def test_valid_login_reaches_dashboard(login_page):
dashboard = login_page.login("qa.buyer01@test.com", "Valid#123")
assert dashboard.welcome_message().startswith("Welcome")
def test_invalid_login_shows_error(login_page):
error = login_page.login_expecting_failure("qa.unknown@test.com", "Valid#123")
assert error == "No account found for this email"
Two different return types for two different outcomes is intentional — a reviewer sees at a glance which path leaves the page and which doesn't, without opening the method body.
6. What does not belong in a page object¶
| Belongs in the page object | Belongs in the test |
|---|---|
| Locators | assert statements |
| "Click submit", "type into field" | "the message should equal 'Received!'" |
| Waiting for this page's elements | Deciding what success looks like |
| Returning text/state for the test to check | Interpreting that state |
The most common POM mistake: asserting inside the page object
# ✗ Wrong — the page object now decides what "success" means
def submit(self):
self.click(self.SUBMIT_BUTTON)
assert self.text_of(self.MESSAGE) == "Received!"
submit() to one expected outcome,
and a test that wants to verify a validation error instead has to work
around an assertion baked into the action. Return the state; let the test
decide what it means.
7. Fat page objects and the fix¶
A page object that grows fifty methods for one enormous page is itself a
maintenance problem. Split by component, not just by page: a HeaderNav
object, a SearchWidget object, and a ProductListPage that composes them.
class ProductListPage(BasePage):
def __init__(self, driver, base_url):
super().__init__(driver, base_url)
self.nav = HeaderNav(driver, base_url)
self.search = SearchWidget(driver, base_url)
def product_count(self):
return len(self.driver.find_elements(By.CSS_SELECTOR, ".product-card"))
def test_search_narrows_results(product_list_page):
product_list_page.search.search_for("trail running shoes")
assert product_list_page.product_count() == 3
Now HeaderNav and SearchWidget are reusable on every page that has them,
instead of duplicated inside ProductListPage, CheckoutPage, and
AccountPage separately.
How It Actually Works¶
The Page Object Model isn't just "good OOP style" applied to tests — it's a deliberate cache-invalidation boundary around the one thing in Selenium that's most expensive and most brittle: locator strategy.
Every driver.find_element(By.CSS_SELECTOR, "...") call triggers a fresh round trip
through the WebDriver wire protocol (see Level 1) to the browser's DOM, which walks
the live DOM tree to find a match — Selenium does not cache element references across
calls by default (a WebElement reference can go stale the instant the DOM
re-renders, raising StaleElementReferenceException). By centralizing locators as
class attributes on a Page Object, you're not making lookups faster — each call still
does the same DOM walk — but you're making the one place a broken locator needs
fixing when the front-end team renames a data-testid a single line of code instead
of a grep-and-replace across every test file.
Method chaining (page.login(user).open_dashboard()) works because each page-object
method returns self or a new page object instance — this is plain Python object
composition, not a Selenium feature, but it mirrors how the actual application state
machine works: each returned object represents "the page the browser is now
believably on," so the type system (or at minimum, the object graph) documents valid
UI-flow transitions the same way a state machine diagram would.
Cheat sheet¶
| Principle | Rule |
|---|---|
| Locators | Live only inside the page object, never in a test |
| Assertions | Live only inside the test, never in the page object |
| Method naming | Name for user intent (login, add_to_cart), not implementation (click_button_3) |
| Return values | self for same-page actions, next page object for navigation |
| Shared mechanics | Factor into a BasePage |
| Large pages | Split into composable component objects, not one giant class |
| Waits | Belong in the page object (or base page) — a test should never call time.sleep or raw find_element |
Exercise¶
Using the Selenium practice page at
https://www.selenium.dev/selenium/web/web-form.html:
- Write
pages/base_page.pywithclick,type_into,text_of, andis_current_urlexactly as shown in section 4. - Write
pages/web_form_page.pyinheriting fromBasePage, covering the text input, textarea, dropdown, checkbox, and submit button — with no locator repeated between methods. - Rewrite five tests from Level 1's Selenium module
to use your page object instead of raw
find_elementcalls. Confirm they still pass. - Add one method that deliberately violates section 6 (asserts inside the page object), watch it make a test's failure message confusing, then fix it by moving the assertion back into the test.
- Split
WebFormPageintoWebFormPageplus aDropdownWidgetcomponent object owning just theSelectlogic, and use it from two different tests.