Page Object Model Fundamentals

9 min read

You've written 30 Selenium tests. Now the design team rebrands the "Sign in" button to "Log in." Forty-seven tests break — every test that ever logged in had By.linkText("Sign in") hardcoded somewhere. You spend a Friday afternoon doing a 47-file find-and-replace. This is the moment every Selenium engineer realises they need the Page Object Model. POM separates what the test does (the intent) from how the page works (the locators and interactions). One change in one place, every test stays green. This lesson is the long-form case for POM, the minimal-correct implementation, and the rules that keep page objects from becoming the next mess.

The problem POM solves

Without POM, locators leak everywhere:

@Test
public void shouldLoginValidUser() {
    driver.findElement(By.id("user-name")).sendKeys("standard_user");
    driver.findElement(By.id("password")).sendKeys("secret_sauce");
    driver.findElement(By.id("login-button")).click();
    Assert.assertTrue(driver.getCurrentUrl().contains("/inventory.html"));
}
 
@Test
public void shouldLoginInvalidUser() {
    driver.findElement(By.id("user-name")).sendKeys("wrong_user");
    driver.findElement(By.id("password")).sendKeys("wrong_password");
    driver.findElement(By.id("login-button")).click();
    String error = driver.findElement(By.cssSelector("[data-test='error']")).getText();
    Assert.assertTrue(error.contains("do not match"));
}

Two tests, four By.id("user-name") references, four By.id("password") references. When the dev team renames id="user-name" to id="username", you change four lines. With twenty tests, you change twenty. With two hundred, you don't.

POM puts the locators behind a class. Tests express intent — "log in as this user" — and never see the underlying selector.

The minimal POM

Two files, one rule. The rule: page objects encapsulate locators and interactions; tests encapsulate intent and assertions.

src/test/java/com/mycompany/tests/pages/LoginPage.java:

package com.mycompany.tests.pages;
 
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
 
public class LoginPage {
 
    private final WebDriver driver;
 
    // All locators in one place — change here, every test updates
    private final By usernameInput = By.id("user-name");
    private final By passwordInput = By.id("password");
    private final By loginButton   = By.id("login-button");
    private final By errorMessage  = By.cssSelector("[data-test='error']");
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }
 
    public LoginPage navigateTo() {
        driver.get("https://www.saucedemo.com");
        return this;
    }
 
    public void loginAs(String username, String password) {
        driver.findElement(usernameInput).sendKeys(username);
        driver.findElement(passwordInput).sendKeys(password);
        driver.findElement(loginButton).click();
    }
 
    public String getErrorText() {
        return driver.findElement(errorMessage).getText();
    }
 
    public boolean isErrorDisplayed() {
        return !driver.findElements(errorMessage).isEmpty()
            && driver.findElement(errorMessage).isDisplayed();
    }
}

src/test/java/com/mycompany/tests/tests/LoginPomTest.java:

package com.mycompany.tests.tests;
 
import com.mycompany.tests.base.BaseTest;
import com.mycompany.tests.pages.LoginPage;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
 
public class LoginPomTest extends BaseTest {
 
    private LoginPage loginPage;
 
    @BeforeMethod
    public void setUpPage() {
        loginPage = new LoginPage(driver);
        loginPage.navigateTo();
    }
 
    @Test
    public void shouldLoginValidUser() {
        loginPage.loginAs("standard_user", "secret_sauce");
        Assert.assertTrue(driver.getCurrentUrl().contains("/inventory.html"));
    }
 
    @Test
    public void shouldShowErrorForInvalidUser() {
        loginPage.loginAs("wrong_user", "wrong_password");
        Assert.assertTrue(loginPage.isErrorDisplayed());
        Assert.assertTrue(loginPage.getErrorText().contains("do not match"));
    }
}

Read the test file out loud: "navigate to login page; log in as standard_user with secret_sauce; assert current URL contains /inventory.html." That's English. The locators don't appear. The findElement calls don't appear. The intent is right there.

When id="user-name" becomes id="username" next sprint, you change one line in LoginPage.java. Every test using the page is correct without modification.

Without POM vs with POM

The same test, two architectures

