Page Object Model (POM)

Look back at the last two lessons: By.id("email"), By.id("password"), and By.id("loginBtn") appear, hardcoded, inside every single test method that touches the login page. Now imagine RecipeBox's frontend team renames id="loginBtn" to id="submitLoginBtn". Every test class with that locator baked in breaks, and you have to hunt down and fix each occurrence by hand. That's the maintenance problem the Page Object Model (POM) exists to solve.

The Problem: Locators Scattered Everywhere

Without POMWith POM
Every test class calls driver.findElement(By.id("loginBtn")) directlyOnly LoginPage knows that locator exists
A UI change means editing N test filesA UI change means editing 1 page class
Tests read like raw browser commandsTests read like business actions: loginPage.login(email, pass)
No reuse — copy-pasted findElement calls everywherePage methods are reused across every test that needs that page
Note: This isn't just theoretical tidiness. On a real project with 200+ tests, "the login button's id changed" turning into "update 1 file" instead of "grep the codebase and fix 40 test methods" is the difference between a five-minute fix and a half-day one.

The Pattern: One Class Per Page

Each meaningful screen in the application gets its own Java class. That class holds:

  • The locators for that page's elements (usually as private fields)
  • Action methods that perform a meaningful operation on that page (e.g. login(...), addRecipe(...), search(...))
  • Optionally, methods that return information from the page (e.g. getErrorMessage())

Test classes then talk to the page through these methods only — they never call driver.findElement() directly for that page's elements again.

Worked Example: LoginPage

public class LoginPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    // Locators — the ONLY place these ids/classes should appear in the whole framework
    private final By emailField   = By.id("email");
    private final By passwordField = By.id("password");
    private final By loginButton  = By.id("loginBtn");
    private final By errorBanner  = By.className("login-error");

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    public void open() {
        driver.get("https://recipebox.example.com/login");
    }

    // The action method every test calls instead of touching findElement directly
    public void login(String email, String password) {
        driver.findElement(emailField).clear();
        driver.findElement(emailField).sendKeys(email);
        driver.findElement(passwordField).clear();
        driver.findElement(passwordField).sendKeys(password);
        driver.findElement(loginButton).click();
    }

    public String getErrorMessage() {
        WebElement error = wait.until(ExpectedConditions.visibilityOfElementLocated(errorBanner));
        return error.getText();
    }

    public boolean isLoginSuccessful() {
        wait.until(ExpectedConditions.urlContains("/recipes"));
        return driver.getCurrentUrl().contains("/recipes");
    }
}

Using LoginPage From a Test Class

public class RecipeBoxLoginPomTest {

    private WebDriver driver;
    private LoginPage loginPage;

    @BeforeMethod
    public void setUp() {
        driver = new ChromeDriver();
        loginPage = new LoginPage(driver);
        loginPage.open();
    }

    @Test
    public void validLoginRedirectsToRecipeList() {
        loginPage.login("tester@example.com", "Test@1234");
        Assert.assertTrue(loginPage.isLoginSuccessful(),
            "Valid credentials should redirect to the recipe list");
    }

    @Test
    public void wrongPasswordShowsError() {
        loginPage.login("tester@example.com", "WrongPassword");
        Assert.assertEquals(loginPage.getErrorMessage(), "Invalid email or password.");
    }

    @AfterMethod
    public void tearDown() {
        if (driver != null) driver.quit();
    }
}

Notice what disappeared: no By.id(...), no raw driver.findElement(...) anywhere in the test class. If RecipeBox renames loginBtn to submitLoginBtn tomorrow, you change one line inside LoginPage and every test that uses it keeps working.

Scaling the Pattern: AddRecipePage

The same shape applies to every other screen — here's the skeleton for RecipeBox's "Add Recipe" page:

public class AddRecipePage {

    private final WebDriver driver;

    private final By titleField      = By.id("recipeTitle");
    private final By categoryDropdown = By.id("categoryDropdown");
    private final By saveButton      = By.id("saveRecipeBtn");

    public AddRecipePage(WebDriver driver) {
        this.driver = driver;
    }

    public void addRecipe(String title, String category) {
        driver.findElement(titleField).sendKeys(title);
        driver.findElement(categoryDropdown).sendKeys(category);
        driver.findElement(saveButton).click();
    }
}
Rules of thumb for good page objects:
  • A page object should never contain assertions — that stays in the test class. The page class exposes state (getErrorMessage()), the test decides if that state is correct.
  • Action methods should represent a real user action (login, addRecipe, searchFor) — not a 1:1 wrapper around every single findElement call.
  • Locators are private. Nothing outside the page class should know or care what a locator actually is.
Next up: Typing driver.findElement(...) inside every page-object method still repeats itself. Head to Page Factory to see Selenium's @FindBy annotation and PageFactory.initElements(), which remove that boilerplate.