IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Intermediate·11 / 27

Page object models

When 10 tests all interact with the orders page, any selector change breaks all 10. A page object wraps those locators in one class — fix the selector once, tests are fixed. That's the whole point.

When you need page objects

Multiple test files share one page object — change a selector once and all tests are fixed

Page objects make sense when the same UI is accessed by multiple test files. If only one test touches the orders page, an inline helper is fine. When 5+ tests all do page.getByRole('button', { name: 'Create order' }) — that's the signal to extract an OrdersPage class.

The key rule: page objects contain locators and actions, not assertions. Assertions belong in the test — that's where the intent is documented. If a page object method throws or returns a boolean based on state, you've gone too far.

Creating a page object

A page object is a TypeScript class that takes page in the constructor, defines locators as properties, and exposes methods for common interactions. Locators are defined once in the constructor — they're lazy by default so they don't cause issues until used.

ts
// pages/orders-page.ts
import type { Page, Locator } from '@playwright/test'

export class OrdersPage {
  readonly page: Page
  readonly createOrderButton: Locator
  readonly orderList: Locator
  readonly statusFilter: Locator

  constructor(page: Page) {
    this.page = page
    this.createOrderButton = page.getByRole('button', { name: 'Create order' })
    this.orderList = page.getByRole('table')
    this.statusFilter = page.getByRole('combobox', { name: 'Status' })
  }

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

  async createOrder(item: string, quantity: number) {
    await this.createOrderButton.click()
    await this.page.getByLabel('Item').fill(item)
    await this.page.getByLabel('Quantity').fill(String(quantity))
    await this.page.getByRole('button', { name: 'Submit' }).click()
  }

  async filterByStatus(status: string) {
    await this.statusFilter.selectOption(status)
  }

  async cancelOrder(orderId: string) {
    await this.orderList.getByRole('row').filter({ hasText: orderId })
      .getByRole('button', { name: 'Cancel' }).click()
    await this.page.getByRole('button', { name: 'Confirm' }).click()
  }

  // Повертає локатор — тест сам робить expect
  getOrderRow(orderId: string): Locator {
    return this.orderList.getByRole('row').filter({ hasText: orderId })
  }
}

Use in tests

Tests become readable English: "go to orders, filter by pending, check count". The locator details are in the page object. The intent is in the test.

ts
import { test, expect } from '@playwright/test'
import { OrdersPage } from '../pages/orders-page'

test('filter shows only pending orders', async ({ page }) => {
  const orders = new OrdersPage(page)
  await orders.goto()
  await orders.filterByStatus('pending')

  // Перевірка — у тесті, не в page object
  await expect(orders.orderList.getByRole('row').filter({
    hasNot: orders.page.getByRole('cell', { name: 'Pending' })
  })).toHaveCount(0)
})

test('cancel order removes it from list', async ({ page }) => {
  const orders = new OrdersPage(page)
  await orders.goto()

  await orders.cancelOrder('ORDER-042')

  // getOrderRow повертає локатор — expect у тесті
  await expect(orders.getOrderRow('ORDER-042')).not.toBeVisible()
})

test('create order appears in list', async ({ page }) => {
  const orders = new OrdersPage(page)
  await orders.goto()

  await orders.createOrder('Laptop Stand', 2)

  await expect(orders.orderList).toContainText('Laptop Stand')
})

Combine with fixtures

The cleanest pattern: page objects for locator/action abstraction, fixtures for setup/teardown. The fixture creates the page object, navigates, seeds data — and the test gets a ready-to-use page object.

ts
// fixtures/index.ts
import { test as base } from '@playwright/test'
import { OrdersPage } from '../pages/orders-page'

export const test = base.extend<{ ordersPage: OrdersPage }>({
  ordersPage: async ({ page }, use) => {
    const orders = new OrdersPage(page)
    await orders.goto()
    await use(orders)
  },
})

// tests/orders.spec.ts
import { test, expect } from '../fixtures'

// Тест отримує вже готову сторінку — без goto() всередині
test('orders page loads', async ({ ordersPage }) => {
  await expect(ordersPage.orderList).toBeVisible()
})