Fluent Page Objects and Builder Pattern

8 min read

A LoginPage's loginAs(...) returns void. A test calls it on a separate line. The next test calls it the same way. Three lessons in, your tests are clean — but each step is its own statement and the test reads as five disconnected actions. The fluent page object pattern (a.k.a. method chaining) returns this from same-page methods and the next page from navigation methods. Tests then read as one continuous pipeline: loginPage.navigateTo().loginAs(...).addToCart(...).checkout(). The compiler enforces that you can only call methods that are valid at each step. This lesson covers the rules, the trade-offs, and the related Builder pattern for test-data construction. The final polish on POM.

The fluent rule, in one sentence

Methods that stay on the same page return this. Methods that navigate to a new page return the new page object.

That's the whole pattern. Apply it consistently and method chains compose; you can read a test left-to-right like a sentence.

A standard vs fluent page object

Standard:

public class RegistrationPage extends BasePage {
 
    private static final By NAME = By.id("name");
    private static final By EMAIL = By.id("email");
    private static final By PASSWORD = By.id("password");
    private static final By COUNTRY = By.id("country");
    private static final By TERMS = By.id("terms");
    private static final By SUBMIT = By.cssSelector("[data-testid='register']");
 
    public RegistrationPage(WebDriver driver) { super(driver); }
 
    public void fillName(String name)      { type(NAME, name); }
    public void fillEmail(String email)    { type(EMAIL, email); }
    public void fillPassword(String pw)    { type(PASSWORD, pw); }
    public void selectCountry(String c)    { new Select(driver.findElement(COUNTRY)).selectByVisibleText(c); }
    public void acceptTerms()              { click(TERMS); }
    public DashboardPage submit()          { click(SUBMIT); return new DashboardPage(driver); }
}
 
// Test
@Test
public void shouldRegisterUser() {
    RegistrationPage reg = new RegistrationPage(driver);
    reg.fillName("Alice");
    reg.fillEmail("alice@test.com");
    reg.fillPassword("Pass!");
    reg.selectCountry("UK");
    reg.acceptTerms();
    DashboardPage dashboard = reg.submit();
    Assert.assertTrue(dashboard.welcomeBannerVisible());
}

Fluent:

public class RegistrationPage extends BasePage {
    // ... same locators ...
 
    public RegistrationPage(WebDriver driver) { super(driver); }
 
    public RegistrationPage fillName(String name)      { type(NAME, name);     return this; }
    public RegistrationPage fillEmail(String email)    { type(EMAIL, email);   return this; }
    public RegistrationPage fillPassword(String pw)    { type(PASSWORD, pw);   return this; }
    public RegistrationPage selectCountry(String c) {
        new Select(driver.findElement(COUNTRY)).selectByVisibleText(c);
        return this;
    }
    public RegistrationPage acceptTerms()              { click(TERMS);         return this; }
    public DashboardPage submit()                      { click(SUBMIT);        return new DashboardPage(driver); }
}
 
// Test
@Test
public void shouldRegisterUser() {
    DashboardPage dashboard = new RegistrationPage(driver)
        .fillName("Alice")
        .fillEmail("alice@test.com")
        .fillPassword("Pass!")
        .selectCountry("UK")
        .acceptTerms()
        .submit();
 
    Assert.assertTrue(dashboard.welcomeBannerVisible());
}

The page implementation grew slightly (a return this per same-page method). The test got shorter and more readable. The compiler now enforces the call sequence — you can only call inventory/dashboard methods after submit() returns.

The compiler-enforced flow

The fluent style does something subtler than just shorten code. It uses Java's type system to enforce what's a valid next action:

DashboardPage dashboard = new RegistrationPage(driver)
    .fillName("Alice")
    .fillEmail("alice@test.com")
    .fillName("oops");      // ✅ still RegistrationPage — compiler allows it
                            //    (you can re-fill after fill)
 
DashboardPage dashboard2 = new RegistrationPage(driver)
    .submit()
    .fillName("oops");      // ❌ submit() returns DashboardPage; fillName is on RegistrationPage
                            //    Compiler error.

