A test that says await page.locator('#email').fill(...) and await page.locator('#password').fill(...) works fine — once. Repeat it across 30 specs and the day a designer renames #email to #user-email, you have 30 files to update. The Page Object Model (POM) consolidates all that selector knowledge into one TypeScript class per page. The selectors live in one place; the test reads as a story (await loginPage.signIn(email, password)) instead of a sequence of CSS lookups. This lesson is the Playwright flavour of POM — typed Locators, classes injected with a Page, fixture composition for cleaner test signatures, and the directory layout that scales to a real codebase.
The basic class
The smallest useful POM is a class that owns selectors and exposes high-level actions:
// pages/LoginPage.ts
import { Page, Locator, expect } from "@playwright/test";
export class LoginPage {
private readonly page: Page;
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByTestId("login-error");
}
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();
}
async expectError(text: string) {
await expect(this.errorMessage).toContainText(text);
}
}Three things to internalise:
- Locators are stored, not elements.
this.emailInput = page.getByLabel('Email')doesn't query the DOM in the constructor — it stores a recipe. The query runs the moment youawaitan action on it. That laziness is what makes the POM survive page reloads, re-renders, and dynamic content. - Methods describe user intent.
login(email, password)reads like the action a user takes — not a list of CSS selectors and clicks. Tests that call it stay focused on what is being tested, not how the form is built. - Assertions can live in the page object too.
expectError(...)is a typed assertion the test calls without owning the selector. Keep test-specific assertions in the test; keep page-shape assertions on the page object.
Using a POM in a test
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.describe("Login", () => {
test("logs in with valid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("alice@test.com", "password123");
await expect(page).toHaveURL(/dashboard/);
});
test("shows error for invalid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("alice@test.com", "wrong");
await loginPage.expectError("Invalid credentials");
});
});The test reads top-to-bottom in plain English. The selectors don't appear anywhere in the spec — they live in LoginPage.ts. The day #email is renamed, you fix one line in LoginPage.ts and every spec that uses it inherits the fix.
POM as a fixture — the canonical Playwright pattern
The new LoginPage(page) line is the same in every test. Move it into a fixture:
// fixtures/index.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
import { ProductListPage } from "../pages/ProductListPage";
import { CheckoutPage } from "../pages/CheckoutPage";
type Pages = {
loginPage: LoginPage;
productListPage: ProductListPage;
checkoutPage: CheckoutPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
productListPage: async ({ page }, use) => {
await use(new ProductListPage(page));
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
}
});
export { expect } from "@playwright/test";Now every test gets typed page objects via destructuring:
import { test, expect } from "../fixtures";
test("logs in", async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login("alice@test.com", "password123");
await expect(page).toHaveURL(/dashboard/);
});The two-line setup-then-call is gone. Tests that need a page object get one for free; tests that don't, don't. The framework wires it up.
Page-object architecture
A more realistic POM — checkout flow
A page that has multiple form fields, a couple of buttons, and a few assertions worth scoping:
// pages/CheckoutPage.ts
import { Page, Locator, expect } from "@playwright/test";
export class CheckoutPage {
private readonly page: Page;
private readonly firstNameInput: Locator;
private readonly lastNameInput: Locator;
private readonly postalCodeInput: Locator;
private readonly continueButton: Locator;
private readonly finishButton: Locator;
private readonly errorBanner: Locator;
private readonly orderTotal: Locator;
private readonly confirmationHeading: Locator;
constructor(page: Page) {
this.page = page;
this.firstNameInput = page.getByPlaceholder("First Name");
this.lastNameInput = page.getByPlaceholder("Last Name");
this.postalCodeInput = page.getByPlaceholder("Zip/Postal Code");
this.continueButton = page.getByRole("button", { name: "Continue" });
this.finishButton = page.getByRole("button", { name: "Finish" });
this.errorBanner = page.locator("[data-test='error']");
this.orderTotal = page.locator(".summary_total_label");
this.confirmationHeading = page.getByRole("heading", { name: /thank you/i });
}
async fillBuyerDetails(firstName: string, lastName: string, postal: string) {
await this.firstNameInput.fill(firstName);
await this.lastNameInput.fill(lastName);
await this.postalCodeInput.fill(postal);
await this.continueButton.click();
}
async confirmOrder() {
await this.finishButton.click();
}
async expectError(text: string) {
await expect(this.errorBanner).toContainText(text);
}
async expectOrderTotalContains(text: string) {
await expect(this.orderTotal).toContainText(text);
}
async expectConfirmation() {
await expect(this.confirmationHeading).toBeVisible();
}
}Used in a test:
import { test, expect } from "../fixtures";
test("user completes the full checkout", async ({ checkoutPage, productListPage, loginPage, page }) => {
await loginPage.goto();
await loginPage.login("standard_user", "secret_sauce");
await productListPage.addToCart("Sauce Labs Backpack");
await productListPage.openCart();
await page.getByRole("button", { name: "Checkout" }).click();
await checkoutPage.fillBuyerDetails("Alice", "Reed", "E1 6AN");
await checkoutPage.expectOrderTotalContains("Total");
await checkoutPage.confirmOrder();
await checkoutPage.expectConfirmation();
});The test reads the way you'd describe the flow on a whiteboard. Selectors live in the page objects; navigation lives in the test; assertions are typed methods that don't leak structure.
Project structure that scales
A real Playwright project with POM looks like this:
playwright-ecommerce-tests/
├── pages/
│ ├── LoginPage.ts
│ ├── ProductListPage.ts
│ ├── ProductDetailPage.ts
│ ├── CartPage.ts
│ └── CheckoutPage.ts
├── fixtures/
│ ├── index.ts ← test.extend with all page-object fixtures
│ └── factories.ts ← typed test data factories
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── signup.spec.ts
│ ├── shop/
│ │ ├── catalogue.spec.ts
│ │ ├── search.spec.ts
│ │ └── checkout.spec.ts
│ └── auth.setup.ts
├── playwright.config.ts
└── tsconfig.json
The pages/ folder holds one file per page or major component. The fixtures/ folder wires every page object into a custom test. Tests are organised by feature, not by page — one checkout.spec.ts may use three different page objects.
When NOT to write a POM
POM has costs: a class per page, an indirection layer, a learning curve for new joiners. Skip it when:
- The test is a one-off (a single happy-path smoke check that won't be edited again).
- The page is genuinely simple — one form, one assertion. A direct
page.locator(...)is more readable than a wrapper. - The "page" is really a small component that's reused across multiple pages — write a small component object instead (
pages/HeaderNav.ts) that takes a Locator scope, not a Page.
For everything else — anything edited weekly, anything used by 3+ tests — the POM pays for itself in the first two refactors.
Component objects — sub-page reuse
When you have a header, a footer, or a complex widget that appears on multiple pages, build a class that takes a Locator (the scope) instead of a Page:
// pages/components/HeaderNav.ts
export class HeaderNav {
private readonly cartLink: Locator;
private readonly cartBadge: Locator;
private readonly logoutButton: Locator;
constructor(scope: Locator) {
this.cartLink = scope.getByRole("link", { name: /cart/i });
this.cartBadge = scope.locator(".shopping_cart_badge");
this.logoutButton = scope.getByRole("button", { name: "Logout" });
}
async openCart() { await this.cartLink.click(); }
async expectCartCount(n: number) { await expect(this.cartBadge).toHaveText(String(n)); }
}Used inside a page object:
export class ProductListPage {
readonly nav: HeaderNav;
// ...
constructor(page: Page) {
this.nav = new HeaderNav(page.locator("header"));
}
}Component objects compose. A ProductListPage has a nav component; a CheckoutPage has the same nav component. One source of truth for header behaviour across the entire suite.
Coming from Cypress?
Cypress POMs typically use plain functions or objects (no new):
// Cypress style
export const loginPage = {
visit: () => cy.visit("/login"),
enterEmail: (email) => cy.get("[data-testid=email]").type(email),
submit: () => cy.get("[data-testid=submit]").click(),
};Playwright style uses classes injected with Page:
// Playwright style
export class LoginPage {
constructor(private page: Page) {}
async goto() { await this.page.goto("/login"); }
async enterEmail(email: string) { await this.page.getByLabel("Email").fill(email); }
async submit() { await this.page.getByRole("button", { name: "Sign in" }).click(); }
}The Playwright class form gives you better TypeScript ergonomics (private fields, typed Locators stored as instance properties) and integrates cleanly with the test.extend fixture pattern. Migration is usually mechanical — wrap each Cypress object in a class, replace cy.* with this.page.*, add async/await.
⚠️ Common mistakes
- Storing element handles instead of Locators.
this.emailInput = await page.locator('#email').elementHandle()is the trap. ElementHandles go stale across navigations and re-renders; Locators don't. Always store the Locator (noawait, noelementHandle) and let Playwright re-query on each action. - Hiding navigation in page objects.
loginPage.login(...)triggers a navigation to/dashboard. Should the page object then return aDashboardPage? Some teams say yes; others find the chained-return style confusing. The cleaner pattern: page objects expose actions; tests handle navigation flow.await loginPage.login(...); await dashboardPage.expectWelcome()reads more naturally thanawait loginPage.login(...).then(d => d.expectWelcome()). - Putting test-specific logic in page objects. A page object should be reusable across many tests. If you find yourself adding
loginAsAliceWithRememberMeAndAccept2FA(...)methods, you've baked a test scenario into the page model. Keep page objects generic; let tests compose the specifics.
🎯 Practice task
Build a two-page POM and use it from a fixture. 30-40 minutes.
-
Create
pages/SauceLoginPage.ts:import { Page, Locator, expect } from "@playwright/test"; export class SauceLoginPage { private readonly page: Page; readonly username: Locator; readonly password: Locator; readonly loginButton: Locator; readonly errorMessage: Locator; constructor(page: Page) { this.page = page; this.username = page.getByPlaceholder("Username"); this.password = page.getByPlaceholder("Password"); this.loginButton = page.getByRole("button", { name: "Login" }); this.errorMessage = page.locator("[data-test='error']"); } async goto() { await this.page.goto("https://www.saucedemo.com"); } async login(user: string, pass: string) { await this.username.fill(user); await this.password.fill(pass); await this.loginButton.click(); } async expectError(text: string) { await expect(this.errorMessage).toContainText(text); } } -
Create
pages/SauceInventoryPage.ts:import { Page, Locator, expect } from "@playwright/test"; export class SauceInventoryPage { private readonly page: Page; readonly items: Locator; readonly cartBadge: Locator; readonly cartLink: Locator; constructor(page: Page) { this.page = page; this.items = page.locator(".inventory_item"); this.cartBadge = page.locator(".shopping_cart_badge"); this.cartLink = page.locator(".shopping_cart_link"); } async addToCart(productName: string) { await this.items .filter({ hasText: productName }) .getByRole("button", { name: "Add to cart" }) .click(); } async expectCartCount(n: number) { await expect(this.cartBadge).toHaveText(String(n)); } async openCart() { await this.cartLink.click(); } } -
Create
fixtures/index.ts:import { test as base } from "@playwright/test"; import { SauceLoginPage } from "../pages/SauceLoginPage"; import { SauceInventoryPage } from "../pages/SauceInventoryPage"; type Fixtures = { loginPage: SauceLoginPage; inventoryPage: SauceInventoryPage; }; export const test = base.extend<Fixtures>({ loginPage: async ({ page }, use) => { await use(new SauceLoginPage(page)); }, inventoryPage: async ({ page }, use) => { await use(new SauceInventoryPage(page)); } }); export { expect } from "@playwright/test"; -
Create
tests/pom.spec.ts:import { test, expect } from "../fixtures"; test.describe("POM-driven Sauce Demo", () => { test("login → add 3 products → check badge", async ({ loginPage, inventoryPage }) => { await loginPage.goto(); await loginPage.login("standard_user", "secret_sauce"); await inventoryPage.addToCart("Sauce Labs Backpack"); await inventoryPage.expectCartCount(1); await inventoryPage.addToCart("Sauce Labs Bike Light"); await inventoryPage.addToCart("Sauce Labs Bolt T-Shirt"); await inventoryPage.expectCartCount(3); }); test("invalid login surfaces an error", async ({ loginPage }) => { await loginPage.goto(); await loginPage.login("standard_user", "wrong"); await loginPage.expectError("do not match"); }); }); -
Run all tests across all browsers — every spec uses POM but reads as a story.
-
Demonstrate the laziness property. Before clicking on cart, reload the page (
await page.reload()). The sameinventoryPage.cartBadgeLocator still works after reload — because it re-queries on use, not in the constructor. -
Stretch: add a
HeaderNavcomponent object that takes aLocatorscope. Use it insideSauceInventoryPageasthis.nav = new HeaderNav(page.locator('#header_container')). Move cart-related methods into the component object. Confirm the test still passes — you've now extracted reusable header logic.
You now have a robust pattern for managing selectors at scale. The next lesson takes the same suite and runs it across three browsers in parallel — Playwright's signature multi-browser feature, available with zero code changes.