Without POM

  • Selectors hardcoded in every test

  • By.id("user-name") repeats in 30+ tests

  • Tests read like findElement chains

  • Selector renames cause 30-file diffs

  • Hard to onboard new engineers

  • Bug fixes touch many files

With POM

  • Selectors centralised in LoginPage class

  • By.id("user-name") appears exactly once

  • Tests read like English: loginPage.loginAs(...)

  • Selector renames are 1-line changes

  • New engineers learn page objects, not selectors

  • Bugs fix in one place

The rules — what belongs in a page object, and what doesn't

The discipline is what makes POM scale. Three rules:

1. Page objects encapsulate locators AND interactions; they don't assert. A page method does — clickLogin, enterUsername, getErrorText. It exposes state via getters (getErrorText(), isErrorDisplayed()). Assertions live in the test, because different tests want different assertions about the same state.

// Bad — assertion inside the page object
public void loginAs(String u, String p) {
    // ...
    Assert.assertTrue(driver.getCurrentUrl().contains("/inventory.html"));    // ❌
}
 
// Good — page does the action, returns the relevant state, test asserts
public void loginAs(String u, String p) { /* fill + click */ }
public String getCurrentUrl() { return driver.getCurrentUrl(); }

2. Page methods that navigate to a new page should return the next page object. This makes the test a pipeline of pages, and the compiler enforces correct flow:

public InventoryPage loginAs(String username, String password) {
    driver.findElement(usernameInput).sendKeys(username);
    driver.findElement(passwordInput).sendKeys(password);
    driver.findElement(loginButton).click();
    return new InventoryPage(driver);
}
 
// In the test
InventoryPage inventory = loginPage.loginAs("standard_user", "secret_sauce");
inventory.addToCart("Sauce Labs Backpack");

After loginAs(...), the test has an InventoryPage reference and can only call inventory methods. A typo like inventory.loginAs(...) is a compile error. Lesson 4 of this chapter goes deep on the fluent style.

3. One page object per page, plus widget objects for shared components. A Header widget (logo, search, account menu) appears on every page — it's its own class, embedded in or returned by every page. Don't duplicate the header's locators across thirty page objects.

Where to put the files

The folder layout from chapter 1's Maven setup pays off:

src/test/java/com/mycompany/tests/
├── base/
│   ├── BaseTest.java         ← @BeforeMethod creates driver; @AfterMethod quits
│   └── BasePage.java         ← shared helpers (lesson 3 — coming)
├── pages/
│   ├── LoginPage.java
│   ├── InventoryPage.java
│   ├── CartPage.java
│   └── components/
│       └── Header.java       ← shared header widget
├── tests/
│   ├── LoginTest.java
│   └── CheckoutTest.java
└── ...

Tests under tests/, page objects under pages/, base classes under base/. New engineers find their way around the project in five minutes. Component classes (header, footer, modal dialogs) live in pages/components/ — they're page objects too, just for partial pages.

A second page object — proving the pattern scales

// src/test/java/com/mycompany/tests/pages/InventoryPage.java
package com.mycompany.tests.pages;
 
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
 
import java.util.List;
 
public class InventoryPage {
 
    private final WebDriver driver;
 
    private final By inventoryItem = By.cssSelector("[data-test='inventory-item']");
    private final By cartBadge     = By.cssSelector(".shopping_cart_badge");
    private final By cartLink      = By.cssSelector(".shopping_cart_link");
 
    public InventoryPage(WebDriver driver) { this.driver = driver; }
 
    public int productCount() {
        return driver.findElements(inventoryItem).size();
    }
 
    public void addToCartByName(String productName) {
        // Locate the card by visible name, then click its Add-to-cart button
        WebElement card = driver.findElement(
            By.xpath("//div[@class='inventory_item' and .//div[normalize-space()='" + productName + "']]")
        );
        card.findElement(By.xpath(".//button[normalize-space()='Add to cart']")).click();
    }
 
    public String cartBadgeText() {
        List<WebElement> badges = driver.findElements(cartBadge);
        return badges.isEmpty() ? "" : badges.get(0).getText();
    }
 
    public CartPage openCart() {
        driver.findElement(cartLink).click();
        return new CartPage(driver);
    }
}