In the standard (non-fluent) style, this wouldn't be caught — reg.fillName("oops") after reg.submit() is just two unrelated method calls. The fluent style turns "did you stay on the right page?" into a compile-time check.

That's not always worth it — many tests don't have ordering bugs to begin with. But on long, branching flows (sign-up → email-verify → onboarding → dashboard), the type-driven flow is genuinely safer.

Standard vs fluent — when to use each

Standard POM vs fluent POM

Standard POM

  • Each call on its own line

  • Methods return void or specific data

  • Tests read as a list of actions

  • Independent calls — easy to mix orders

  • Best for tests that perform discrete, unrelated actions

Fluent POM

  • Methods chain via return this / return next page

  • Tests read as one continuous pipeline

  • Compiler enforces correct page transitions

  • Best for forms and linear flows

  • Slight cost: every same-page method needs return this

The pragmatic rule: fluent for forms and linear flows; standard for ad-hoc test bodies that touch multiple pages in arbitrary order. Most projects mix both — fluent on registration/checkout/wizards, standard on dashboard tests that just poke at things.

The Builder pattern for test data

A related pattern, often paired with fluent POM. Test data — User, Product, Order — often has many fields, most with sensible defaults and only a few overridden per test:

TestUser admin = new TestUser.Builder()
    .name("Alice")
    .role("admin")
    .build();   // defaults for email, country, locale, etc.
 
TestUser limited = new TestUser.Builder()
    .name("Bob")
    .country("Japan")
    .role("readonly")
    .build();

The Builder class:

public class TestUser {
    public final String name;
    public final String email;
    public final String role;
    public final String country;
 
    private TestUser(Builder b) {
        this.name = b.name;
        this.email = b.email;
        this.role = b.role;
        this.country = b.country;
    }
 
    public static class Builder {
        private String name = "Default User";
        private String email = "default@test.com";
        private String role = "user";
        private String country = "United Kingdom";
 
        public Builder name(String name)       { this.name = name; return this; }
        public Builder email(String email)     { this.email = email; return this; }
        public Builder role(String role)       { this.role = role; return this; }
        public Builder country(String country) { this.country = country; return this; }
 
        public TestUser build() { return new TestUser(this); }
    }
}

Same fluent shape — every setter returns this, the terminal build() produces an immutable result. Tests stay short, defaults stay centralised, overrides are explicit. We'll lean into this in chapter 7's data-driven testing lesson.

The fluent registration test, complete

package com.mycompany.tests.tests;
 
import com.mycompany.tests.base.BaseTest;
import com.mycompany.tests.pages.InventoryPage;
import com.mycompany.tests.pages.LoginPage;
import org.testng.Assert;
import org.testng.annotations.Test;
 
public class FluentLoginTest extends BaseTest {
 
    @Test
    public void shouldLogInFluently() {
        InventoryPage inventory = new LoginPage(driver)
            .navigateTo()
            .fillUsername("standard_user")
            .fillPassword("secret_sauce")
            .submit();
 
        Assert.assertEquals(inventory.productCount(), 6);
    }
 
    @Test
    public void shouldRetryAfterTypingWrongPasswordFluently() {
        // Stay on LoginPage — fluent same-page methods make the retry chain natural
        new LoginPage(driver)
            .navigateTo()
            .fillUsername("standard_user")
            .fillPassword("WRONG")
            .submitExpectingError()        // returns LoginPage (we know we'll error)
            .clearForm()                    // also returns LoginPage
            .fillUsername("standard_user")
            .fillPassword("secret_sauce")
            .submit();                      // returns InventoryPage
 
        Assert.assertTrue(driver.getCurrentUrl().contains("/inventory.html"));
    }
}

Two things to note:

  • fillUsername(...) and fillPassword(...) return LoginPage (same page, chain continues).
  • submit() returns InventoryPage (the success destination).
  • submitExpectingError() returns LoginPage because we know the error case keeps us there. The page object can offer multiple "submit" variants based on what the test expects to happen — naming each one explicitly is clearer than a single submit() that magically returns one type or another.

When a navigation method might return either page

