1. Home
  2. Blog
  3. Test Automation

Test Automation

Page Object Model Tutorial: Cleaner Selenium Tests in Python, With Examples

When one button changes and fifty tests fail, the problem is usually structure. See how the Page Object Model fixes that, with a full before-and-after example in Selenium and pytest.

10 min read
Page Object Model tutorial with Selenium and Python examples

Key takeaways

  • The Page Object Model keeps locators and page actions in classes, separate from your tests.
  • When the UI changes, you update one class instead of every test that touches that page.
  • Page objects expose user actions, such as login(), not raw clicks and selectors.
  • Keep assertions in tests, and split pages into smaller components as they grow.

Most UI test suites start out fine. Ten tests later, the same CSS selector is pasted into half the files, and a small redesign turns the whole build red. The Page Object Model, usually shortened to POM, is the most widely used way to prevent that.

In this tutorial we build a small POM project in Python using Selenium and pytest, against the-internet.herokuapp.com/login, a public practice site with a simple login page. The ideas apply equally to Java, C# or any other language.

What is the Page Object Model?

The Page Object Model is a design pattern where each page or major part of your application gets its own class. That class holds two things: how to find the elements on that page, and methods for what a user can do there.

Your tests then talk to these classes rather than to the browser directly. A test reads login_page.login("alice", "secret") rather than three lines of element lookups. Think of a page object as an API for a web page: tests use the API, and only the page object knows how the page is built.

The problem it solves

Here is a typical test written without any structure:

def test_valid_login(driver):
    driver.get("https://the-internet.herokuapp.com/login")
    driver.find_element(By.ID, "username").send_keys("tomsmith")
    driver.find_element(By.ID, "password").send_keys("SuperSecretPassword!")
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
    flash = WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.ID, "flash")))
    assert "You logged into a secure area" in flash.text

On its own, that looks harmless. The trouble comes with scale. Now imagine a second test for a wrong password, a third for an empty form and thirty more that must log in first. If a developer renames the username id, every copy breaks, and someone has to find and fix them all. The test also describes how the page works, which hides what it is checking.

These are the two problems POM solves: duplicated knowledge about the page, and tests that are hard to read. They are the same reasons we recommend clear intent in well-written test cases.

Project setup

Create a virtual environment, install the packages and lay out your folders like this. Selenium 4.6 and later can download a matching browser driver automatically through Selenium Manager, so you do not need to install one yourself.

pip install selenium pytest

project/
  pages/
    __init__.py
    base_page.py
    login_page.py
    secure_page.py
  tests/
    conftest.py
    test_login.py

Separating pages from tests is what makes the pattern easy to follow. Anyone looking for a selector knows exactly where to go. Run pytest from the project folder with python -m pytest, so that the pages package can be imported.

Build a base page

Every page needs the same few operations: open a URL, wait for an element, click and type. Put those once in a base class:

# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

class BasePage:
    BASE_URL = "https://the-internet.herokuapp.com"

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

    def open(self, path):
        self.driver.get(self.BASE_URL + path)

    def find(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):
        element = self.find(locator)
        element.clear()
        element.send_keys(text)

    def text_of(self, locator):
        return self.find(locator).text

Notice that waiting lives here, in one place. Tests no longer need time.sleep or their own explicit waits, and every page object gets the same timing behaviour automatically. Keep this class small, though. It should contain only what truly applies to every page.

Write the page classes

Locators go at the top as class constants, and each public method represents something a user does.

# 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):
    USERNAME = (By.ID, "username")
    PASSWORD = (By.ID, "password")
    SUBMIT = (By.CSS_SELECTOR, "button[type=submit]")
    MESSAGE = (By.ID, "flash")

    def load(self):
        self.open("/login")
        return self

    def login(self, username, password):
        self.type(self.USERNAME, username)
        self.type(self.PASSWORD, password)
        self.click(self.SUBMIT)
        return SecurePage(self.driver)

    def message(self):
        return self.text_of(self.MESSAGE)
# pages/secure_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage

class SecurePage(BasePage):
    MESSAGE = (By.ID, "flash")
    LOGOUT = (By.CSS_SELECTOR, "a[href='/logout']")

    def message(self):
        return self.text_of(self.MESSAGE)

    def logout(self):
        self.click(self.LOGOUT)

Look at what login returns: the page the user lands on. This makes navigation explicit and lets a test read as a journey. It does assume the login worked, so a test for a failed login simply ignores the returned object and keeps using LoginPage. Teams with many failure paths often add a separate login_expecting_error() method to make that intent clear.

Write the tests

