When the "Submit" button gets renamed to "Sign In", a suite without Page Object Model requires updating every test that clicks it; with POM, you change one line in LoginPage.ts and the rest follow. The pattern has one common misuse: putting expect() assertions inside page object methods, which makes failure messages ambiguous and breaks the separation between how to interact with a page and what a test verifies. This article builds a complete LoginPage and DashboardPage from scratch, then wires them into Playwright fixtures so tests receive page objects directly in their signatures instead of constructing them manually.

What Page Object Model actually solves

POM is a design pattern, not a Playwright feature. The idea: create a class that represents a page (or a section of a page). The class contains the locators and the methods that interact with that page. Tests use the class instead of raw Playwright commands.

Without POM, your tests look like this:

test('user can log in', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

test('login fails with wrong password', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('wrongpassword');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('Invalid credentials')).toBeVisible();
});

With POM:

test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

test('login fails with wrong password', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'wrongpassword');
  await expect(page.getByText('Invalid credentials')).toBeVisible();
});

When the button changes from "Submit" to "Sign In", you fix it in LoginPage.ts. Both tests stay green without touching them.

Building your first Page Object

Create a pages/ folder at the project root. Inside it, LoginPage.ts:

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

export class LoginPage {
  readonly page: Page;
  readonly loginButton: Locator;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.usernameInput = page.getByLabel('Username');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Submit' });
  }

  async goto() {
    await this.page.goto('https://lab.becomeqa.com');
  }

  async login(username: string, password: string) {
    await this.loginButton.click();
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Two things to notice. First, locators are defined in the constructor as class properties. This is the Playwright-recommended approach. Locators are lazy: they don't search the DOM when defined, only when used. Second, methods represent user actions (goto, login), not individual clicks. Tests call actions, not implementation details.

Add more pages

Add a DashboardPage.ts for the page after login:

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

export class DashboardPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly itemsTable: Locator;
  readonly addItemButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole('heading', { name: 'My Travel Items' });
    this.itemsTable = page.getByRole('table');
    this.addItemButton = page.getByRole('button', { name: 'Add Item' });
  }

  async isLoaded() {
    await this.heading.waitFor({ state: 'visible' });
  }

  async getRowCount() {
    const rows = this.page.getByRole('row');
    return await rows.count() - 1; // subtract header row
  }

  async clickAddItem() {
    await this.addItemButton.click();
  }
}

Tests now read like a user flow:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test('dashboard shows items after login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await dashboardPage.isLoaded();

  const rowCount = await dashboardPage.getRowCount();
  expect(rowCount).toBeGreaterThan(0);
});

Use a BasePage for shared behavior

If you have 10 page objects and they all need the same navigation logic or a common waitForLoad method, put that in a base class:

// pages/BasePage.ts
import { Page } from '@playwright/test';

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

  async waitForNetworkIdle() {
    await this.page.waitForLoadState('networkidle');
  }

  async getPageTitle() {
    return await this.page.title();
  }

  async scrollToBottom() {
    await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  }
}

// pages/LoginPage.ts
import { Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class LoginPage extends BasePage {
  readonly loginButton: Locator;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: any) {
    super(page);
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.usernameInput = page.getByLabel('Username');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Submit' });
  }

  async goto() {
    await this.page.goto('https://lab.becomeqa.com');
  }

  async login(username: string, password: string) {
    await this.loginButton.click();
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Don't overdo inheritance. One level (BasePage → specific page) is usually enough. Deep inheritance chains become hard to follow.

Turn page objects into fixtures

Creating new LoginPage(page) in every test is repetitive. Playwright fixtures let you inject page objects automatically:

// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type PageFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<PageFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect } from '@playwright/test';

Now import test from your fixture file instead of from Playwright:

// tests/login.spec.ts
import { test, expect } from '../fixtures/pages.fixture';

test('user can log in', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await expect(loginPage.page.getByText('My Travel Items')).toBeVisible();
});

test('dashboard shows items after login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await dashboardPage.isLoaded();

  const rowCount = await dashboardPage.getRowCount();
  expect(rowCount).toBeGreaterThan(0);
});

Page objects appear in the test signature. No constructor calls, no imports of page classes in test files, just the objects, ready to use.

This fixture pattern is the professional standard for Playwright projects. Once you have more than 5 page objects, fixtures make test files dramatically cleaner than manual construction.

What belongs in a page object and what doesn't

Put in page objects:
  • Locators for the page's elements
  • Methods that represent user actions on the page
  • Wait conditions specific to the page (isLoaded, waitForModal)
  • Simple data getters (getRowCount, getHeadingText)
Keep in test files:
  • Assertions (expect(...))
  • Test data
  • Test logic (what to do, in what order)
  • Descriptions of what the test verifies

The rule: page objects describe HOW to interact with the page. Tests describe WHAT to verify. If you put assertions inside page objects, tests become harder to read and failures become harder to diagnose.

// Wrong — assertion inside page object
async login(username: string, password: string) {
  await this.loginButton.click();
  await this.usernameInput.fill(username);
  await this.passwordInput.fill(password);
  await this.submitButton.click();
  await expect(this.page.getByText('My Travel Items')).toBeVisible(); // don't do this
}

// Right — page object just performs the action
async login(username: string, password: string) {
  await this.loginButton.click();
  await this.usernameInput.fill(username);
  await this.passwordInput.fill(password);
  await this.submitButton.click();
}
// Test does the assertion
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();

Project structure with POM

A clean folder structure for a POM-based project:

project/
  pages/
    BasePage.ts
    LoginPage.ts
    DashboardPage.ts
    ItemsPage.ts
  fixtures/
    pages.fixture.ts
    auth.fixture.ts
  tests/
    auth/
      login.spec.ts
      logout.spec.ts
    items/
      items-list.spec.ts
      items-crud.spec.ts
    api/
      items-api.spec.ts
  playwright.config.ts
  package.json

Create a page object only when you have two or more tests that interact with the same page. One test that uses a page directly is fine. The overhead of a class isn't worth it for a single test.
→ See also: Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Advanced Page Object Patterns in Playwright | TypeScript Interfaces and Types for Page Object Model | JavaScript Classes for QA Engineers: Building Page Objects

FAQ

Should every page in the app have a page object?

Only pages that have automated tests. If you have a settings page with no tests, don't create a SettingsPage.ts just to have it.

My page object is getting huge, 500 lines. What do I do?

Split by section. A page with a table, a modal, and a sidebar can become ItemsTablePage, AddItemModal, and a parent ItemsPage that composes them. Or extract the modal into its own class and import it into the page.

Can I use page objects for API tests?

The pattern is sometimes called "API Objects": a class that wraps related API calls the same way a page object wraps UI interactions. It's useful when many tests hit the same endpoints. Not mandatory, but the same benefits apply.

When should I switch from no-POM to POM?

When you find yourself copy-pasting the same 4-5 lines for the third time. That's the signal. Don't build page objects speculatively before you know you need them.