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 callsAssert.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
WebDriverpublic on the page object. It's tempting — once the test has the page, the test can also do rawfindElementcalls. Then it does, and the encapsulation is gone. Keep the driverprivate finaland force everything through page methods. - One mega-PageObject per app. A
MyAppPagewith 50 locators and 100 methods is a god-object that no one wants to touch. One class per page (or per major component). WhenLoginPage.javaexceeds ~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.
- Create
src/test/java/com/mycompany/tests/pages/and add theLoginPageclass from this lesson. AddInventoryPagetoo. - 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. - Force a selector rename. In Sauce Demo's HTML (you can do this via DevTools live edits for demonstration), pretend
id="user-name"becomesid="username". Update the constant inLoginPage. Confirm every test usingLoginPagestill passes (after restoring the real DOM, of course). Now imagine doing the same change without POM — count how many files would have needed editing. - Build a Header component object. Create
pages/components/Header.javawith locators for the burger menu, cart link, and cart badge. Inject aHeaderfield into bothInventoryPageand any other authenticated-area page object. Tests that interact with the header now do so viainventoryPage.header().openCart(). - Return-type discipline. In
LoginPage, makeloginAs(...)returnInventoryPage. 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. - 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.