You already know POM. You built page objects in the Selenium with Java course, the Playwright course, and the Cypress course. But knowing how to write a page object is different from understanding why the pattern exists and what problem it's actually solving. This lesson treats POM as a design pattern — not a Selenium feature, not a "thing you do because everyone does it" — but a deliberate architectural choice with a specific intent, specific trade-offs, and a boundary that, when violated, makes the pattern useless. This understanding is what separates automation engineers who maintain frameworks from automation engineers who inherit the mess left by someone who copied a tutorial.
The pattern intent
POM belongs to the family of encapsulation patterns. Its intent: isolate the knowledge of how to interact with a page from the knowledge of what scenarios to test. That isolation produces a single place to update when the UI changes, and test methods that read like business scenarios rather than browser automation scripts.
The pattern has three internal components, each with a distinct responsibility:
Locators (private): the selectors that find elements — CSS selectors, XPaths, data-testid attributes, ARIA roles. Private because the way you find an element is an implementation detail that no test should depend on. If data-testid="submit-btn" changes to data-testid="submit-button", nothing outside the page object should know or care.
Actions (public): the operations a user can perform — click, fill, navigate, select, upload. These are named as business verbs: login(email, password), addToCart(productName), submitForm(). The test calls the verb; the page object knows the mechanics.
State queries (public): getters that expose observable state without asserting anything. getErrorText(), isSuccessBannerVisible(), getCartItemCount(). The test decides what to assert; the page object just reports what's there.
- – CSS selectors
- – data-testid attributes
- – ARIA roles / XPath
- – login(email, password)
- – addToCart(name)
- – submitForm()
- – isErrorDisplayed()
- – getCartCount()
- – getHeadingText()
- Returns next page object –
- goToCart() → CartPage –
- submit() → ConfirmPage –
POM vs Page Factory — two implementations of the same pattern
In Selenium Java, POM can be implemented two ways: manually with By constants, or using Selenium's PageFactory with @FindBy annotations.
Manual By constants — explicit and predictable:
public class LoginPage {
private final WebDriver driver;
private final By emailInput = By.id("email");
private final By passwordInput = By.id("password");
private final By submitButton = By.cssSelector("[data-testid='submit']");
private final By errorMessage = By.cssSelector("[data-testid='error']");
public LoginPage(WebDriver driver) { this.driver = driver; }
public void login(String email, String password) {
driver.findElement(emailInput).sendKeys(email);
driver.findElement(passwordInput).sendKeys(password);
driver.findElement(submitButton).click();
}
public String getErrorText() {
return driver.findElement(errorMessage).getText();
}
}Every findElement call re-queries the DOM when called. Dynamic content — elements that appear after JavaScript runs, content that changes after AJAX — works correctly because the lookup is fresh each time.
Page Factory with @FindBy — less boilerplate:
public class LoginPage {
@FindBy(id = "email")
private WebElement emailInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(css = "[data-testid='submit']")
private WebElement submitButton;
public LoginPage(WebDriver driver) {
PageFactory.initElements(driver, this);
}
public void login(String email, String password) {
emailInput.sendKeys(email);
passwordInput.sendKeys(password);
submitButton.click();
}
}Cleaner syntax — no By. prefix, no driver.findElement() in each method. PageFactory.initElements() creates proxy objects that locate elements lazily on first use.
The trade-off: Page Factory elements are proxies. On fast, dynamic pages — single-page apps that re-render components after each action — the proxy can hold a stale reference, producing StaleElementReferenceException. Manual By constants avoid this because each call to driver.findElement(By.id("email")) is a fresh DOM lookup with no caching. For stable, server-rendered pages, Page Factory is convenient. For SPAs with frequent re-renders, manual By constants are more reliable.
Beyond UI: the same pattern everywhere
POM is tool-agnostic. The exact same structure applies outside Selenium:
Playwright (TypeScript) — methods return Locator, not WebElement:
export class LoginPage {
private readonly emailInput = this.page.getByTestId("email");
private readonly passwordInput = this.page.getByTestId("password");
private readonly submitButton = this.page.getByTestId("submit");
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}API client (Rest Assured / requests) — same encapsulation, different interaction:
# Service object — POM equivalent for API testing
class UserApiClient:
def __init__(self, base_url: str, token: str):
self._base = base_url
self._headers = {"Authorization": f"Bearer {token}"}
def create(self, email: str, role: str = "user") -> dict:
return requests.post(
f"{self._base}/users",
json={"email": email, "role": role},
headers=self._headers
).json()
def get_by_id(self, user_id: int) -> dict:
return requests.get(
f"{self._base}/users/{user_id}",
headers=self._headers
).json()The test calls user_api.create(email, role) — it never sees the URL path, the header format, or the JSON structure. When the API changes, one class changes.
Anti-patterns that make POM worthless
Mega-page: one AppPage class with 80 methods covering the login form, the product catalogue, the checkout flow, and the user profile. When the checkout changes, the class containing login logic is modified — and a bug in the checkout section can break login tests. Split by page or by major screen section.
Action overload: loginAndNavigateToDashboardAndVerifyWelcomeBanner(). A method that does five things hides what each test actually depends on. Keep actions atomic and composable.
Assertions inside page objects: loginPage.submitAndAssertDashboard() that calls assertEquals(driver.getCurrentUrl(), "/dashboard"). Different tests want different assertions about the same action. Expose state; let tests assert.
Driver as a public field: page.driver.findElement(By.id("email")) in a test method. The moment a test reaches through the page object to the driver directly, the encapsulation is gone and the POM layer has zero value.
⚠️ Common mistakes
- Creating page objects without a
BasePage. Every page object duplicatesfind(),waitFor(), andclick()helpers. Extract shared WebDriver wrapper methods toBasePageonce — page objects inherit, not duplicate. - Returning
voidfrom navigation methods. Whenlogin()succeeds and lands on the dashboard, returningvoidforces the test to separately instantiateDashboardPage. Returningnew DashboardPage(driver)fromlogin()makes the test a pipeline of page objects, and the compiler catches if you try to call dashboard methods before logging in. - One page object per URL instead of one per screen. A checkout flow that's one URL but three distinct forms (shipping, payment, confirmation) should be three page objects — not one 200-method monolith. Model conceptual screens, not URL paths.
🎯 Practice task
Cross-tool POM analysis — 35 minutes.
- Pattern recognition. Open your page objects from the Selenium course and the Playwright course side by side. Map each component: where are the locators? Private or public? Where do actions live? Where do state queries live? The structure should be nearly identical despite different syntax.
- Enforce the boundary. Search both projects for any test method that calls
driver.findElement,page.locator, orcy.getdirectly (not through a page object method). Each one is a POM boundary violation. For each violation, add the appropriate method to the relevant page object and update the test. - Return type discipline. In your Selenium page objects, find any action method that navigates to a new page and returns
void. Update it to return the appropriate next page object. Update the calling test to assign the return value and verify the pipeline compiles. - Build a service object. If you've completed the Rest Assured or Karate course: create a
UserApiClientclass (Pythonrequestsor Java Rest Assured) withcreate(),get(),update(), anddelete()methods. Write one test that calls it without ever seeing a URL or a header. The test should read like a user story, not an HTTP script. - Stretch — Page Factory vs manual
By. Pick a page object from the Selenium course that uses@FindBy. Convert it to manualByconstants. Run the test suite. Now trigger a scenario where an element is re-rendered mid-test (navigate away and back, or force a React re-render via JavaScript in DevTools). Observe whether stale element errors appear with Page Factory but not with manualBy.
Next lesson: the Singleton pattern applied to WebDriver management — how to share a driver across tests safely without global state breaking parallel execution.