Components and Fluent Page Objects
On this page
So far every page object maps to one URL. Real applications are built from parts that repeat across URLs: a header, a cookie banner, a search box, a table row. If each page object re-declares the header's locators, you are back to the duplication POM was supposed to remove. This chapter splits shared parts into component objects and makes page objects fluent, so a test reads as a user journey.
Pages are made of components#
Both the Selenium documentation and Martin Fowler say the same thing: a page object "need not represent an entire page" and can be composed of component objects. On the practice site, the main menu (Home, Practice, Courses, Blog, Contact) appears on every page. It becomes a SiteHeader component that page objects include as a field.
A component has three properties:
- A root. Every locator inside it is resolved relative to that root, never relative to the whole document. That keeps "Practice" in the menu distinct from any "Practice" link elsewhere on the page.
-
No URL and no
open()method. Components are reached through the page that contains them. - The same API wherever it appears. The header behaves identically on the login page and on the Practice page, so the code exists once.
Java:
package com.example.components;
import java.util.function.Function;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import com.example.pages.PracticeIndexPage;
public class SiteHeader {
private static final By ROOT = By.id("menu-primary-items");
private final WebDriver driver;
public SiteHeader(WebDriver driver) {
this.driver = driver;
}
private WebElement root() {
return driver.findElement(ROOT); // look up fresh every time: no stale roots
}
public PracticeIndexPage openPractice() {
return open("Practice", PracticeIndexPage::new);
}
public <T> T open(String item, Function<WebDriver, T> nextPage) {
root().findElement(By.xpath(".//a[normalize-space()='" + item + "']")).click();
return nextPage.apply(driver);
}
}
TypeScript:
// components/site-header.ts
import { type Locator, type Page } from '@playwright/test';
import { PracticeIndexPage } from '../pages/practice-index-page';
export class SiteHeader {
private readonly root: Locator;
constructor(private readonly page: Page) {
this.root = page.locator('#menu-primary-items');
}
link(name: string): Locator {
return this.root.getByRole('link', { name, exact: true });
}
async openPractice(): Promise<PracticeIndexPage> {
await this.link('Practice').click();
return new PracticeIndexPage(this.page);
}
}
The page objects then expose the component as a field. In Java, LoginPage gains public final SiteHeader header;, assigned in the constructor with this.header = new SiteHeader(driver);. The TypeScript version is the same with readonly header: SiteHeader.
Why does SiteHeader resolve every locator relative to its root element instead of the whole document?
Scoping to the root keeps the component's locators unambiguous wherever it appears. The Java version still looks the root up fresh each time, which is what avoids stale elements.
Why not By.linkText("Practice")?#
The obvious Java locator, By.linkText("Practice"), finds nothing on this site. The menu is rendered in capitals by CSS text-transform, and Selenium's link text matching works on the rendered text, so only By.linkText("PRACTICE") matches. We checked both against the live page: zero matches and one match.
Matching "PRACTICE" would make the test depend on a styling choice. The XPath normalize-space() predicate matches the text in the DOM instead, which is what the site's content actually says. Playwright's getByRole('link', { name: 'Practice' }) uses the accessible name, which also comes from the DOM text, so it works unchanged.
This kind of discovery is exactly why components pay off. The knowledge "this menu is uppercased by CSS, match on DOM text" now lives in one class instead of in every test that navigates.
A page with a repeated part#
The Practice index lists three exercises, each a link plus a description. A test usually wants either the whole list or one entry by name:
package com.example.pages;
import java.util.List;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
public class PracticeIndexPage extends BasePage {
private final By exerciseLinks =
By.cssSelector(".post-content .wp-block-column:first-child a");
public PracticeIndexPage(WebDriver driver) {
super(driver);
wait.until(ExpectedConditions.urlMatches("/practice/$"));
}
public List<String> exercises() {
return wait.until(ExpectedConditions.visibilityOfAllElementsLocatedBy(exerciseLinks))
.stream().map(WebElement::getText).toList();
}
public LoginPage openLoginExercise() {
driver.findElement(By.linkText("Test Login Page")).click();
return new LoginPage(driver);
}
}
exercises() returns plain strings, not elements. If the rows had several fields and actions each (think of a cart line with quantity, price and a remove button), you would model one row as a component with its own root element and return a list of those components. Fowler's example in the original article is exactly this: an album list object containing album objects.
Fluent page objects#
Every method that causes navigation returns the page object for where the user ends up. Methods that keep the user on the same page return this. The Selenium docs describe this as letting tests "model the user's journey through our application". The payoff is a test that reads top to bottom as a journey:
@Test
public void canReachLoginFromPracticeIndex() {
String heading = new LoginPage(driver())
.open()
.header.openPractice()
.openLoginExercise()
.loginAs("student", "Password123")
.heading();
assertEquals(heading, "Logged In Successfully");
}
driver() comes from the test base class we build in chapter 06; until then, read it as the driver field from chapter 01.
In TypeScript every step is async, so long chains do not work; use one await per step instead. The return types still carry the journey:
import { test, expect } from './fixtures';
test('a journey across three page objects', async ({ loginPage }) => {
const practice = await loginPage.header.openPractice();
const login = await practice.openLoginExercise();
const home = await login.loginAs('student', 'Password123');
await expect(home.heading).toHaveText('Logged In Successfully');
});
test('practice menu lists the exercises', async ({ loginPage, page }) => {
const practice = await loginPage.header.openPractice();
await expect(page).toHaveURL(/\/practice\/$/);
await expect(practice.exerciseLinks).toHaveText([
'Test Login Page',
'Test Exceptions',
'Test Table',
]);
});
toHaveText with an array checks the count and the order of the matched elements, and retries until they match, which is the Playwright equivalent of the Java exercises() list comparison.
In a fluent design, what should a LoginPage.typeUsername(String) method return?
Actions that keep the user on the same page return this; only navigation returns the next page object. Returning LoggedInPage would claim a navigation that has not happened.
Where fluent design goes wrong#
Fluent page objects are a tool, not a rule. Three situations where they hurt:
One action, several destinations. A login can land on the home page, stay on the login page with an error, or go to a "change your password" page. A single login() cannot return the right type for all three. Give each expected outcome its own method (loginAs, loginExpectingError), as we did in chapter 01. The method name then documents what the test expects, and the constructor check of the returned page fails fast when the expectation is wrong.
Components that hard-code destinations. SiteHeader.openPractice() imports PracticeIndexPage. Add five more menu items and the header imports five pages, and every page imports the header: a dependency cycle that is harmless in Java and TypeScript but hard to navigate. The generic open(String item, Function<WebDriver, T> nextPage) method avoids that: the test says where it expects to land, header.open("Courses", CoursesPage::new), and the header stays ignorant of the pages.
Chains that hide failures. When a ten-step chain fails, the stack trace points at one line. Break long chains into named variables at the points where a reader would want to know "where are we now". The TypeScript version above is not worse for having three variables.
Your SiteHeader now has a method per menu item and imports every page in the site. What keeps the header independent of the pages?
The test says where it expects to land, so the component never imports pages. Returning BasePage throws away the type that documents the journey.
Checklist for this chapter#
- Shared UI (headers, menus, dialogs, table rows) lives in component classes with a root.
- Locators inside a component are relative to its root.
- Navigation methods return the next page object; same-page actions return
this. - Separate methods for separate expected outcomes.
If you want to see the same component approach applied to native mobile screens, our Appium page object article builds screen objects for an app. For more on dealing with elements that change under you, see exception handling with Selenium.