Page Factory vs Plain By Locators
On this page
Search for "page object model selenium" and a large share of tutorials use PageFactory, @FindBy and initElements. The page objects in this course do not. This chapter shows what Page Factory does, rewrites our login page with it so you can compare, and explains why we, and the current Selenium documentation, default to plain By locators.
What Page Factory is#
PageFactory is a Java support class in selenium-support (part of selenium-java). You annotate WebElement fields with @FindBy, call PageFactory.initElements, and every field is replaced by a proxy. The proxy finds the real element only when you call a method on it, and by default finds it again on every call.
Here is the login page from chapter 01, rewritten with Page Factory. It compiles and passes against Selenium 4.52.0:
package com.example.pages;
import java.time.Duration;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.AjaxElementLocatorFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public class FactoryLoginPage {
private final WebDriver driver;
private final WebDriverWait wait;
@FindBy(id = "username")
private WebElement username;
@FindBy(id = "password")
private WebElement password;
@FindBy(id = "submit")
private WebElement submit;
@FindBy(id = "error")
private WebElement error;
public FactoryLoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
PageFactory.initElements(new AjaxElementLocatorFactory(driver, 10), this);
}
public FactoryLoginPage open() {
driver.get("https://practicetestautomation.com/practice-test-login/");
return this;
}
public FactoryLoginPage loginExpectingError(String user, String pass) {
username.sendKeys(user);
password.sendKeys(pass);
submit.click();
return this;
}
public String errorMessage() {
return wait.until(ExpectedConditions.visibilityOf(error)).getText();
}
}
It reads nicely: the fields look like the elements themselves, and the methods have no driver.findElement noise. That readability is why the pattern spread.
Three variations matter:
-
PageFactory.initElements(driver, this)uses the default locator, which tries once and throwsNoSuchElementExceptionif the element is not there yet (subject only to any implicit wait). -
new AjaxElementLocatorFactory(driver, 10), as above, retries the lookup for up to 10 seconds. That is the closest Page Factory gets to waiting. -
@CacheLookupon a field makes the proxy remember the element after the first lookup. Faster, but if the DOM node is replaced (any re-render, any navigation back) you getStaleElementReferenceException.
A @FindBy field also has @CacheLookup. After the form re-renders, the next sendKeys on it fails. With what?
@CacheLookup makes the proxy remember the first element it found. Once that DOM node is replaced, the cached reference is stale.
Why the Selenium docs moved on#
The Selenium project's page about Page Factory now sits in the Legacy section of selenium.dev under "Selenium 2", with a banner saying it "describes Selenium 2 and 3 and is kept for reference". The current Page Object Models page uses plain By locators and does not mention Page Factory at all.
Page Factory is not deprecated: the class and annotations ship in Selenium 4.52.0 without @Deprecated, and existing suites keep working. But the documentation has clearly stopped recommending it, and the reasons are practical:
1. It encourages the wrong kind of waiting. A proxy field looks like a ready element, so the natural code is submit.click(). Whether that waits depends on which locator factory someone chose in the constructor, which the method body does not show. Even with AjaxElementLocatorFactory, the retry is "until the element exists", not "until it is visible" or "clickable". For our #error box, which exists from page load with opacity 0, "exists" is useless: you still need the explicit visibilityOf wait in errorMessage(). Once you write explicit waits anyway, the proxy adds nothing.
2. It works on elements, waits work on locators. ExpectedConditions has two families: visibilityOfElementLocated(By) re-finds the element on each poll, visibilityOf(WebElement) checks a specific element. With Page Factory you only have WebElements, so you lose the By-based conditions, including presenceOfAllElementsLocatedBy, numberOfElementsToBe, and invisibilityOfElementLocated.
3. It hides errors. A misspelled @FindBy fails at first use, with a stack trace that goes through a dynamic proxy, and it fails the same way whether the element was missing, slow, or in a different frame. With By fields and a visible(By) helper, the TimeoutException names the locator and the condition.
4. It does not exist outside Java. The Python, JavaScript and Ruby Selenium bindings have no Page Factory. If your organisation writes tests in more than one language, plain locators keep the page objects recognisably the same pattern, as chapter 01 showed.
5. Components get awkward. Scoping a component's fields to a root element requires passing a DefaultElementLocatorFactory built around a SearchContext. It works, but most teams discover it only after their header component accidentally matched an element in the footer.
The plain By version, again#
For comparison, the core of the chapter 01 page object:
private final By submit = By.id("submit");
private final By error = By.id("error");
public String errorMessage() {
return visible(error).getText();
}
private void submitCredentials(String user, String pass) {
type(username, user);
type(password, pass);
click(submit);
}
Every interaction states its wait (visible, click via elementToBeClickable) through two small helpers in BasePage. Every lookup happens at the moment of use, so there is nothing to go stale. Locators are values you can pass around, log, or combine (root.findElement(locator) for components).
Side by side#
@FindBy + PageFactory
|
By fields + helpers |
|
|---|---|---|
| Lookup timing | Lazy, on each call (unless @CacheLookup) |
At the moment of use |
| Waiting | Only via locator factory, presence only | Explicit, any ExpectedConditions
|
| Stale elements | Possible with @CacheLookup
|
Not possible (fresh lookup) |
| Error messages | Through a proxy | Name the locator and condition |
| Languages | Java only | Every binding |
| Lists |
List<WebElement> with @FindBy
|
findElements(By) or wait conditions |
| Status | Legacy docs, not deprecated | Current Selenium docs |
What about Playwright?#
Playwright solves the problem Page Factory was trying to solve. A Playwright Locator is lazy like a proxy field, re-resolves on every action, and waits for actionability (visible, stable, enabled, receiving events) before each action. That is why the TypeScript page objects in chapter 02 can be plain classes with readonly locator fields and no helper methods.
The trade-off is the one we saw in chapter 02: Playwright's idea of "visible" includes opacity: 0, so the page object still has to encode what "showing" means for an element like #error.
Migrating an existing Page Factory suite#
You do not need a big-bang rewrite. A safe order:
- Add
BasePagewithvisible(By),type(By, String)andclick(By)helpers. - Pick the page objects with the most flaky failures first (your CI history or test reports will show them).
- For each, replace
@FindBy(id = "x") WebElement x;withBy x = By.id("x");, then fix every compile error by routing the use through a helper. The compiler finds every call site for you. - Remove
PageFactory.initElementsfrom the constructor last. A page object without any@FindByfields gets nothing from it.
Run the suite after each page, and delete any Thread.sleep you find on the way: an explicit wait on the right condition replaces it.
Our Selenium with Java and TestNG course covers explicit waits in more depth, and the TestNG documentation for TestingBot shows the same page objects against remote browsers.
You are migrating a Page Factory suite one page at a time. When do you remove PageFactory.initElements from a page object's constructor?
Replacing each @FindBy field with a By is what makes the compiler find the call sites. A page object without @FindBy fields gets nothing from initElements, so it goes last.