Page Factory — @FindBy Annotations

9 min read

The LoginPage you wrote in the previous lesson kept locators as private final By usernameInput = By.id("user-name"); constants. That works fine. Selenium also ships Page Factory — a built-in alternative that uses Java annotations on WebElement fields to describe the same locators in a more declarative shape. This lesson covers Page Factory's mechanics, when its declarative style genuinely earns its keep, and the honest case for sticking with manual By constants on dynamic SPAs. Both styles are valid; knowing the trade-offs lets you pick on purpose.

Page Factory in one example

Convert the LoginPage from the previous lesson to Page Factory:

package com.mycompany.tests.pages;
 
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
 
public class LoginPage {
 
    private final WebDriver driver;
 
    @FindBy(id = "user-name")    private WebElement usernameInput;
    @FindBy(id = "password")     private WebElement passwordInput;
    @FindBy(id = "login-button") private WebElement loginButton;
    @FindBy(css = "[data-test='error']") private WebElement errorMessage;
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
        PageFactory.initElements(driver, this);   // REQUIRED — wires up the @FindBy fields
    }
 
    public LoginPage navigateTo() {
        driver.get("https://www.saucedemo.com");
        return this;
    }
 
    public void loginAs(String username, String password) {
        usernameInput.sendKeys(username);
        passwordInput.sendKeys(password);
        loginButton.click();
    }
 
    public String getErrorText() {
        return errorMessage.getText();
    }
}

Three things happened compared to the manual-By version:

  1. Locators became annotations. By.id("user-name")@FindBy(id = "user-name").
  2. Fields became WebElement directly. No more driver.findElement(usernameInput) — you call .sendKeys(...) on the field as if it were the element itself.
  3. The constructor got a new line. PageFactory.initElements(driver, this); is mandatory — it's what populates those WebElement fields. Forget it and every interaction throws NullPointerException.

The test code is unchanged — loginPage.loginAs(...) works exactly the same. The internal mechanics are different.

How Page Factory actually works

PageFactory.initElements(driver, this) doesn't immediately call findElement for every annotated field. Instead, it creates a proxy for each WebElement field. The proxy holds a reference to the locator, not to a real element. When you call .sendKeys(...) on the proxy, it triggers a findElement at that moment — a small but important detail.

That gives Page Factory a kind of just-in-time element resolution. The element is fetched fresh each time you interact with it, which sounds like it should help with StaleElementReferenceException. In practice, the help is partial — once the proxy resolves to a concrete element during a single call, it's still vulnerable to the DOM changing under it during that call. We'll come back to this in the trade-offs.

The @FindBy variants

@FindBy mirrors the By factories from chapter 2:

@FindBy(id = "email")
@FindBy(name = "email")
@FindBy(css = ".submit-btn")
@FindBy(xpath = "//button[@type='submit']")
@FindBy(className = "error")
@FindBy(linkText = "Login")
@FindBy(partialLinkText = "Log")
@FindBy(tagName = "button")

Two compound annotations widen the shape:

// AND — both conditions must match
@FindBys({
    @FindBy(className = "form-group"),
    @FindBy(tagName = "input")
})
private WebElement formGroupInput;
 
// OR — any condition can match
@FindAll({
    @FindBy(id = "submit"),
    @FindBy(css = "[data-testid='submit']")
})
private WebElement submitButton;

@FindBys is rare — when both conditions stack you usually have a direct CSS selector that does the same thing. @FindAll shows up occasionally during migrations, when a page has both a legacy id and a new data-testid and you don't know which is present.

Lists of elements

Page Factory handles List<WebElement> automatically:

@FindBy(css = "[data-test='inventory-item']")
private List<WebElement> productCards;
 
public int productCount() { return productCards.size(); }

The proxy for a list re-evaluates on every call, so productCards.size() reflects whatever's currently in the DOM. Same for any access — it's not a snapshot.

Manual locators vs Page Factory — the trade-offs

Manual By constants vs Page Factory

Manual By constants

  • private final By login = By.id("login");

  • Re-found on every interaction — minimum staleness risk

  • Easy to compose with explicit waits inline

  • Locators visible as string literals — easy to grep

  • Slightly more verbose: driver.findElement(login).click()

Page Factory @FindBy

  • @FindBy(id = "login") private WebElement login;

  • Declarative — annotations make pages easy to scan

  • PageFactory.initElements(driver, this) needed in constructor

  • Field name doubles as documentation

  • On highly-dynamic SPAs, less compose-friendly with waits

The honest summary in 2026:

  • Page Factory is fine on stable, server-rendered pages. Login pages, traditional forms, admin tools. The annotation style is genuinely cleaner.
  • Manual By constants tend to win on heavy SPAs. Adding an explicit wait inline (wait.until(elementToBeClickable(loginButton)).click()) is a one-liner with By and awkward with Page Factory's pre-fetched fields. Many React/Vue codebases standardise on manual By for that reason.
  • There's no objectively-correct choice. Pick one style per project and stick with it. Mixing both inside a single page class makes the code read inconsistently.

A complete Page Factory test

package com.mycompany.tests.tests;
 
import com.mycompany.tests.base.BaseTest;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.testng.Assert;
import org.testng.annotations.Test;
 
import java.util.List;
 
public class PageFactoryTest extends BaseTest {
 
    public static class SauceLoginPage {
        @FindBy(id = "user-name")    WebElement username;
        @FindBy(id = "password")     WebElement password;
        @FindBy(id = "login-button") WebElement loginBtn;
 
        public SauceLoginPage(WebDriver driver) {
            PageFactory.initElements(driver, this);
        }
 