The driver setup belongs in a pytest fixture, so each test gets a fresh browser that always closes, even after a failure.

# tests/conftest.py
import pytest
from selenium import webdriver

@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    browser = webdriver.Chrome(options=options)
    yield browser
    browser.quit()
# tests/test_login.py
from pages.login_page import LoginPage

def test_valid_login(driver):
    secure = LoginPage(driver).load().login("tomsmith", "SuperSecretPassword!")
    assert "You logged into a secure area" in secure.message()

def test_wrong_password(driver):
    page = LoginPage(driver).load()
    page.login("tomsmith", "wrong")
    assert "Your password is invalid" in page.message()

Those tests now say what they check, with no selectors in sight. Run them with python -m pytest -v. Because the practice site is public and can be slow or unavailable at times, point real suites at an environment you control.

Tip: Never commit real passwords in test files. The values above are demo credentials the practice site displays publicly. For your own systems, read them from environment variables or your CI secret store.

Before and after

Situation Without POM With POM
The username id changes Edit every test that logs in Edit one constant
Reading a test Selectors and waits everywhere Reads like user steps
Adding a new login test Copy and paste lines Call an existing method
Changing how you wait Change many tests Change the base page
Initial effort Lower Slightly higher

Design rules that keep POM healthy

Using classes alone does not give you the benefits. These habits do:

  1. Keep assertions in tests. A page object reports state, and the test decides if that state is right. This keeps page classes reusable for both positive and negative tests.
  2. Name methods after user intent. Prefer search_for("shoes") to click_search_box_and_type(). Good names survive redesigns.
  3. Do not expose selectors. If a test has to use By.ID, a method is missing from the page object.
  4. Do not build one giant class. A 900-line DashboardPage is as hard to maintain as the mess it replaced.
  5. Keep test data out of page objects. Pass usernames and products in as arguments, so tests stay in control of their inputs.
  6. Prefer composition to deep inheritance. One base page is fine. Long chains of parent classes make it hard to see where behaviour comes from.

Shared components

Modern apps reuse the same header, search bar or data table on many screens. Do not repeat those locators in every page class. Instead, model each as its own component object and attach it to the pages that use it:

class Header(BasePage):
    CART = (By.CSS_SELECTOR, "[data-testid=cart]")

    def open_cart(self):
        self.click(self.CART)

class ProductPage(BasePage):
    def __init__(self, driver):
        super().__init__(driver)
        self.header = Header(driver)

A test can then say product.header.open_cart(). Despite the name, a “page object” does not have to represent a whole page. This example uses a data-testid attribute, which is usually a more stable hook than a styling class.

POM in Playwright

The pattern is not tied to Selenium. Here is the same login page in Playwright for TypeScript. The structure is identical, but it is shorter because Playwright waits automatically, so no base page helper is needed for that.

import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly username: Locator;
  readonly password: Locator;
  readonly submit: Locator;

  constructor(private readonly page: Page) {
    this.username = page.getByLabel('Username');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Login' });
  }

  async login(user: string, pass: string) {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
  }
}

New to the tool? Start with our Playwright tutorial for beginners. The same structure works in Cypress too, so if that is your tool, begin with our Cypress tutorial for beginners.

When to skip it

POM has a cost: more files and one more layer to read. It may not be worth it for a one-off script, a short proof of concept or a suite of two or three tests on a simple page. Add the structure at the point you notice a selector being copied for the second or third time.

Also, remember that POM makes UI tests easier to maintain, not faster or more reliable by itself. A clean suite with too many browser tests is still slow, so keep end-to-end tests focused on critical journeys, as explained in our end-to-end testing best practices.

Final thoughts

The Page Object Model comes down to one rule: each piece of knowledge about a page should live in exactly one place. Start small by moving your login flow into a class, then migrate the pages that break most often. You do not need to rewrite the entire suite in one go, and you will feel the benefit the next time a selector changes.

Frequently asked questions

What is the Page Object Model in Selenium?

It is a design pattern where each page has a class holding its locators and user actions. Selenium tests call those methods instead of finding elements directly.

What is the difference between POM and Page Factory?

POM is the general pattern. Page Factory is a Selenium helper, mainly used in Java, that initialises annotated elements. You can use POM without it.

Should page objects contain assertions?

Generally no. Keep assertions in tests and let page objects return data or state. Checking that a page has loaded correctly is a common, reasonable exception.

Is POM a framework?

No. It is a design pattern, so it needs no library. You can apply it with Selenium, Playwright, Cypress or any other UI automation tool.

Should each page have exactly one class?

Not necessarily. Large pages are easier to manage when split into components, such as a header or results table, each with its own small class.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →