Q24 of 42 · Playwright

How would you structure a POM for Playwright with shared base behaviour?

PlaywrightMidplaywrightpage-objectspatternsfixturesmid

Short answer

Short answer: Create a `BasePage` class with the `page` fixture and shared utilities (waiting helpers, URL navigation, common header interactions). Subclass per page: `LoginPage extends BasePage`. Inject the page via constructor. Methods return `this` or the next page object for fluent chaining.

Detail

A maintainable Playwright POM has a thin base class plus per-page classes. The base owns shared concerns; subclasses own page-specific locators and actions.

Base class:

import { Page, expect } from '@playwright/test';

export abstract class BasePage {
  constructor(protected readonly page: Page) {}

  async goto(path: string) {
    await this.page.goto(path);
  }

  // Shared header
  get header() {
    return this.page.getByRole('banner');
  }

  async logout() {
    await this.header.getByRole('button', { name: 'Log out' }).click();
  }

  async expectTitle(expected: string | RegExp) {
    await expect(this.page).toHaveTitle(expected);
  }
}

Per-page subclass:

import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';

export class LoginPage extends BasePage {
  private email = () => this.page.getByLabel('Email');
  private password = () => this.page.getByLabel('Password');
  private submit = () => this.page.getByRole('button', { name: 'Sign in' });

  async visit(): Promise<this> {
    await this.goto('/login');
    return this;
  }

  async loginAs(email: string, pwd: string): Promise<DashboardPage> {
    await this.email().fill(email);
    await this.password().fill(pwd);
    await this.submit().click();
    return new DashboardPage(this.page);
  }
}

Why locators-as-functions (instead of properties): the function returns a fresh Locator each call, which re-queries on each action. A property captured at construction time would also work because Locators are lazy, but functions read more naturally and avoid subtle staleness if you ever materialise.

Fixture integration — instead of new LoginPage(page) in every test, expose pages as fixtures:

type Pages = { loginPage: LoginPage; dashboard: DashboardPage };
export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => use(new LoginPage(page)),
  dashboard: async ({ page }, use) => use(new DashboardPage(page)),
});

// Spec
test('logs in', async ({ loginPage, dashboard }) => {
  await loginPage.visit();
  await loginPage.loginAs('alice@x.com', 'pwd');
  await expect(dashboard.welcome).toContainText('Welcome');
});

Discipline: assertions belong in tests, not in POM methods. POM exposes state (locators, simple readers); tests assert.

// EXAMPLE

pages/CartPage.ts

import { Locator, Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { CheckoutPage } from './CheckoutPage';

export class CartPage extends BasePage {
  rows = () => this.page.getByTestId('cart-row');
  total = () => this.page.getByTestId('total');
  private checkoutBtn = () => this.page.getByRole('button', { name: 'Checkout' });

  async visit() {
    await this.goto('/cart');
    return this;
  }

  async removeItem(itemId: string) {
    await this.page.getByTestId(`item-${itemId}`)
      .getByRole('button', { name: 'Remove' })
      .click();
    return this;
  }

  async proceedToCheckout(): Promise<CheckoutPage> {
    await this.checkoutBtn().click();
    return new CheckoutPage(this.page);
  }
}

// WHAT INTERVIEWERS LOOK FOR

Inheritance from a thin base, methods returning `this` or the next page, fixture integration, and the rule that assertions stay in tests.

// COMMON PITFALL

Putting assertions inside POM methods — failures point at `LoginPage.loginAs` instead of the test's intent.