Skip to main content

Free sample lesson

Why page objects

Module 4, The Page Object Model, from Playwright Project Pack. This is the full lesson, exactly as it appears in the course.

A page object is a small class that represents one screen of the app. It holds the locators for that screen and a few methods that act on it. Your tests then talk to the page object instead of poking at raw selectors. This is the single biggest readability and maintainability win in the whole framework.

Three layers: test specs use page objects, which drive the Playwright browser page.Layers of the Page Object Model Test specs what to verify · login.spec.ts Page Objects how to interact · LoginPage Playwright Page the real browser

The problem with inline selectors

Without page objects, every test repeats the same selectors and steps. Here is what a login test looks like with the selectors inlined:

test('user can login', async ({ page }) => {
  await page.goto('/')
  await page.getByTestId('username').fill('standard_user')
  await page.getByTestId('password').fill('secret_sauce')
  await page.getByTestId('login-button').click()
})

Copy that into ten tests and you have those three selectors written ten times.


What goes wrong at scale

That duplication hurts in three ways:

  • If a locator changes, you edit it in every test that used it.
  • The test reads like a list of clicks, not like a description of behavior.
  • Logging in is mixed into tests that are really about products or checkout.

The fix is to move the locators and the steps into one place.


The same flow with a page object

A page object pulls the locators and steps out of the test:

export class LoginPage {
  readonly username: Locator
  readonly password: Locator
  readonly loginButton: Locator

  constructor(page: Page) {
    this.username = page.getByTestId('username')
    this.password = page.getByTestId('password')
    this.loginButton = page.getByTestId('login-button')
  }

  async login(user: string, pass: string) {
    await this.username.fill(user)
    await this.password.fill(pass)
    await this.loginButton.click()
  }
}

The test gets shorter and clearer

Now the test says what it does, not how:

test('standard_user logs in successfully', async () => {
  await login.login(USERS.standard, PASSWORD)
  await login.expectLoggedIn()
})

One line to log in. Anyone can read it. The selectors live in LoginPage, so the test stays focused on behavior.


Maintainability: change in one place

When the login button's data-test value changes, you update a single line in LoginPage:

this.loginButton = page.getByTestId('signin-button')

Every test that logs in keeps working. No find-and-replace across the suite. That is the maintainability payoff.


Reusability across suites

The same LoginPage is used by the login suite, the products suite, the cart suite, and the checkout suite. They all start by logging in, and they all do it through the one page object. Write the flow once, reuse it everywhere.


The pattern this framework follows

This framework has one page object per screen: LoginPage, InventoryPage, ProductDetailPage, CartPage, and CheckoutPage. Each one owns its locators in the constructor and exposes a handful of action methods. Tests import them and read like plain English. The next two lessons walk through the real classes.

Keep going with Playwright Project Pack

12 modules and 44 lessons. Lifetime access and free updates included.

Get the full course

Or get every course in the Ultimate QA Automation Bundle