        public void loginAs(String u, String p) {
            username.sendKeys(u);
            password.sendKeys(p);
            loginBtn.click();
        }
    }
 
    public static class SauceInventoryPage {
        @FindBy(css = "[data-test='inventory-item']") List<WebElement> products;
 
        public SauceInventoryPage(WebDriver driver) { PageFactory.initElements(driver, this); }
 
        public int productCount() { return products.size(); }
    }
 
    @Test
    public void shouldShowSixProductsAfterLogin() {
        driver.get("https://www.saucedemo.com");
        new SauceLoginPage(driver).loginAs("standard_user", "secret_sauce");
        Assert.assertEquals(new SauceInventoryPage(driver).productCount(), 6);
    }
}

Two page classes, one test, every interaction goes through annotated fields. Run it; it passes.

When manual locators win — a worked example

A SPA-heavy page where elements re-render constantly:

// Page Factory — works most of the time, occasionally throws stale
public class DynamicCartPagePF {
    @FindBy(css = ".cart-item .price") private List<WebElement> prices;
 
    public BigDecimal totalPrice() {
        return prices.stream()
            .map(p -> new BigDecimal(p.getText().replace("$", "")))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
 
// Manual By — explicit wait + re-find each time, no staleness
public class DynamicCartPage extends BasePage {
    private final By prices = By.cssSelector(".cart-item .price");
 
    public BigDecimal totalPrice() {
        return wait.until(d -> {
            List<WebElement> els = d.findElements(prices);
            return els.size() >= 1 ? els : null;    // wait for at least one
        }).stream()
            .map(p -> new BigDecimal(p.getText().replace("$", "")))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

The manual version composes the wait into the find. The Page Factory version pre-fetches and may need an external wait around it. On a flaky cart page that re-renders three times during the lifetime of the test, the manual version is materially more reliable.

Page Factory and modern Selenium

Page Factory has been around since Selenium 2 and is no longer the most-actively-developed area of Selenium itself. It's stable — it works the same as it did five years ago — but features like Selenium 4's relative locators don't have first-class @FindBy annotations. Some teams have moved entirely to manual By constants for this reason; others keep Page Factory and use plain By only where they need relative locators or composed waits.

The Selenium tool entry covers both styles; the TestNG cheat sheet covers the testing side.

Comparison with Cypress and Playwright

Neither Cypress nor Playwright has an exact equivalent of Page Factory — both rely on plain string selectors held as class fields:

// Cypress
class LoginPage {
  emailInput = "[data-testid='email']";
  loginAs(u: string, p: string) {
    cy.get(this.emailInput).type(u);
  }
}
 
// Playwright
class LoginPage {
  emailInput = this.page.getByTestId("email");   // a Locator object
  loginAs(u: string, p: string) {
    this.emailInput.fill(u);
  }
}

Playwright's Locator objects do something similar to Page Factory's proxies — lazy until interaction — but without the annotation ceremony. If you find Page Factory's annotation style appealing, you'll feel right at home in Playwright with Locator properties.

⚠️ Common mistakes

  • Forgetting PageFactory.initElements(driver, this) in the constructor. Every interaction with the annotated fields now throws NullPointerException because the proxies were never created. The error message is unhelpful — you'll waste 20 minutes before realising the constructor is missing the wiring. Make it muscle memory.
  • Mixing Page Factory with explicit waits in the same class without thinking. Page Factory pre-fetches; explicit waits assume you'll be calling findElement from inside the wait condition. Combine them carefully — usually means using wait.until(ExpectedConditions.elementToBeClickable(By.id("..."))).click() instead of touching the annotated field directly when you need the wait.
  • @CacheLookup on dynamic elements. This annotation tells Page Factory to find the element once and cache it forever. On any element that re-renders, the cached reference goes stale and every subsequent interaction throws StaleElementReferenceException. Only use @CacheLookup on elements you're certain won't change — and in modern SPAs, that's nearly nothing.

🎯 Practice task

Build both styles, side by side. 30–40 minutes.

  1. Convert your LoginPage from the previous lesson to Page Factory. Make sure your existing tests still pass.
  2. Build the same page two ways. Write LoginPagePF.java (Page Factory) and LoginPageManual.java (manual By constants), both implementing the same public methods. Have your test class take a constructor parameter that picks one. Run both — same outcome, different style. Decide which feels cleaner for your codebase.
  3. List of elements. Add a List<WebElement> field with @FindBy(css = "[data-test='inventory-item']") to your InventoryPagePF. Write a method that asserts there are six products. Notice that the list is re-evaluated each time you read it — try removing one inventory card via DevTools live-edit and watch the count update.
  4. Force NullPointerException. Comment out the PageFactory.initElements(driver, this) line. Run a test. Read the NPE — it's a confusing one. This is the single most common Page Factory bug in the wild; recognising the failure shape saves time later.
  5. @CacheLookup experiment. Add @CacheLookup to your username field. Test still passes. Now navigate away from the login page and back, and try interacting with the same field — StaleElementReferenceException. Remove the cache annotation; the test passes again. Now you've felt why caching a WebElement is risky.
  6. Stretch — relative locators in a Page Factory class. Selenium 4's relative locators (with(...).above(...)) don't have a first-class @FindBy annotation. Build a Page Factory class with a method that uses with(By.tagName("input")).above(By.id("submit")) inline. The hybrid (annotations for stable elements, plain By for relative ones) is the pragmatic choice in real codebases.

Next lesson: extract the duplicated wait/find/click helpers from your page objects into a shared BasePage superclass. Inheritance gives every page object the same interaction toolkit for free.

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