Skip to content

Page Object Model in Playwright โ€‹

POM is the pattern that separates what the test does (business language) from how it's done in the UI (selectors and clicks). Here are the design decisions that have worked best for us โ€” including a counterintuitive one or two.

One class per page (not three) โ€‹

I come from a project where the POM was split into three folders per page โ€” UI (selectors), Tasks (actions), Questions (assertions), Screenplay-style. As the project grew, that separation multiplied files and friction.

The decision when migrating: a single class per page/modal/list consolidating selectors + actions + assertions. To keep long classes readable, editor folding regions:

ts
// #region Selectors ... // #endregion
// #region Actions ...   // #endregion
// #region Assertions ... // #endregion

page via constructor, not per method โ€‹

Two options for injecting the page:

ts
// A: per method โ€” explicit but repetitive and prone to passing the wrong page
await componentFormTasks.fillForm(page, componentData);

// B: via constructor โ€” clean, OOP, and EACH TEST CREATES ITS OWN INSTANCE
const componentForm = new ComponentFormPage(page);
await componentForm.fillForm(componentData);

We chose B. The deciding factor isn't aesthetics: with one instance per test, the POM stays isolated under parallel execution โ€” no shared state between workers.

Locators as properties, initialized in the constructor โ€‹

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

export class EditorPage {
  // #region Selectors
  private editorFrame: Locator;
  private save: Locator;

  constructor(private page: Page) {
    this.editorFrame = this.page.locator('#editor-iframe');
    this.save = this.page.getByTestId('btn-save');
  }
  // #endregion

  // #region Actions
  async saveChanges(): Promise<void> {
    await this.save.click();
  }
  // #endregion
}

Every locator centralized and typed (Locator/FrameLocator โ€” iframes are treated as just another property). Priority goes to user-facing locators: getByRole, getByText, getByLabel, getByTestId.

Parameterized dynamic locators โ€‹

When there are N similar elements (table rows), the locator is generated by a function that takes the data โ€” it targets the exact element regardless of its position in the DOM:

ts
private readonly list: { row: (data: string) => Locator };

constructor(private readonly page: Page) {
  this.list = { row: (data: string) => this.page.getByTestId(`row-${data}`) };
}

async validateRow(name: string): Promise<void> {
  await expect(this.list.row(name)).toBeVisible();
}

And while we're at it, the most profitable anti-flaky rule: web-first assertions (await expect(locator).toBeVisible()), which retry on their own, instead of evaluating booleans (expect(await locator.isVisible()).toBe(true)), which don't retry.

SOLID in practice: the dialogs case โ€‹

The anti-pattern we refactored: a monolithic Dialog class with dozens of locators from different dialogs and a validateFields(tipo, ventana, โ€ฆ) with a giant switch. Any change to one dialog affected the entire class (SRP violation).

The refactor: a base class with what's common + one subclass per dialog:

ts
// base/Dialog.ts โ€” only what's common to all dialogs
export class Dialog {
  protected readonly title: Locator;
  protected readonly confirm: Locator;

  constructor(protected page: Page) {
    this.title = page.locator('.dialog__title');
    this.confirm = page.getByTestId('primary-btn');
  }
}

// dialogs/DeleteDialog.ts โ€” the specifics
export class DeleteDialog extends Dialog {
  async validateDeleteMessage(): Promise<void> {
    await expect(this.page.locator('.dialog-message'))
      .toHaveText('Are you sure you want to delete this component?');
  }
}

Adding a new dialog = adding a subclass, without touching the base (OCP). TypeScript enables this with abstract classes and inheritance โ€” one of the reasons to automate in TS.

Two satellite patterns โ€‹

  • A "copies" object: UI texts (titles, button labels) centralized in a shared object โ€” getByRole('button', { name: copies.actions.import }). A copy change gets fixed in one place.
  • Composition: a page object can contain others (this.details = new ComponentFormPage(page)), and a test composes several (menu + page + dialog). Compose > inherit, except for what's genuinely common.