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@Testpublic 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@Testpublic 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 outcomespublic InventoryPage submitExpectingSuccess() { click(SUBMIT); return new InventoryPage(driver); }public LoginPage submitExpectingError() { click(SUBMIT); return this; }// Option 2: return a sealed-type-ish wrapperpublic 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 chaincy.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 chainsawait 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.
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.
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.
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.
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.
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.
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.
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.