When you click "Login" on the login page, you land on the dashboard. When you click "Transfer" on the dashboard, you land on the transfer page. Your page objects should reflect this navigation flow. A method on LoginPage that navigates to DashboardPage should return a DashboardPage instance. This is called fluent navigation.
// Without fluent navigation -- manual page object creation
test('login and transfer', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
const transferPage = new TransferPage(page);
await loginPage.goto();
await loginPage.login('john@test.com', 'password123');
// You just KNOW that after login you land on dashboard
// But there is no code connection between login() and DashboardPage
await dashboardPage.clickTransfer();
// Again, you KNOW this goes to transfer page
// But the code does not express this
await transferPage.transferMoney('9876543210', '500');
});import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './base.page';
import { DashboardPage } from './dashboard.page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByTestId('error-message');
}
async goto() {
await super.goto('/banking/login');
}
// Returns DashboardPage -- because that is where you land
async loginAs(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
return new DashboardPage(this.page);
}
// Login that stays on login page (invalid credentials)
async loginExpectingError(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}import { type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './base.page';
import { TransferPage } from './transfer.page';
export class DashboardPage extends BasePage {
readonly transferButton: Locator;
readonly balanceDisplay: Locator;
constructor(page: Page) {
super(page);
this.transferButton = page.getByRole('button', { name: 'Transfer' });
this.balanceDisplay = page.getByTestId('balance');
}
// Returns TransferPage -- because clicking Transfer navigates there
async goToTransfer(): Promise<TransferPage> {
await this.transferButton.click();
return new TransferPage(this.page);
}
async expectLoaded() {
await expect(this.balanceDisplay).toBeVisible();
}
}import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
test('complete transfer with fluent navigation', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
// login() returns DashboardPage
const dashboard = await loginPage.loginAs('john@test.com', 'password123');
await dashboard.expectLoaded();
// goToTransfer() returns TransferPage
const transfer = await dashboard.goToTransfer();
await transfer.transferMoney('9876543210', '500');
await transfer.expectSuccess();
});
test('invalid login stays on login page', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
// This method does NOT return DashboardPage -- you stay on login
await loginPage.loginExpectingError('wrong@test.com', 'wrongpass');
const error = await loginPage.getErrorText();
expect(error).toBe('Invalid email or password');
});Use different method names for different outcomes. loginAs() navigates to dashboard and returns DashboardPage. loginExpectingError() stays on the login page and returns nothing. The return type is your documentation -- it tells the developer where they will end up.
Watch out for circular imports. LoginPage imports DashboardPage, DashboardPage imports TransferPage. If TransferPage also imports LoginPage, you get a circular dependency. Design your navigation graph to be one-directional. If you need bi-directional navigation, use lazy imports or restructure.
Q: What is fluent navigation in POM?
A: Fluent navigation means page object methods return the page object you land on after the action. loginPage.loginAs() returns a DashboardPage instance because a successful login takes you to the dashboard. dashboardPage.goToTransfer() returns a TransferPage. The return type serves as documentation -- it tells you which page you will be on next. It also enables IntelliSense in your IDE -- after calling loginAs(), autocomplete shows only DashboardPage methods. I use different method names for different outcomes: loginAs() for successful login, loginExpectingError() when you stay on the login page.
Key Point: Methods that navigate should return the destination page object. loginAs() returns DashboardPage. The return type documents the navigation flow and enables IDE autocomplete.