---
title: Components and Fluent Page Objects | TestingBot
description: '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…'
source_url:
  html: https://testingbot.com/resources/courses/page-object-model/components-and-fluent-pages
  md: https://testingbot.com/resources/courses/page-object-model/components-and-fluent-pages.md
---

Course

[Page Object Model and Framework Design](https://testingbot.com/resources/courses/page-object-model)

Chapter 4 of 10

1. [1 What Is the Page Object Model?](https://testingbot.com/resources/courses/page-object-model)
2. [2 Your First Page Object in Selenium](https://testingbot.com/resources/courses/page-object-model/first-page-object)
3. [3 Page Object Model in Playwright](https://testingbot.com/resources/courses/page-object-model/playwright-page-objects)
4. [4 Components and Fluent Page Objects](https://testingbot.com/resources/courses/page-object-model/components-and-fluent-pages)
5. [5 Page Factory vs Plain By Locators](https://testingbot.com/resources/courses/page-object-model/page-factory-vs-by-locators)
6. [6 Test Data, Config and Environments](https://testingbot.com/resources/courses/page-object-model/test-data-and-config)
7. [7 Test Automation Framework Structure](https://testingbot.com/resources/courses/page-object-model/framework-layout)
8. [8 Run the Framework in Parallel on a Grid](https://testingbot.com/resources/courses/page-object-model/parallel-on-a-grid)
9. [9 POM Anti-Patterns and When to Skip POM](https://testingbot.com/resources/courses/page-object-model/anti-patterns)
10. [10 Knowledge check](https://testingbot.com/resources/courses/page-object-model/knowledge-check)

[Exit course](https://testingbot.com/resources/courses)

 Chapter 4 of 10 Intermediate 8 min read 3 quick checks Updated Nov 2026 

# Components and Fluent Page Objects
On this page
1. [Pages are made of components](https://testingbot.com#pages-are-made-of-components)
2. [A page with a repeated part](https://testingbot.com#a-page-with-a-repeated-part)
3. [Fluent page objects](https://testingbot.com#fluent-page-objects)
4. [Where fluent design goes wrong](https://testingbot.com#where-fluent-design-goes-wrong)
5. [Checklist for this chapter](https://testingbot.com#checklist-for-this-chapter)

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[#](https://testingbot.com#pages-are-made-of-components)

Both the [Selenium documentation](https://www.selenium.dev/documentation/test_practices/encouraged/page_object_models/) 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:

1. **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.
2. **No URL and no `open()` method.** Components are reached through the page that contains them.
3. **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:

```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:

```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?

 Root-relative lookups are faster in Selenium So a menu item such as Practice cannot match a Practice link somewhere else on the page Because components are not allowed to use the driver To avoid stale element exceptions

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")?[#](https://testingbot.com#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[#](https://testingbot.com#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:

```java
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[#](https://testingbot.com#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:

```java
@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:

```typescript
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?

 `void` A `LoggedInPage` A new `LoginPage` instance `this`

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[#](https://testingbot.com#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?

 A generic `open(item, nextPage)` where the test passes the page it expects, such as `CoursesPage::new` Make every navigation method return `BasePage` Move the header's locators into every page object

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[#](https://testingbot.com#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](https://testingbot.com/resources/articles/page-object-model-appium) builds screen objects for an app. For more on dealing with elements that change under you, see [exception handling with Selenium](https://testingbot.com/resources/articles/exception-handling-with-selenium).

[Previous · Chapter 3 Page Object Model in Playwright](https://testingbot.com/resources/courses/page-object-model/playwright-page-objects) [Next · Chapter 5 Page Factory vs Plain By Locators](https://testingbot.com/resources/courses/page-object-model/page-factory-vs-by-locators)

On this page

1. [Pages are made of components](https://testingbot.com#pages-are-made-of-components)
2. [A page with a repeated part](https://testingbot.com#a-page-with-a-repeated-part)
3. [Fluent page objects](https://testingbot.com#fluent-page-objects)
4. [Where fluent design goes wrong](https://testingbot.com#where-fluent-design-goes-wrong)
5. [Checklist for this chapter](https://testingbot.com#checklist-for-this-chapter)

Certification

This course has a free, hands-on certificate.

[How it works](https://testingbot.com/resources/courses/page-object-model/certification)
