Lesson 11

Page Object Model

Why POM · Page class anatomy · Selectors as constants · Assertions in methods · beforeAll vs beforeEach

→ next slide  |  ESC overview

Lesson Plan
Lesson 11: Page Object Model

1. The problem — why flat tests break down

2. POM concept — one class per page

3. Anatomy of a page class

4. Flat vs POM — live refactor of cart test

5. beforeAll vs beforeEach — when to use each

Homework — Task 2: Refactor your flat cart tests into POM. Create page classes in pages/, move all selectors there, and make the test file selector-free. Open a PR.
The Problem

What Goes Wrong with Flat Tests

The same selectors appear in every file. One site change breaks everything.

search.spec.ts
await page.locator('.cartbtn-event.forward').click();
await expect(page.locator('.item-messagebox'))
  .toContainText('Toode lisati');
cart.spec.ts — identical copy
await page.locator('.cartbtn-event.forward').click();
await expect(page.locator('.item-messagebox'))
  .toContainText('Toode lisati');
Selector change = update every file Site renames .cartbtn-event.cart-btn. You hunt through every spec file to find all copies — 10 files, 10 chances to miss one.
Tests read like machine code .cartbtn-event.forward tells you nothing.
home.openShoppingCart() is self-explanatory.
POM fixes both: selectors in one place, test steps read like user stories.
Concept

What Is the Page Object Model?

Each page gets its own class. The class owns all selectors and actions — the test file stays selector-free.

Test file — what  no selectors
await home.search('harry potter');
await home.addToCart(0);
cart = await home.openShoppingCart();
await cart.verifyCartQuantity(2);
Page class — how  selectors here
const cartForwardBtn = '.cartbtn-event.forward';

async openShoppingCart() {
  await this.goToCartBtn.click();
  return new CartPage(this.page);
}
Test = what the user does
Reads like a user story — no noise
Class = how it's done
All selectors in one place
Browser executes it
Real clicks on the real page
Project Structure

Where Page Classes Live

playwright-kriso/
  tests/
    flat/
      cart.spec.ts ← raw page calls
    pom/
      cart.pom.spec.ts ← uses page classes
    fixtures.ts ← injects page objects
  pages/ ← one file per page
    HomePage.ts
    CartPage.ts
    ProductPage.ts
