This episode covers the Page Object Pattern, reusable page classes, multi-page flows and shared fixtures, as well as suite maintainability.

When your test count reaches dozens or hundreds, writing every action directly inside test() blocks will make the suite hard to maintain. This episode 6 covers the Page Object Model (POM) — the most popular design pattern for organizing end-to-end tests — and how to combine it with Playwright's built-in fixtures.
This pattern changes the way you think: from writing tests to designing page interfaces. Each page of the application is mapped to a class that encapsulates locators and actions. Tests then only call expressive methods, so the test intent is clearly readable and UI changes only need to be edited in one place.
Without Page Objects, a small change to the page structure — for example, changing a button's data-testid attribute — could force you to edit a dozen tests at once. Locators are also scattered everywhere, making it hard to understand who is doing what in the suite.
With Page Objects, all location and interaction details are gathered in one class per page. Tests only use the public methods of that class. The result: UI changes only need to be edited once, and tests become living documentation of the application's behavior.
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Notice that each locator is declared once as a class property. page.getByLabel('Email') is re-evaluated every time it's used, so it's safe for dynamic applications. The login(email, password) method wraps a sequence of actions into one meaningful operation.
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('successful login leads to the dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'secret123');
await expect(page).toHaveURL(/dashboard/);
});Compared to writing page.getByLabel('Email').fill(...) over and over, the test block above is far more concise. new LoginPage(page) receives the page fixture as its only dependency — a pattern consistent with the way Playwright hands objects to tests.
A page class should only contain operations that many tests actually need. Avoid writing one giant method that mimics an entire business flow; break it into small operations like isErrorMessageVisible() or getTitle(). Small methods are easier to combine and easier to debug when they fail.
export class CheckoutPage {
constructor(readonly page: Page) {}
async addToCart() {
await this.page.getByRole('button', { name: 'Add to Cart' }).click();
}
async getTotal() {
return this.page.locator('#total').textContent();
}
}getTotal() returns data, while addToCart() performs an action. This separation of actions and queries makes tests easier to verify.
Real business flows almost always cross multiple pages. Page Objects make navigation between pages easy by returning the destination page instance from the method that triggers the transition:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('complete login to dashboard flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'secret123');
const dashboard = new DashboardPage(page);
await expect(dashboard.userName).toBeVisible();
});Initializing a new page object on the same page is the simplest, easiest-to-follow approach. When the configuration gets more complex, move page object creation into a custom fixture so tests don't repeat new LoginPage(page).
Fixtures are Playwright's mechanism for injecting dependencies into tests consistently. Here's an example of a custom fixture that exposes all page objects:
import { test as base, type Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from '@playwright/test';With these fixtures, tests can directly receive loginPage and dashboardPage without boilerplate. base.extend({...}) creates a new test runner that is a superset of the built-in one.
Test and method names are your first documentation. Use a given-when-then pattern or descriptive sentences that explain behavior, not mechanics. test('user can log in with a valid email and password') is far more informative than test('login test 1').
If the same pattern appears in many tests — like preparing data or logging in first — extract it into a fixture or helper. Duplication is the main enemy of maintainability; every duplicate means changes in several places at once.
npx playwright testThe command npx playwright test still works the same even though the structure now uses Page Objects — this pattern is transparent to the runner.
A healthy structure looks roughly like this:
playwright-project
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
├── fixtures/
│ └── pages.ts
├── tests/
│ ├── login.spec.ts
│ └── checkout.spec.ts
└── playwright.config.tsSeparating the pages, fixtures, and tests folders keeps the code easy to navigate. As the project grows, this simple rule prevents the test suite from turning into one messy folder.
Episode 6 equipped you with the test organization pattern used throughout the rest of the series: the Page Object Pattern wraps each page's locators and actions into reusable classes, custom fixtures inject page objects into tests without boilerplate, and clear naming principles keep the suite readable and maintainable.
Key takeaways:
base.extend remove page object creation boilerplate.In the next episode we'll discuss assertions and debugging — built-in assertions and matchers, custom and soft assertions, plus debugging tools like playwright codegen, debug mode, and the trace viewer that will be your main weapons when tests fail.