Page Object Model in Selenium: A Guide With Java and Python Examples

page object model

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.

Key takeaways
The 30-second version of this article
The page object model puts each page's locators and actions in one class, so tests read like user steps and UI changes are fixed in one place.
POM is a test design pattern, described by Martin Fowler and recommended in the Selenium documentation.
Page objects return data and page objects; tests make the assertions. The one check inside a page object is that the page has loaded.
Page Factory is an optional Java helper for declaring locators with @FindBy, not an alternative to POM; Python uses locator tuples instead.
Katalon Studio applies the same pattern through test objects in the Object Repository and custom keywords, and can keep running existing Selenium projects.

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 login_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

  1. Page classes. One per page or significant component. They hold locators and expose actions as methods. They don't assert.
  2. 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.
  3. Test classes. They create page objects, call their methods, and make the assertions.
  4. Setup and teardown. A base test class (Java) or a fixture (pytest) starts the browser before each test and quits it after.
  5. 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.py
  • tests/conftest.py, tests/test_login.py
  • pytest.ini with pythonpath = . so the tests can import pages

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).text

login_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

By fields in Java, (By, value) tuples in Python

@FindBy and @FindBys annotations on WebElement fields

Setup

None beyond the class

Call PageFactory.initElements(driver, this)

When elements are found

When a method looks them up

Lazily, through a proxy, each time the field is used (@CacheLookup keeps the first result)

Explicit waits

Wait on a locator, for example visibility_of_element_located

Wait on the element, for example ExpectedConditions.visibilityOf(element)

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 SecureAreaPage does.
  • Use explicit waits in the base page. Put waits in shared helpers so no page class calls find_element without 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_as returns the secure page; login_expecting_error returns 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 than type_username, type_password, and click_button in a row.
  • Don't expose the driver or raw elements. Tests that reach into page.driver bring 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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 CustomKeywords in script view. As in POM, keep assertions in the test case, not in the keyword.
  5. 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

Huyen Nguyen
Huyen NguyenTechnical Writer, Katalon

Huyen Nguyen is an experienced technical writer in the software testing industry. With strong technical expertise and a deep understanding of Katalon products, she creates clear, practical guides that support testers at every skill level.

Connect on LinkedIn →
True Platform has arrived

Test everything. Ship with confidence.

From generating test cases to spotting failures, Katalon's AI works across your entire pipeline, automatically.

The Next Evolution of Manual Testing: Introducing Katalon AI Test Runner

Introducing Katalon AI Test Runner. Watch it run manual test cases live, with evidence for every pass and fail.

Live webinar
9:00 to 10:00 AM EST · Live online
Alex MartinsVP of Strategy
Gia EspositoSenior Solutions Engineer
Save your seat