QA Interview Hub · Knowledge base · Quizzes

Patterns

Page Object Model

What problem Page Object solves

Without Page Object, locators and UI actions are scattered across all the tests. The data-testid of the login button changed, and you fix 40 files. Tests read as a set of clicks, not as a business scenario.

A Page Object (POM) encapsulates a page or a screen: locators and actions live in one class, and a test calls methods with business names. The main benefits:

  • One point of change. A locator changed, you fix one line.
  • Readability. await loginPage.login(user) instead of three fill and click.
  • Reuse. The same actions aren't copied between tests.

Playwright has an important feature: a Locator is lazy. It doesn't look for an element at the moment of creation, only at an action or a check. So locators can be safely created in the constructor as readonly fields, even if the page isn't open yet.

It is important to understand that POM is not "a class for every URL". The boundaries of a Page Object are defined by meaning: a logical screen, a modal window, a complex form. If a page is huge, it is split into components (the next section).

In an interview: A Page Object encapsulates a page's locators and actions in one class, so when the UI changes I fix one place, and tests read as business scenarios. In Playwright locators are lazy, so I declare them in the constructor as readonly fields.

A minimal Page Object in Playwright

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

export class LoginPage {
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(private readonly page: Page) {
    this.email = page.getByLabel('Email');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
    this.error = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}

The test:

test('shows error for wrong password', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'wrong');
  await expect(loginPage.error).toHaveText('Invalid credentials');
});

Key points: page comes from outside through the constructor (not a global variable), methods are async and return a Promise, locators are public so a test can make web-first checks with expect(locator). Methods describe the user's intent (login), not the mechanics (clickSubmitButton). If an action leads to another page, the method may return a new Page Object, but in Playwright this isn't mandatory, because the page is the same.

In an interview: My Page Object gets page in the constructor, keeps locators as readonly fields and has async methods with business names. I leave locators accessible, so as to make checks through expect(locator) in the test.

Assertions in a Page Object, god objects and inheritance

Assertions inside a Page Object

A classic discussion. Martin Fowler advises keeping assertions in tests: a Page Object describes a page, and the test decides what to check. A compromise approach, popular in Playwright: state-waiting methods are allowed, for example expectLoaded() with await expect(this.heading).toBeVisible(), because this is synchronisation, not a business check. But verifyOrderTotalIs100() in a Page Object hides the test's purpose and is poorly reusable.

A God Page Object

A class of 1500 lines with all the application's elements. Signs: dozens of unrelated methods, constant merge conflicts, nobody knows what is in there. The cure: split into pages and components (header, table, modal).

Inheritance chains

BasePage -> AuthenticatedPage -> AdminPage -> AdminUsersPage. A year later a change in BasePage breaks everything, and to understand a method you have to go through 4 files. A better option: a minimal or no base class, and shared parts through composition (readonly header = new Header(page)).

Other mistakes

  • Methods return a boolean instead of giving the test a locator (isVisible() doesn't wait, unlike expect().toBeVisible()).
  • page.waitForTimeout() in methods instead of auto-waiting.
  • A Page Object creates test data or goes to the API: that is a different responsibility.
In an interview: I keep business assertions in tests, and in a Page Object I allow only page state waits like expectLoaded. I split large pages into components, and instead of deep inheritance hierarchies I use composition.