---
title: Page objects in Python | Selenium with Python and pytest
description: When three tests fill in the same form, the form&#39;s locators appear
  in three places. Rename one field and three tests break. A page object puts everything…
source_url:
  html: https://testingbot.com/resources/courses/selenium-python/page-objects
  md: https://testingbot.com/resources/courses/selenium-python/page-objects.md
---

Course

[Selenium with Python and pytest](https://testingbot.com/resources/courses/selenium-python)

Chapter 7 of 10

1. [1 Selenium Python Tutorial with pytest](https://testingbot.com/resources/courses/selenium-python)
2. [2 Set up Selenium with Python and pytest](https://testingbot.com/resources/courses/selenium-python/setup-and-first-test)
3. [3 Selenium locators in Python: the By API](https://testingbot.com/resources/courses/selenium-python/locators)
4. [4 Selenium waits in Python: WebDriverWait and time.sleep](https://testingbot.com/resources/courses/selenium-python/waits)
5. [5 pytest fixtures for Selenium: conftest.py and scope](https://testingbot.com/resources/courses/selenium-python/pytest-fixtures)
6. [6 Assertions, screenshots on failure and test reports](https://testingbot.com/resources/courses/selenium-python/assertions-screenshots-reports)
7. [7 Page objects in Python](https://testingbot.com/resources/courses/selenium-python/page-objects)
8. [8 Run Selenium tests in parallel with pytest-xdist](https://testingbot.com/resources/courses/selenium-python/parallel-with-pytest-xdist)
9. [9 Run Selenium Python tests on the TestingBot grid](https://testingbot.com/resources/courses/selenium-python/run-on-testingbot)
10. [10 Selenium Python Quiz](https://testingbot.com/resources/courses/selenium-python/knowledge-check)

[Exit course](https://testingbot.com/resources/courses)

 Chapter 7 of 10 Intermediate 5 min read 2 quick checks Updated Nov 2026 

# Page objects in Python
On this page
1. [The page object](https://testingbot.com#the-page-object)
2. [The tests](https://testingbot.com#the-tests)
3. [How far to take it](https://testingbot.com#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](https://testingbot.com/resources/courses/page-object-model) 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[#](https://testingbot.com#the-page-object)

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

```python
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?

 Locators can no longer be class attributes. pytest will not collect tests that use the page. Tests cannot choose the browser or run it remotely, and every new page object opens another browser that the fixture never closes.

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[#](https://testingbot.com#the-tests)

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

```python
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[#](https://testingbot.com#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](https://testingbot.com/resources/courses/page-object-model/first-page-object) in more depth, how to [lay out a framework](https://testingbot.com/resources/courses/page-object-model/framework-layout) as it grows, and the [anti-patterns](https://testingbot.com/resources/courses/page-object-model/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?

 In `WebFormPage.submit()`, as `assert text == "Received!"`. In the test; the page object returns what it sees. In the fixture teardown, so every test checks it.

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.

[Previous · Chapter 6 Assertions, screenshots on failure and test reports](https://testingbot.com/resources/courses/selenium-python/assertions-screenshots-reports) [Next · Chapter 8 Run Selenium tests in parallel with pytest-xdist](https://testingbot.com/resources/courses/selenium-python/parallel-with-pytest-xdist)

On this page

1. [The page object](https://testingbot.com#the-page-object)
2. [The tests](https://testingbot.com#the-tests)
3. [How far to take it](https://testingbot.com#how-far-to-take-it)

Certification

This course has a free, hands-on certificate.

[How it works](https://testingbot.com/resources/courses/selenium-python/certification)