A new page, same pattern. Locators private, interactions public, navigation methods return the next page object. The test doesn't change shape:

@Test
public void shouldAddItemToCart() {
    InventoryPage inventory = loginPage.loginAs("standard_user", "secret_sauce");
    inventory.addToCartByName("Sauce Labs Backpack");
    Assert.assertEquals(inventory.cartBadgeText(), "1");
}

That's the whole shape. Add a CheckoutPage, a CartPage, an OrderConfirmationPage — same recipe each time. By page-15 your test files are 80% English and 20% page-method calls; by page-50 your locators have all moved exactly once.

Comparison with Cypress and Playwright

// Cypress — page objects are also recommended in TS
class LoginPage {
  private username = "[data-testid='user-name']";
  private password = "[data-testid='password']";
  private loginBtn = "[data-testid='login-button']";
 
  loginAs(u: string, p: string) {
    cy.get(this.username).type(u);
    cy.get(this.password).type(p);
    cy.get(this.loginBtn).click();
  }
}
 
// Playwright — same shape, with locator() returning a chainable object
class LoginPage {
  constructor(private page: Page) {}
  async loginAs(u: string, p: string) {
    await this.page.getByTestId("user-name").fill(u);
    await this.page.getByTestId("password").fill(p);
    await this.page.getByTestId("login-button").click();
  }
}

POM is universal across frameworks. The Java version has slightly more ceremony (constructors, types, semicolons) but the structure is identical. Once you've internalised the pattern in one framework, you can pick it up in another in an afternoon.

⚠️ Common mistakes

  • Putting assertions inside page objects. A loginPage.shouldBeLoggedIn() that calls Assert.assertTrue(...) couples the page object (a reusable abstraction) to a specific test's expectations. Different tests want different assertions about the same page state. Expose state via getters (getCurrentUrl(), isWelcomeBannerVisible()); let tests assert.
  • Making WebDriver public on the page object. It's tempting — once the test has the page, the test can also do raw findElement calls. Then it does, and the encapsulation is gone. Keep the driver private final and force everything through page methods.
  • One mega-PageObject per app. A MyAppPage with 50 locators and 100 methods is a god-object that no one wants to touch. One class per page (or per major component). When LoginPage.java exceeds ~200 lines, the page itself probably has too many responsibilities and should be split — same as any Java class.

🎯 Practice task

Convert your existing tests to POM. 35–45 minutes.

  1. Create src/test/java/com/mycompany/tests/pages/ and add the LoginPage class from this lesson. Add InventoryPage too.
  2. Rewrite at least three of your existing Selenium tests as POM-based versions: LoginPomTest, InventoryPomTest, plus one of your own choosing. Run them; all should pass.
  3. Force a selector rename. In Sauce Demo's HTML (you can do this via DevTools live edits for demonstration), pretend id="user-name" becomes id="username". Update the constant in LoginPage. Confirm every test using LoginPage still passes (after restoring the real DOM, of course). Now imagine doing the same change without POM — count how many files would have needed editing.
  4. Build a Header component object. Create pages/components/Header.java with locators for the burger menu, cart link, and cart badge. Inject a Header field into both InventoryPage and any other authenticated-area page object. Tests that interact with the header now do so via inventoryPage.header().openCart().
  5. Return-type discipline. In LoginPage, make loginAs(...) return InventoryPage. Update calling tests to assign the return value. The test now reads as a pipeline: InventoryPage inventory = new LoginPage(driver).navigateTo().loginAs(...);. Also note that you can't accidentally call inventory methods before login — the compiler catches it.
  6. Stretch — POM smell test. Open one of your page objects. Count the public methods. If there are more than ~15, the page is probably doing too much. Identify the responsibilities and split into smaller page objects — for instance, a checkout page might split into ShippingPage, PaymentPage, ConfirmationPage. Refactor; tests update with at most a few lines.

Next lesson: Selenium's built-in POM helper — Page Factory and @FindBy. We'll cover what it adds, where it shines, and the surprisingly common cases where rolling your own with plain By constants is actually cleaner.

// tip to track lessons you complete and pick up where you left off across devices.