Sometimes you genuinely don't know — a button can succeed or fail. Two common approaches:

// Option 1: separate methods for the two outcomes
public InventoryPage submitExpectingSuccess() { click(SUBMIT); return new InventoryPage(driver); }
public LoginPage    submitExpectingError()    { click(SUBMIT); return this; }
 
// Option 2: return a sealed-type-ish wrapper
public Either<LoginPage, InventoryPage> submit() { ... }

Option 1 is what most production codebases use. Option 2 is elegant in theory, awkward in practice — Java's lack of native sum types means you'd lean on a library like Vavr's Either, and many teams find that overkill for QA code. Stick with Option 1.

Comparison with Cypress and Playwright

// Cypress — natural fluent style because cy.* is itself a chain
cy.get("[data-testid='username']").type("standard_user");
cy.get("[data-testid='password']").type("secret_sauce");
cy.get("[data-testid='submit']").click();
 
// Playwright — page objects can chain via async/await + locator chains
await loginPage.fillUsername("standard_user");
await loginPage.fillPassword("secret_sauce");
const inventory = await loginPage.submit();

Cypress's cy chain is fluent at the framework level, so explicit page-object chaining adds less. Playwright's async nature breaks pure chaining (each await is its own statement) but page methods returning the next page is still a great pattern. Selenium's synchronous Java API is the framework where fluent POM yields the cleanest tests.

The Selenium tool entry covers method-call mechanics; Core Java for QA's OOP lessons cover the inheritance and chaining concepts.

⚠️ Common mistakes

  • Returning this from a method that actually navigates. If submit() triggers a real page navigation but you accidentally return this;, the next method call operates on a stale page object and locators don't match. The test fails with NoSuchElementException and you spend an hour debugging. Audit every navigation method — does it return the destination page?
  • Going fluent everywhere out of pattern-purity. Fluent works beautifully on form fills and linear flows. On a test that does "open dashboard, check three unrelated metrics, log out," forcing a chain just so it's fluent is awkward. Use the right style for the test.
  • Side effects in supposed-to-be-pure setters. A Builder.email("x") that hits an API to verify the email exists is a side effect — and the next time someone reads a one-line builder usage, they don't expect a network call. Builders should be pure data construction. If you need to validate, do it in build() and document it.

🎯 Practice task

Apply fluent style across your suite. 30–40 minutes.

  1. Convert LoginPage to the fluent style. Same-page methods (fillUsername, fillPassword) return LoginPage. The successful submit() returns InventoryPage. Add a submitExpectingError() that stays on LoginPage for negative-path tests.
  2. Refactor at least three of your existing test methods to use the fluent chain. Compare the test files before and after — line counts, readability. Most teams report a clean ~30% reduction.
  3. Type-system test. In one of your fluent tests, attempt to call a LoginPage method after the chain has navigated (e.g., .submit().fillUsername("oops")). The compiler should reject it. Hover over the error in IntelliJ — it points exactly at the type mismatch. That's the safety net.
  4. Build a TestUser Builder. Create the TestUser and TestUser.Builder classes from the lesson. Use them in a data-driven test:
    TestUser admin = new TestUser.Builder().name("Admin").role("admin").build();
    loginPage.loginAs(admin.email, admin.password);
    Notice how the Builder makes test data construction self-documenting.
  5. Mixed style. Pick a test that does ad-hoc actions across multiple pages — say, log in, check three menu items, navigate to a settings page, change a preference, log out. Don't force the whole thing into a chain. Use fluent for the form parts (settingsPage.changeTheme("dark").save()) and standard for the unrelated steps. The point: fluent is a tool, not a religion.
  6. Stretch — Lombok @Builder. If your project uses Lombok, replace the hand-written Builder for TestUser with @Builder on the class. Compare. Lombok is the productivity win every Java QA team should evaluate.

Chapter 6 is done. You now have the full POM — fluent, inheritance-based, with the Builder pattern for test data. The framework structure is complete. Chapter 7 turns to the data side: reading test inputs from Excel/JSON/CSV, running the same tests across browsers, and bringing Selenium Grid into the mix. The framework you've built is ready to scale.

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