The page object model (POM) puts each page's locators and actions in one class, so tests read like user steps and UI changes are fixed in one place. This guide covers what POM is, why it's a design pattern, working Selenium examples in Java and Python, Page Factory, and best practices.
The page object model (POM) is a way of organizing UI test code where each page, or significant part of a page, gets its own class that holds its locators and the actions a user can take there. Tests call those methods instead of touching the HTML, so when the UI changes, you update one class and the tests keep working.
This guide explains what POM is and why it counts as a design pattern, then builds the same login test twice: in Java with Selenium, TestNG, and Page Factory, and in Python with Selenium and pytest. Both use a public demo site, so you can run them yourself. It ends with Page Factory compared to plain POM, best practices, and how the same idea works in Katalon Studio.
What is the page object model?
In a page object model, a page object is a class that represents one page of the application under test (AUT), or one component of it. It has two jobs:
- Hold the locators for the elements on that page, such as the username field and the login button.
- Offer methods for what a user can do there, such as
login_as(username, password), and return data the test can check.
The test code then reads like a user story: open the login page, log in, check the message. It doesn't contain a single find_element call or CSS selector.
Here's the difference in practice:
Aspect | Without POM | With POM |
|---|---|---|
Where locators live | Inside each test, repeated wherever the page is used | In one page class |
What a test looks like | A list of find, type, and click calls | A few calls such as |
When a locator changes | Edit each test that uses it | Edit one line in the page class |
Readability | Technical steps hide the intent | The intent is the code |
Imagine an e-commerce app with dozens of checkout tests, and a release that changes the "Place order" button's ID. Without POM, you search for and fix each test that uses it. With POM, you change the locator in CheckoutPage and the checkout tests pick it up.
Is the page object model a design pattern?
Yes. Martin Fowler describes it as a pattern in his PageObject article: "A page object wraps an HTML page, or fragment, with an application-specific API." The Selenium project's own page object models documentation also calls it a design pattern for improving test maintenance and reducing duplicated code.
It's a test design pattern rather than one of the classic Gang of Four patterns, and it isn't tied to Selenium or to web pages. Fowler notes the same idea works for any UI technology, which is why teams apply it to mobile screens with Appium too (see our Appium tutorial).
Why use the page object model?
- Maintenance in one place. Each locator lives in one class. A UI change means one edit, not a search across the suite.
- Reuse. Many tests share the same login or search steps. Write them once as page methods and call them wherever they're needed.
- Readable tests. Tests describe behavior (
login_as,add_to_cart) instead of mechanics, so a reviewer can see what's being tested at a glance. - Room to grow. New pages become new classes. Existing tests don't change when you add them.
POM costs a little more up front: the first test takes longer because you write the page classes too. It pays off from the second test that uses the same page. For a short-lived script that touches one page, it can be more structure than you need.
POM is one building block of a test automation framework, alongside test data, reporting, and execution. It also combines well with other approaches, such as a keyword-driven framework in Selenium, where keywords call page object methods.
Core components of a page object model
- Page classes. One per page or significant component. They hold locators and expose actions as methods. They don't assert.
- A base page. An optional parent class with shared helpers: open a URL, wait for an element, type, click. It keeps waits and other plumbing out of every page class.
- Test classes. They create page objects, call their methods, and make the assertions.
- Setup and teardown. A base test class (Java) or a fixture (pytest) starts the browser before each test and quits it after.
- Component objects. Optional classes for repeated parts of a page, such as a header or a product card (covered below).
Page object model in Selenium with Java and TestNG
The example uses the login page at the-internet.herokuapp.com, a public practice site. Valid credentials (tomsmith and SuperSecretPassword!) open a "Secure Area" page with a success message; a wrong password keeps you on the login page with an error. The test checks both outcomes.
This Java version uses Selenium 4 with TestNG, and uses Page Factory (@FindBy and PageFactory.initElements) to declare locators. You need Java 17 or later and Maven, with selenium-java and testng as dependencies. Selenium Manager downloads the matching ChromeDriver for you.
BasePage.java holds the driver, an explicit wait, and the helpers. Calling PageFactory.initElements here wires up the @FindBy fields of every page that extends it.
package com.example.pages;
import java.time.Duration;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public abstract class BasePage {
protected static final String BASE_URL = "https://the-internet.herokuapp.com";
protected final WebDriver driver;
protected final WebDriverWait wait;
protected BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
PageFactory.initElements(driver, this);
}
protected void open(String path) {
driver.get(BASE_URL + path);
}
protected void type(WebElement element, String text) {
wait.until(ExpectedConditions.visibilityOf(element));
element.clear();
element.sendKeys(text);
}
protected void click(WebElement element) {
wait.until(ExpectedConditions.elementToBeClickable(element)).click();
}
protected String textOf(WebElement element) {
return wait.until(ExpectedConditions.visibilityOf(element)).getText();
}
}LoginPage.java declares the locators and exposes two actions. A successful login returns the next page object; a failed one returns the login page, so each outcome has its own method.
package com.example.pages;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
public class LoginPage extends BasePage {
@FindBy(id = "username")
private WebElement usernameField;
@FindBy(id = "password")
private WebElement passwordField;
@FindBy(css = "button[type='submit']")
private WebElement loginButton;
@FindBy(id = "flash")
private WebElement flashMessage;
public LoginPage(WebDriver driver) {
super(driver);
}
public LoginPage load() {
open("/login");
return this;
}
public SecureAreaPage loginAs(String username, String password) {
submit(username, password);
return new SecureAreaPage(driver);
}
public LoginPage loginExpectingError(String username, String password) {
submit(username, password);
return this;
}
public String errorMessage() {
return textOf(flashMessage);
}
private void submit(String username, String password) {
type(usernameField, username);
type(passwordField, password);
click(loginButton);
}
}SecureAreaPage.java waits until the browser has reached the secure page before the test reads anything. Without that wait, the test can read the old page's heading while the new one is still loading.
package com.example.pages;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.ui.ExpectedConditions;
public class SecureAreaPage extends BasePage {
@FindBy(tagName = "h2")
private WebElement heading;
@FindBy(id = "flash")
private WebElement flashMessage;
public SecureAreaPage(WebDriver driver) {
super(driver);
// Wait until the browser has actually reached this page.
wait.until(ExpectedConditions.urlContains("/secure"));
}
public String heading() {
return textOf(heading);
}
public String flashMessage() {
return textOf(flashMessage);
}
}BaseTest.java and LoginTest.java handle the browser and the checks. Setup and teardown live in the base test; the assertions live only in the test class.
package com.example.tests;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
public abstract class BaseTest {
protected WebDriver driver;
@BeforeMethod
public void setUp() {
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
driver = new ChromeDriver(options);
}
@AfterMethod(alwaysRun = true)
public void tearDown() {
if (driver != null) {
driver.quit();
}
}
}package com.example.tests;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertTrue;
import com.example.pages.LoginPage;
import com.example.pages.SecureAreaPage;
import org.testng.annotations.Test;
public class LoginTest extends BaseTest {
@Test
public void validLoginOpensSecureArea() {
SecureAreaPage secureArea = new LoginPage(driver)
.load()
.loginAs("tomsmith", "SuperSecretPassword!");
assertEquals(secureArea.heading(), "Secure Area");
assertTrue(secureArea.flashMessage().contains("You logged into a secure area!"));
}
@Test
public void invalidPasswordShowsError() {
LoginPage loginPage = new LoginPage(driver)
.load()
.loginExpectingError("tomsmith", "wrong-password");
assertTrue(loginPage.errorMessage().contains("Your password is invalid!"));
}
}Run it with mvn test. Remove the --headless=new argument if you want to watch the browser.
Page object model in Python with Selenium and pytest
Python's Selenium bindings don't include a Page Factory, so Python page objects store locators as (By, value) tuples and look elements up through the base page. The flow is the same as the Java version. Install the packages with pip install selenium pytest, then use this layout:
pages/base_page.py,pages/login_page.py,pages/secure_area_page.pytests/conftest.py,tests/test_login.pypytest.iniwithpythonpath = .so the tests can importpages
base_page.py wraps the explicit waits, so page classes don't call find_element directly.
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
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).textlogin_page.py and secure_area_page.py are the page objects.
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
from pages.secure_area_page import SecureAreaPage
class LoginPage(BasePage):
USERNAME = (By.ID, "username")
PASSWORD = (By.ID, "password")
LOGIN_BUTTON = (By.CSS_SELECTOR, "button[type='submit']")
FLASH = (By.ID, "flash")
def load(self):
self.open("/login")
return self
def login_as(self, username, password):
self._submit(username, password)
return SecureAreaPage(self.driver)
def login_expecting_error(self, username, password):
self._submit(username, password)
return self
def error_message(self):
return self.text_of(self.FLASH)
def _submit(self, username, password):
self.type(self.USERNAME, username)
self.type(self.PASSWORD, password)
self.click(self.LOGIN_BUTTON)from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from pages.base_page import BasePage
class SecureAreaPage(BasePage):
HEADING = (By.TAG_NAME, "h2")
FLASH = (By.ID, "flash")
def __init__(self, driver, timeout=10):
super().__init__(driver, timeout)
# Wait until the browser has actually reached this page.
self.wait.until(EC.url_contains("/secure"))
def heading(self):
return self.text_of(self.HEADING)
def flash_message(self):
return self.text_of(self.FLASH)conftest.py and test_login.py start a headless Chrome for each test and make the assertions.
# tests/conftest.py
import pytest
from selenium import webdriver
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()# tests/test_login.py
from pages.login_page import LoginPage
def test_valid_login_opens_secure_area(driver):
secure_area = LoginPage(driver).load().login_as("tomsmith", "SuperSecretPassword!")
assert secure_area.heading() == "Secure Area"
assert "You logged into a secure area!" in secure_area.flash_message()
def test_invalid_password_shows_error(driver):
login_page = LoginPage(driver).load().login_expecting_error("tomsmith", "wrong-password")
assert "Your password is invalid!" in login_page.error_message()Run pytest -v from the project folder. Both tests should pass: one for the valid login and one for the wrong password. If you're new to Selenium setup, our Selenium testing tutorial covers installation and a first script.
Page Factory vs page object model
Page Factory isn't an alternative to POM. It's a helper in Selenium's Java support package that fills in a page object's element fields for you. You can build a page object model with it, as in the Java example, or without it, as in the Python example.
Page object model (plain) | Page Factory | |
|---|---|---|
What it is | A design pattern for organizing test code | A Selenium Java class that initializes page object fields |
How locators are declared |
|
|
Setup | None beyond the class | Call |
When elements are found | When a method looks them up | Lazily, through a proxy, each time the field is used ( |
Explicit waits | Wait on a locator, for example | Wait on the element, for example |
Languages | Any language Selenium supports | Java; Python's bindings have no Page Factory |
Pick one style per project. Plain By locators make waits and dynamic locators easier to write; Page Factory gives shorter, declarative page classes in Java.
Page component objects
Fowler points out that page objects don't have to map one class to one page. They work best for the significant parts of a page. A page showing a list of albums could have an album list object that contains several album objects, plus a header object and a footer object.
The same applies to a product grid. A ProductsPage can return a list of ProductCard component objects, each with its own name, price, and "Add to cart" action. The important detail is scope: a component looks up its locators inside its own root element (in Python, root.find_element(...)), not across the whole page, or each card returns the first product's name.
Components keep page classes small and let you reuse a header or search bar object on each page that has one.
Page object model best practices
- Keep assertions in tests. Page objects return data; tests decide whether it's right. Both Fowler and the selenium.dev docs recommend this. The one check that belongs in a page object is that the page has loaded, done when the object is created, as
SecureAreaPagedoes. - Use explicit waits in the base page. Put waits in shared helpers so no page class calls
find_elementwithout one. Avoid fixed sleeps. Our guide to explicit wait in Selenium goes deeper, and missing waits are a common cause of flaky tests. - Prefer stable locators. Use IDs, names, or dedicated test attributes first, and short CSS selectors after that. Keep long, position-based XPath as a last resort; see how to find XPath in Selenium for writing ones that hold up.
- Model different outcomes as different methods.
login_asreturns the secure page;login_expecting_errorreturns the login page. The test states which result it expects. - Return page objects after navigation. If an action opens another page, return that page's object, so the test's flow is visible in the code.
- Name methods after user intent.
login_as(user, password)says more thantype_username,type_password, andclick_buttonin a row. - Don't expose the driver or raw elements. Tests that reach into
page.driverbring back the coupling POM is meant to remove. - Keep the base page small. Shared helpers only. Business steps belong in page classes, not in a base class that every page inherits.
How to use the page object model in Katalon Studio
Katalon Studio builds the same idea into the tool. Studio's docs state that a test object "is designed to follow the Page Object Model (POM) pattern" to represent the structure of the application under test. Instead of writing page classes and locator fields by hand, you store elements as test objects in the Object Repository and reuse them across tests.
- Capture test objects. Use Spy Web or Web Recorder Plus to capture elements while you click through the app. Studio saves each one as a test object in the Object Repository, grouped by page, with its locator (XPath, attributes, CSS, image, or Smart Locator). You can also create test objects by hand.
- Reuse what's already there. When Web Recorder Plus opens, it loads your project's Object Repository and flags recorded objects that duplicate an existing one, so the repository stays one source of truth per element.
- Handle dynamic elements with variables. Add a variable such as
${id}to a test object's locator and pass the value from the test, instead of creating one object per row or option. - Wrap page actions in custom keywords. Write a custom keyword class in Groovy or Java, for example a login keyword that sets the username and password test objects and clicks the button. Test cases call it from the manual view or through
CustomKeywordsin script view. As in POM, keep assertions in the test case, not in the keyword. - Let locators heal. When a locator breaks after a UI change, Studio's self-healing tries the other known locators for that test object and suggests the one that worked. Self-healing needs an active Katalon Studio license.
If you already have Selenium tests, you can keep them. Studio can run and maintain existing Selenium, TestNG, and JUnit projects with Katalon True (the docs list the supported versions), so page objects you've written in Java can sit next to tests built from the Object Repository.
Frequently asked questions
POM is a design pattern where each page, or significant part of a page, is a class that holds its locators and the actions a user can take there. Tests call methods such as login_as(user, password) instead of touching HTML, so a UI change means one edit in the page class rather than fixes across many tests.
Page classes (locators and actions), test classes (steps and assertions), an optional base page for shared waits and helpers, setup and teardown in a base test or pytest fixture, and optional component objects for repeated sections such as headers or product cards.
Create a base page with explicit waits, write one page class per page with its locators and action methods, return the next page object when an action navigates, and write short tests that call those methods and make the assertions. In Java you can declare locators with Page Factory's @FindBy.
Keep assertions in tests, put explicit waits in the base page, prefer stable locators such as IDs, model different outcomes as different methods, return page objects after navigation, name methods after user intent, and don't expose the driver from page classes.
Katalon Studio's test objects follow the POM pattern. You capture elements into the Object Repository with Spy Web or Web Recorder Plus, reuse them across test cases, and wrap page-level actions in custom keywords written in Groovy or Java. Existing Selenium, TestNG, and JUnit projects can also run in Studio.
Yes. Martin Fowler describes the page object as a pattern that wraps an HTML page or fragment with an application-specific API, and the Selenium documentation calls the page object model a design pattern for improving test maintenance and reducing duplicated code. It's a test design pattern, not one of the Gang of Four patterns.
POM is the design pattern: page classes that hold locators and actions. Page Factory is a Selenium Java helper that initializes a page object's @FindBy fields with PageFactory.initElements. You can build POM with or without Page Factory, and Python's Selenium bindings don't include it.

Test everything. Ship with confidence.
From generating test cases to spotting failures, Katalon's AI works across your entire pipeline, automatically.

