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.
- 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:
- 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.
- Name methods after user intent. Prefer
search_for("shoes")toclick_search_box_and_type(). Good names survive redesigns. - Do not expose selectors. If a test has to use
By.ID, a method is missing from the page object. - Do not build one giant class. A 900-line
DashboardPageis as hard to maintain as the mess it replaced. - Keep test data out of page objects. Pass usernames and products in as arguments, so tests stay in control of their inputs.
- 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
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.