One class per page (or feature area) HomePage — navigation, search, add to cart
CartPage — quantities, totals, remove
ProductPage — product details, add to cart
Rule: zero selectors in test files If you see .locator( or .getByRole( in a spec.ts file, it belongs in a page class instead.
Page Class Anatomy

Structure of a Page Class

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

// ① Selectors — module-level constants
const logoSelector   = '.logo-icon';
const cartForwardBtn = '.cartbtn-event.forward';

export class HomePage {
  private logo:        Locator;
  private goToCartBtn: Locator;

  constructor(private page: Page) {
    this.logo        = page.locator(logoSelector);   // ② lazy locator
    this.goToCartBtn = page.locator(cartForwardBtn);
  }

  async openUrl() {              // ③ action method
    await this.page.goto('https://www.kriso.ee/');
  }

  async verifyLogo() {           // ④ verify — assertion inside
    await expect(this.logo).toBeVisible();
  }

  async openShoppingCart(): Promise<CartPage> {  // ⑤ factory
    await this.goToCartBtn.click();
    return new CartPage(this.page);
  }
}
① Constants at top Every selector visible in one place.
Change it once → fixed everywhere.
② Lazy locators No DOM query when the constructor runs.
The DOM is touched only on .click(), .fill(), etc.
④ verify methods Assertions live in the page class. Tests call await home.verifyLogo()
⑤ Factory method openShoppingCart() navigates and returns a new CartPage.
Comparison

Flat Test vs POM Test — Side by Side

Flat — cart.spec.ts
test('Test add book to cart', async () => {
  await page.getByRole('link', {
    name: 'Lisa ostukorvi'
  }).first().click();

  await expect(
    page.locator('.item-messagebox')
  ).toContainText('Toode lisati ostukorvi');

  await expect(
    page.locator('.cart-products')
  ).toContainText('1');
});

test('Test verify cart has two items', async () => {
  await page.locator('.cartbtn-event.forward').click();
  await expect(
    page.locator('.order-qty > .o-value')
  ).toContainText('2');
});
POM — cart.pom.spec.ts
test('Test add book to cart', async () => {
  await home.addToCart(0);
  await home.verifyItemAddedToCart();
  await home.verifyCartCount('1');
});

test('Test verify cart has two items', async () => {
  cart = await home.openShoppingCart();
  await cart.verifyCartQuantity(2);
});
What changed:
  • No selectors in the test file
  • Each line reads like a user action
  • Selector changes → fix in one page class
  • Test logic and selector logic are separate
Assertions

Putting Assertions Inside Page Methods

// CartPage.ts

async verifyCartQuantity(number: number) {
  await expect(this.cartQuantityEl)
    .toContainText(number.toString());
}

// Asserts rows sum == displayed total,
// then returns the sum for cross-test comparison.
async verifyCartSumIsCorrect(): Promise<number> {
  const items = await this.rowSubtotals.all();
  let sum = 0;
  for (const item of items) {
    sum += parseFloat(
      (await item.textContent() ?? '')
        .replace(/€/g, '').replace(',', '.')
    );
  }
  const total = parseFloat(
    (await this.orderTotal.textContent() ?? '')
      .replace(/€/g, '').replace(',', '.')
  );
  expect(total).toBeCloseTo(sum, 2);
  return sum;
}
In the test — clean and readable
test('Test verify cart has two items', async () => {
  cart = await home.openShoppingCart();
  await cart.verifyCartQuantity(2);
});
test('Test verify cart sum of two', async () => {
  cartSumOfTwo = await cart.verifyCartSumIsCorrect();
});
test('Test verify cart sum of one', async () => {
  cartSumOfOne = await cart.verifyCartSumIsCorrect();
  expect(cartSumOfOne).toBeLessThan(cartSumOfTwo);
});
Why return the sum? cartSumOfTwo is stored so the last test can prove the total actually dropped — not just that the new total is internally consistent.
Test Lifecycle

beforeAll vs beforeEach

beforeAll — runs ONCE before the whole suite
test.beforeAll(async ({ browser }) => {
  const context = await browser.newContext();
  page = await context.newPage();
  home = new HomePage(page);
  await home.openUrl();
  await home.acceptCookies();
});
Use when:
  • Tests build on each other (add → verify → remove)
  • State must persist between tests
  • Setup is expensive (login, seed data)
Also requires test.describe.configure({ mode: 'serial' }) to guarantee order.
beforeEach — runs before EVERY test
test.beforeEach(async ({ page }) => {
  await page.goto('https://www.kriso.ee/');
  await page.fill(searchInput, 'harry potter');
  await page.click(searchButton);
});

test('shows multiple results', async ({ page }) => { ... });
test('has add-to-cart buttons', async ({ page }) => { ... });
Use when:
  • Tests are independent — order doesn't matter
  • Each test needs a clean starting state
  • Tests can run in parallel
Each test gets a fresh browser context — no leftover state.
beforeAll = shared, sequential, stateful  ·  beforeEach = isolated, parallel, clean
Shared State

Sharing Data Between Tests

Page objects and values shared across tests are declared at module scope — outside the describe block.

test.describe.configure({ mode: 'serial' });

// ── Module scope — all tests share these ──
let page: Page;
let home: HomePage;
let cart: CartPage;      // set by home.openShoppingCart()
let cartSumOfTwo = 0;    // written in test 7, read in test 9
let cartSumOfOne = 0;

test.describe('Add Books to Shopping Cart (POM)', () => {

  test.beforeAll(async ({ browser }) => {
    const context = await browser.newContext();
    page = await context.newPage();
    home = new HomePage(page);
    await home.openUrl();
    await home.acceptCookies();
  });

  test.afterAll(async () => {
    await page.context().close();
  });

  // tests read and write home, cart, cartSumOfTwo, cartSumOfOne
});
Why module scope? beforeAll runs once — the page object must live somewhere all tests can reach it. Module scope is the standard place.
Why store cart separately? cart doesn't exist at setup time. Test 6 navigates to the cart and assigns it: cart = await home.openShoppingCart(). Tests 7–9 then use it.
Why store cartSumOfTwo? Test 9 needs to compare the sum after removal against the sum before. Storing it at module scope is the only way to pass it across tests.
Conventions

Method Naming in Page Classes

openABC() Navigation or opening something.

openUrl()
openBookPage(1)
openShoppingCart()
verifyABC() Contains an assertion. Test calls it and trusts it.

verifyLogo()
verifyCartQuantity(2)
verifyCartSumIsCorrect()
addABC() / removeABC() Mutations — add or remove something.

addToCart(0)
removeItemFromCart(0)
acceptABC() Dismissing dialogs or banners.

acceptCookies()
acceptConsent()
continueABC() Going back or resuming a flow.

continueShopping()
search(keyword) Short, verb-only names for simple actions.

search('harry potter')
search('tolkien')
Method names describe user intent, not implementation. openShoppingCart() not clickForwardButton().

Homework — Task 2: Page Object Model

Refactor your flat cart tests from Task 1 into the POM pattern.

Page classes to create:
  • pages/HomePage.ts — selectors + methods for home page and search results
  • pages/CartPage.ts — selectors + verify methods for cart page
Move all selectors out of the test file into these classes.
Test file to create:
  • tests/pom/cart.pom.spec.ts
  • Same test names as the flat version
  • No .locator( or .getByRole( in the test file
  • Use beforeAll + test.describe.configure({ mode: 'serial' })

Push your changes to your fork and open a PR.