Test Automation Lesson 3 of 6 ⏱️ 4 min read

The Page Object Model

Structuring UI tests with page objects so a changed selector is a one-line fix, with a full pytest example.

Updated Oct 30, 2026

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

Text
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

Python
# 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

Python
# 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
Python
# 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

Python
# 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()
Python
# 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 than click_submit_button().
  • Keep waits in the base page. Tests should never contain WebDriverWait or sleep.
  • 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.

Finished this lesson?
Tick it off to track your Test Automation progress.
Next lesson