Page Object Model in Playwright

9 min read

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 you await an 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 (no await, no elementHandle) 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 a DashboardPage? 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 than await 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.

  1. 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);
      }
    }
  2. 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();
      }
    }
  3. 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";
  4. 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");
      });
    });
  5. Run all tests across all browsers — every spec uses POM but reads as a story.

  6. Demonstrate the laziness property. Before clicking on cart, reload the page (await page.reload()). The same inventoryPage.cartBadge Locator still works after reload — because it re-queries on use, not in the constructor.

  7. Stretch: add a HeaderNav component object that takes a Locator scope. Use it inside SauceInventoryPage as this.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.

// tip to track lessons you complete and pick up where you left off across devices.