Lesson 7 of 12 · Automation Frameworks
The Page Object Model
Structure UI tests with page objects and components in Playwright (TypeScript) and Selenium (Java): what belongs in a page object, fixtures, and common anti-patterns.
intermediate 8 min read Updated
You will learn to
- Explain the problem the Page Object Model solves and its trade-offs
- Build page objects and component objects in Playwright with TypeScript
- Inject page objects into tests with Playwright fixtures
- Apply the same pattern in Selenium 4 with Java and avoid common anti-patterns
Prerequisites
- Playwright Locators
- TypeScript for Testers
Once a UI suite grows past a few dozen tests, the same locators and interaction steps start appearing in many files. Then the login button gets a new label, and you are editing forty tests. The Page Object Model (POM) is the most widely used design pattern for solving this: it puts knowledge of how a page works in one class, so tests only express what the user does and what should happen.
Interviewers ask about POM in nearly every automation interview, and the strong answers go beyond “a class per page” to cover what belongs in a page object, what does not, and when the pattern is overkill.
The problem
Here is a test without any abstraction:
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('ana@example.com');
await page.getByLabel('Password').fill('correct-horse');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Readable on its own. But every test that needs a logged-in user repeats those four lines, and every test that touches the login form repeats its locators. A change to the form means a change everywhere.
The pattern
A page object is a class that represents a page (or a significant part of one) and exposes:
- Locators for the elements tests need, defined once.
- Methods for user intentions, such as
login(email, password), rather than raw clicks.
Tests use the page object and contain the assertions about behaviour. The test reads like a specification; the page object hides the mechanics.
A Playwright page object in TypeScript
// pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorAlert: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.errorAlert = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
await expect(this.signInButton).toBeVisible(); // page is ready
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
// pages/dashboard-page.ts
import { type Page, type Locator } from '@playwright/test';
export class DashboardPage {
readonly heading: Locator;
readonly userMenu: Locator;
constructor(readonly page: Page) {
this.heading = page.getByRole('heading', { name: 'Dashboard' });
this.userMenu = page.getByRole('button', { name: 'Account menu' });
}
}
And the tests:
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { DashboardPage } from '../pages/dashboard-page';
test('valid user reaches the dashboard', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('ana@example.com', process.env.TEST_PASSWORD ?? '');
await expect(new DashboardPage(page).heading).toBeVisible();
});
test('wrong password shows an error', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('ana@example.com', 'wrong-password');
await expect(login.errorAlert).toHaveText('Invalid email or password');
});
Because Playwright locators are lazy, defining them in the constructor is safe: nothing is looked up until a method acts on them.
Where do assertions go?
There are two schools:
- Assertions only in tests. Page objects expose locators and actions; tests decide what to check. This keeps page objects reusable for both positive and negative tests.
- Readiness checks in page objects. A page object may assert that it has loaded (as
gotodoes above), because that is about the page’s mechanics, not the behaviour under test.
The common, defensible position is a combination: behavioural assertions belong in tests; page objects may wait for or verify their own readiness. The Playwright documentation’s own POM example includes expect calls inside page object methods, so treat this as a team convention to agree on, not an absolute rule.
Components for repeated UI
Modern apps are built from components: headers, navigation, modals, data tables. Model those as component objects scoped to a root locator, and compose them into pages:
// components/header.ts
import { type Locator } from '@playwright/test';
export class Header {
readonly cartLink: Locator;
readonly searchBox: Locator;
constructor(readonly root: Locator) {
this.cartLink = root.getByRole('link', { name: /Cart/ });
this.searchBox = root.getByRole('searchbox', { name: 'Search' });
}
async search(term: string) {
await this.searchBox.fill(term);
await this.searchBox.press('Enter');
}
}
// pages/product-page.ts
import { type Page, type Locator } from '@playwright/test';
import { Header } from '../components/header';
export class ProductPage {
readonly header: Header;
readonly addToCartButton: Locator;
constructor(readonly page: Page) {
this.header = new Header(page.getByRole('banner'));
this.addToCartButton = page.getByRole('button', { name: 'Add to cart' });
}
async goto(slug: string) {
await this.page.goto(`/products/${slug}`);
}
}
Scoping to root means the header’s locators can never accidentally match something in the page body.
Injecting page objects with fixtures
Creating page objects at the top of every test is repetitive. Playwright fixtures let tests ask for page objects by name:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
type Pages = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from '@playwright/test';
// tests/login.spec.ts
import { test, expect } from '../fixtures';
test('valid user reaches the dashboard', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('ana@example.com', process.env.TEST_PASSWORD ?? '');
await expect(dashboardPage.heading).toBeVisible();
});
Fixtures are created only when a test requests them, and they can include setup and teardown around use, which makes them the natural place for things like creating a test user via the API.
The same pattern in Selenium 4 with Java
The idea is identical in Selenium. The main difference is that you must handle synchronisation yourself, typically with explicit waits.
// LoginPage.java
import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public class LoginPage {
private final WebDriver driver;
private final WebDriverWait wait;
private final By email = By.id("email");
private final By password = By.id("password");
private final By signIn = By.cssSelector("button[type='submit']");
private final By error = By.cssSelector("[role='alert']");
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public LoginPage open(String baseUrl) {
driver.get(baseUrl + "/login");
wait.until(ExpectedConditions.visibilityOfElementLocated(signIn));
return this;
}
public void loginAs(String user, String pass) {
driver.findElement(email).sendKeys(user);
driver.findElement(password).sendKeys(pass);
driver.findElement(signIn).click();
}
public String errorText() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(error)).getText();
}
}
// LoginTest.java (JUnit 5)
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
class LoginTest {
private WebDriver driver;
@BeforeEach
void setUp() {
driver = new ChromeDriver(); // Selenium Manager resolves the driver binary
}
@AfterEach
void tearDown() {
driver.quit();
}
@Test
void wrongPasswordShowsError() {
var login = new LoginPage(driver).open("https://staging.example.com");
login.loginAs("ana@example.com", "wrong-password");
assertEquals("Invalid email or password", login.errorText());
}
}
Storing By locators rather than WebElement fields avoids stale element references, because each method finds the element fresh. Selenium’s older PageFactory with @FindBy still exists, but plain By locators are simpler to reason about and are what the Selenium documentation’s page object examples use.
Anti-patterns to avoid
Two more to watch:
- Wrapping every single click in a method (
clickEmail(),clickPassword()) adds code without abstraction. Group steps into user intentions. - Returning the next page object from every method (fluent navigation) reads nicely but couples page objects together. Use it where navigation is deterministic and skip it where the next page depends on data.
When POM is overkill
For a handful of tests, or for one-off scripts, a page object layer adds indirection without much benefit. Playwright’s role-based locators are already readable. Introduce page objects when duplication appears, not before. Some teams also prefer lighter alternatives such as plain helper functions or the Screenplay pattern (actors performing tasks). POM remains the most common and the safest default for team-sized suites.
Practice exercise
Refactor the following test into a CartPage page object and a fixture, so that the test body contains only intentions and assertions:
test('removing an item updates the total', async ({ page }) => {
await page.goto('/cart');
const row = page.getByRole('row').filter({ hasText: 'Blue Mug' });
await row.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByRole('row').filter({ hasText: 'Blue Mug' })).toHaveCount(0);
await expect(page.getByTestId('cart-total')).toHaveText('$12.00');
});
Practise related locator and structure challenges in the Playwright Lab and the Selenium Lab. A good solution keeps assertions in the test, exposes a method like removeItem(name), uses a parameterised locator for rows, and injects CartPage through test.extend.
Show a model answer
// pages/cart-page.ts
import { type Page, type Locator } from '@playwright/test';
export class CartPage {
readonly total: Locator;
constructor(readonly page: Page) {
this.total = page.getByTestId('cart-total');
}
async goto() {
await this.page.goto('/cart');
}
row(productName: string): Locator {
return this.page.getByRole('row').filter({ hasText: productName });
}
async removeItem(productName: string) {
await this.row(productName).getByRole('button', { name: 'Remove' }).click();
}
}// fixtures.ts
import { test as base } from '@playwright/test';
import { CartPage } from './pages/cart-page';
export const test = base.extend<{ cartPage: CartPage }>({
cartPage: async ({ page }, use) => {
await use(new CartPage(page));
},
});
export { expect } from '@playwright/test';// tests/cart.spec.ts
import { test, expect } from '../fixtures';
test('removing an item updates the total', async ({ cartPage }) => {
await cartPage.goto();
await cartPage.removeItem('Blue Mug');
await expect(cartPage.row('Blue Mug')).toHaveCount(0);
await expect(cartPage.total).toHaveText('$12.00');
});Why it is good: the row locator is defined once and parameterised, the test reads as a specification, and the same CartPage supports other tests such as “cannot remove the last item” because it does not assert on its own.
Key takeaways
- The Page Object Model puts how a page works in one class so tests express what the user does and expects.
- Expose locators and intention-named methods; keep behavioural assertions in tests.
- Model repeated UI as component objects scoped to a root locator and compose them into pages.
- In Playwright, inject page objects with fixtures via
test.extend; in Selenium, storeBylocators and use explicit waits. - Avoid god pages, hidden assertions, leaked locators and deep inheritance. Introduce POM when duplication appears.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- How do Playwright fixtures work? Build a custom fixture with test.extend.
- How do you implement the Page Object Model, and why do many teams avoid PageFactory?
- How do you handle StaleElementReferenceException in Selenium?
- Explain Browser, BrowserContext and Page in Playwright. Why do contexts matter for test isolation?