Exit course
Chapter 7 of 10 Intermediate 5 min read 2 quick checks Updated

Page objects in Python

On this page
  1. The page object
  2. The tests
  3. How far to take it

When three tests fill in the same form, the form's locators appear in three places. Rename one field and three tests break. A page object puts everything a test needs to know about one page (its URL, its locators and the actions a user can take on it) into a single class. Tests then speak in user terms, and a markup change is fixed in one place.

This chapter shows the pattern in Python on the practice form. The Page Object Model course goes much further, into components, fluent page transitions, test data, framework layout and the anti-patterns to avoid; this chapter is the short version.

The page object#

Create pages/__init__.py (empty) and pages/web_form.py:

from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait


class WebFormPage:
    URL = "https://www.selenium.dev/selenium/web/web-form.html"

    TEXT_INPUT = (By.ID, "my-text-id")
    PASSWORD = (By.NAME, "my-password")
    DROPDOWN = (By.NAME, "my-select")
    SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
    MESSAGE = (By.ID, "message")

    def __init__(self, driver):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def open(self):
        self.driver.get(self.URL)
        return self

    def fill_text(self, value):
        field = self.driver.find_element(*self.TEXT_INPUT)
        field.clear()
        field.send_keys(value)
        return self

    def fill_password(self, value):
        self.driver.find_element(*self.PASSWORD).send_keys(value)
        return self

    def choose(self, visible_text):
        Select(self.driver.find_element(*self.DROPDOWN)).select_by_visible_text(visible_text)
        return self

    def submit(self):
        self.driver.find_element(*self.SUBMIT).click()
        return self.wait.until(EC.visibility_of_element_located(self.MESSAGE)).text

The design choices, briefly:

  • Locators are class-level tuples. find_element(*self.TEXT_INPUT) unpacks (By.ID, "my-text-id") into the two arguments. The same tuples work with expected conditions, which take a locator tuple directly.
  • The page receives a driver; it never creates one. Browser lifetime belongs to the fixture. A page object that starts its own browser cannot run on a grid or in parallel without being rewritten.
  • Methods are user actions (fill_text, choose, submit), not element getters. Waiting lives inside them: submit() waits for the confirmation, so no test has to remember to.
  • Methods that stay on the page return self, which lets a test chain steps. submit() returns the confirmation text because that is what a caller needs next.
  • No assertions inside the page. The page reports what it sees; the test decides what is correct. That keeps one page object usable by a test that expects success and a test that expects a validation error.

A colleague's page object has def __init__(self): self.driver = webdriver.Chrome(). What problem does that cause first?

The fixture owns the browser so that the browser choice, headless mode, the grid and teardown are decided in one place. A page that creates its own driver bypasses all of that and leaks sessions.

The tests#

tests/test_web_form.py now reads like a description of the behaviour:

import pytest

from pages.web_form import WebFormPage


@pytest.mark.smoke
def test_submit_shows_confirmation(driver):
    message = WebFormPage(driver).open().fill_text("Selenium").fill_password("secret").submit()
    assert message == "Received!"


@pytest.mark.parametrize("text", ["plain", "with spaces", "1234-5678"])
def test_text_input_keeps_value(driver, text):
    WebFormPage(driver).open().fill_text(text)
    assert driver.find_element(*WebFormPage.TEXT_INPUT).get_attribute("value") == text


def test_dropdown_selection_is_submitted(driver):
    page = WebFormPage(driver).open().choose("Two")
    page.submit()
    assert "my-select=2" in driver.current_url

Because conftest.py sits in the project root, pytest's default prepend import mode inserts that directory at the start of sys.path when it loads the file, which is why from pages.web_form import WebFormPage works without installing anything. If you move conftest.py into tests/, add pythonpath = . to pytest.ini instead.

Run the suite as before. Nothing about the fixture, the screenshots or the reports changed: page objects only reorganise what tests know about pages.

How far to take it#

Keep page objects boring. A good rule is one class per page or per significant component (a header, a date picker, a results table), with methods for what users do there. Signs that a page object has grown too clever:

  • a generic click(locator) or type(locator, text) wrapper that adds nothing but a layer of indirection;
  • a base class with dozens of helpers that every page inherits whether it needs them or not;
  • assertions or time.sleep inside page methods.

The Page Object Model course covers a first page object in more depth, how to lay out a framework as it grows, and the anti-patterns above in detail.

One test expects the form to show "Received!" and another expects a validation error. Where should the check for "Received!" live?

An assertion in the page object would make the validation-error test fail inside submit(). Pages report state; tests decide what state is correct.

So far every test runs one after another. The suite is small, but at 20 seconds a test, a few hundred tests take over an hour. The next chapter runs them in parallel.