The Testing Academy · Class Notes Thursday, 15 October (IST)
Live class · study guide

The page object model in Playwright: why locators leave the spec, a LoginPage by hand, page classes from Snap Locator, an AI-written cart test trimmed with Ponytail, and the limits of POM

Why the page object model exists: one class per page, holding that page's locators and actions, so a spec keeps only its assertions. Then a LoginPage written by hand for TTACart, inventory and cart classes generated with the Snap Locator extension and its healer chains, a three-page cart test written by AI and trimmed with Ponytail, and the limits of POM. Specs 291 to 294, pushed to the batch repo.

By Pramod Dutta, The Testing Academy. Study notes from the Playwright 3x class, built from the session recording, the batch repository (commit "feat: add page object model module": specs 291 to 294, four page objects and a per-step screenshot helper, pushed as the class ended) and the Eraser notes deck (the 15 October section, with LoginPage, 292 and 293 as shown on screen). Every spec was run headless at the batch's 1920 × 1080 viewport with Playwright 1.63, and the two editor errors the class hit were reproduced with tsc against the repo's tsconfig. Spec 292's title check passes even with a wrong password; it is shown as written in class, then with its fix. Where the pushed code differs from the screen, the page follows the repo and says so.

01

What this class covered

  • What a page object model is: one class per page, holding its locators and methods
  • Why locators leave the spec, and when they can stay
  • Best practices and common mistakes
  • Spec 291: the login test without page objects
  • LoginPage by hand: readonly fields, the constructor, two actions and export
  • Spec 292: the same login through LoginPage, and a title check that cannot fail
  • Snap Locator: generating the inventory and cart page classes
  • The healer: three-way fallback chains, and how Playwright reads them
  • Spec 293: three page objects in one test, written by AI, and full permission to use AI from now on
  • Screenshots per test step, the Ponytail review, and where the data-test attribute belongs
  • The limits of POM: weak locators, giant classes and too much abstraction
  • Tasks and announcements
02

What a page object model is

The batch is in its endgame. Data-driven testing came last time, and the advanced Playwright framework comes next. In between sits the pattern that framework is built on.

The page object model (POM) is a design pattern where you create a separate class for each page of your application. On TTACart, the Testing Academy's practice shop, that means one class for the login page, one for the inventory page, one for the cart, one each for checkout step one and step two, and one for the checkout-complete page. Each class holds two things: the page's locators, and its methods, the actions you can take on that page.

Until now, the test file drove the browser itself. With POM, the test calls the page class, and the page class does the work. That is abstraction: the test states what it wants done, and the details of how stay hidden in the class. The same pattern is used in Selenium and Cypress as well as Playwright.

THE SPEC STOPS DRIVING THE BROWSER ITSELF Without page objects 291: locators, actions and the assertion all sit in the spec SPEC FILE does everything locators, actions, assertion BROWSER the page under test driven by the spec itself every spec repeats the locators With page objects 292: each layer has one job SPEC FILE assertions only calls loginPage.login() PAGE CLASS locators + actions LoginPage, one per screen BROWSER the page under test driven by the page class A changed button is now one edit in the page class, not one edit per spec.
The class's own before and after. Spec 291 keeps locators and actions in the test; 292 moves them into LoginPage and keeps only the assertion.
03

Why locators leave the spec

With a small suite, 10 to 100 tests, keeping the locators inside the spec file works, and it is what the batch has done so far. Below 100 or 200 tests there is no point in POM. Past that it breaks down. In the instructor's team the suite has reached 12,000 tests, with 30-plus QA engineers adding, deleting and maintaining locators every day. When the same locator is copied into many specs, a UI change means editing every one of them, and a change made for one test breaks another.

Hence the rule: locators live outside the spec, in a page class. You get one place to manage locators, one place to update when the UI changes, and locators that every test can reuse. The test calls methods, never selectors: it says loginPage.login(...) and does not care which locators or waits sit behind it. Think in actions, not internals.

Why teams use it, in short: reusable code, cleaner tests, easier maintenance, better readability, it scales to large suites and whole QA teams, and UI changes are quicker to absorb.

Three questions from the room settled the boundaries:

  • Are locators ever kept somewhere outside the page objects? No.
  • Is an environment file a kind of page object? No.
  • Do assertions go in the page object? No, in the spec. The spec holds only the assertions.
04

Best practices and common mistakes

The lists shown in class. Best practices:

  • One page class for one page.
  • Meaningful method names.
  • Assertions only in the test.
  • No huge "god classes" that do everything.
  • Reusable helpers where possible.
  • Prefer Playwright's own locators.
  • Components for sections that repeat across pages.

Common mistakes:

  • Putting everything into the test.
  • Hard-coding too many waits.
  • Brittle selectors.
  • One giant page class.
  • The same locator duplicated.
  • Unrelated pages mixed into one class.

Bad structure means hard maintenance. The repo's README for this module draws the line in one table:

Put in the page object Keep in the test
Locators Assertions about this scenario
Navigation (goto) Test data
Reusable actions (login) The order of steps
App quirks (data-test) What "correct" means
05

Spec 291: the login test without page objects

The starting point is last class's login test on the practice login page, cut down to one row of data, so it is data-driven with a single row. Two comments mark the two kinds of code that are about to move:

TypeScript
import { test, expect } from '@playwright/test';

test.describe('DDT Simple', () => {
    const data =
    {
        description: "valid credentials",
        username: "admin@gmail.com",
        password: "admin123",
        expectedURL: /admin/,
        shouldPass: true

    };

     test(`Login with : ${data.description}`, async ({ page }) => {
        await page.goto('https://app.thetestingacademy.com/playwright/multiple_element_filter');

        // page locators
        let textboxEmailAddress = page.getByRole("textbox", { name: "Email Address" });
        let textboxPassword = page.getByRole("textbox", { name: "Password" }).or(page.locator("#password")).or(page.locator("[name=\"password\"]"));
        let buttonLoginToPracticeAccount = page.getByRole("button", { name: "Login to Practice Account" }).or(page.getByTestId("login-button")).or(page.getByText("Login to Practice Account"));

        // page methods
        await textboxEmailAddress.fill(data.username);
        await textboxPassword.fill(data.password);
        await buttonLoginToPracticeAccount.click();


        await expect(page).toHaveURL(data.expectedURL);

    });
});
Text
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/20_Page_Object_Model/291_No_POM.spec.ts:14:10 › DDT Simple › Login with : valid credentials (1.4s)

  1 passed (1.8s)

Everything under // page locators is finding locators, and everything under // page methods is page actions, filling and clicking. Both move into a page class. What stays is the last line, the assertion, because from now on the only job of a test is the assertion.

Why the URL check passes. This practice form submits by GET, so after the click the address carries the form's fields in its query string, starting ?email=admin%40gmail.com. The pattern /admin/ matches the admin in the email, not an admin page, so the check passes for any well-formed email that contains "admin".

06

LoginPage by hand

The example moves to TTACart. First count the pages: login, inventory, cart, checkout step one, checkout step two and the order-complete page. To keep it simple, start with two, login and inventory. Page classes get a folder of their own, pages, next to the specs, with one page file per page, such as LoginPage.ts. Only the page classes go in that folder; the specs stay where they are.

On the login page, three elements matter: the username box, the password box and the login button. They become the page locators, declared as readonly fields of type Locator, along with the page itself:

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

export class LoginPage {
    // Page Locators
    readonly page: Page;
    readonly emailInput: Locator;
    readonly passwordInput: Locator;
    readonly loginButton: Locator;

Stop there and every field is underlined in red. TypeScript's message, as tsc prints it with the repo's tsconfig.json:

Text
tests/20_Page_Object_Model/pages/LoginPage.ts(6,14): error TS2564: Property 'page' has no initializer and is not definitely assigned in the constructor.
tests/20_Page_Object_Model/pages/LoginPage.ts(7,14): error TS2564: Property 'emailInput' has no initializer and is not definitely assigned in the constructor.
tests/20_Page_Object_Model/pages/LoginPage.ts(8,14): error TS2564: Property 'passwordInput' has no initializer and is not definitely assigned in the constructor.
tests/20_Page_Object_Model/pages/LoginPage.ts(9,14): error TS2564: Property 'loginButton' has no initializer and is not definitely assigned in the constructor.

A declared field holds nothing until something assigns it, and the repo's strict setting makes an unassigned field an error. The fix is the constructor, which runs when a test writes new LoginPage(page): it stores the page in this.page and builds each locator from it. Once the constructor assigns all four, the error is gone.

  • Why the constructor, and not the field declarations? From the repo's README: a Locator belongs to one page, and the page does not exist until a test hands it over. Playwright gives every test a fresh page, so the constructor is the first moment there is one.
  • What readonly does. A readonly field can be assigned once, in the constructor, and never again. It is a compile-time guard, so a stray = in a test cannot rebind a locator; at run time it is erased and freezes nothing.

Then the page actions. Two things can be done on this page: go to it, and fill the username and password, then click login. The finished class:

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

export class LoginPage {
    // Page Locators
    readonly page: Page;
    readonly emailInput: Locator;
    readonly passwordInput: Locator;
    readonly loginButton: Locator;

    constructor(page: Page) {
        this.page = page;
        this.emailInput = page.getByRole("textbox", { name: "Username" });
        this.passwordInput = page.getByRole("textbox", { name: "Password" })
        // TTACart uses data-test, not Playwright's default data-testid.
        this.loginButton = page.locator('[data-test="login-button"]');
    }

    // Page Actions
    async goto() {
        await this.page.goto('https://app.thetestingacademy.com/playwright/ttacart/');
    }

    async login(username: string, password: string) {
        await this.emailInput.fill(username);
        await this.passwordInput.fill(password);
        await this.loginButton.click();
    }







}

The pushed file builds the login button from TTACart's data-test attribute; why is covered with spec 293.

Export it. The first import of the class into a spec failed. TypeScript's message:

Text
tests/20_Page_Object_Model/292_POM.spec.ts(2,10): error TS2459: Module '"./pages/LoginPage"' declares 'LoginPage' locally, but it is not exported.

A class is private to its file until it is exported. export class LoginPage makes it importable, and every page class needs it.

07

Spec 292: the same login through LoginPage

Without page objects, this test would page.goto the login page, find and fill each box, click, then assert. With LoginPage it is four lines, and the commented lines keep the old way for reference:

TypeScript
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('POM with Login Page Simple', () => {
    test('Login with valid credns', async ({ page }) => {

            // This is without the POM
            // await page.goto('https://app.thetestingacademy.com/playwright/ttacart/');
            // await page.getByRole("textbox", { name: "Username" }).fill;
            // //...

            // With POM
            const loginPage = new LoginPage(page);
            await loginPage.goto();
            await loginPage.login("standard_user","tta_secret");
            await expect(page).toHaveTitle("TTACart - Login");

    });
});

The first attempt hit errors because the await keywords were missing. goto() and login() are async methods, so every call to them needs await, like any Playwright action. With them back:

Text
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/20_Page_Object_Model/292_POM.spec.ts:5:9 › POM with Login Page Simple › Login with valid credns (883ms)

  1 passed (1.2s)

It ran fast, and the custom report (tta-report/index.html) showed one test, passed.

A check that cannot fail. toHaveTitle retries until the title matches, and right after the click the browser is still on the login page, whose title is TTACart - Login. So the assertion passes at once, before the inventory page has loaded. Swap a wrong password into the same spec and it still passes, as the first run below shows. After a good login the title is TTACart - Products, and that is the title to expect.

The pushed spec, run with a wrong password:

Text
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/20_Page_Object_Model/292_POM.spec.ts:5:9 › POM with Login Page Simple › Login with valid credns (869ms)

  1 passed (1.2s)

The fix is one word:

TypeScript
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('POM with Login Page Simple', () => {
    test('Login with valid credns', async ({ page }) => {

            // This is without the POM
            // await page.goto('https://app.thetestingacademy.com/playwright/ttacart/');
            // await page.getByRole("textbox", { name: "Username" }).fill;
            // //...

            // With POM
            const loginPage = new LoginPage(page);
            await loginPage.goto();
            await loginPage.login("standard_user","tta_secret");
            await expect(page).toHaveTitle("TTACart - Products");

    });
});
Text
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/20_Page_Object_Model/292_POM.spec.ts:5:9 › POM with Login Page Simple › Login with valid credns (1.6s)

  1 passed (1.9s)

And with the wrong password, the fixed spec now fails, as a login test should:

Text
Running 1 test using 1 worker

  ✘  1 [chromium] › tests/20_Page_Object_Model/292_POM.spec.ts:5:9 › POM with Login Page Simple › Login with valid credns (6.1s)

  1) [chromium] › tests/20_Page_Object_Model/292_POM.spec.ts:5:9 › POM with Login Page Simple › Login with valid credns

    Error: expect(page).toHaveTitle(expected) failed

    Expected: "TTACart - Products"
    Received: "TTACart - Login"
    Timeout:  5000ms

    Call log:
      - Expect "toHaveTitle" with timeout 5000ms
        14 × locator resolved to <html lang="en">…</html>
           - unexpected value "TTACart - Login"

      14 |             await loginPage.goto();
      15 |             await loginPage.login("standard_user","not_the_password");
    > 16 |             await expect(page).toHaveTitle("TTACart - Products");
         |                                ^
      17 |
      18 |     });
      19 | });
        at tests/20_Page_Object_Model/292_POM.spec.ts:16:32

  1 failed
    [chromium] › tests/20_Page_Object_Model/292_POM.spec.ts:5:9 › POM with Login Page Simple › Login with valid credns
08

Snap Locator: page classes from the browser

Writing a class for every page by hand is repetitive, and it is not how page objects are made any more. The class's shortcut is Snap Locator, a Chrome DevTools extension made by the instructor: free, MIT-licensed and open source, with no tracking and no data collection. It exists because, in the instructor's words, the locator extension the batch used before captures your data, which most companies no longer allow. It was built entirely with AI, in Claude Code, without a line written by hand, in 30 to 40 minutes, and it is in use at the instructor's company and the companies he consults for.

Install it. Download the zip, open Chrome's extensions page, switch on Developer mode (without it the install fails), then drag the extension in, or load it unpacked. Open DevTools (right-click, Inspect, or F12), open the three-dot menu, and Snap Locator is there. Refresh the page you want to read. The code and the zip are in the snaplocator repository.

Use it. Pick an element and Snap Locator ranks locators for it: a recommended one with the highest score, such as a Playwright locator or a test id, then alternates, then fallbacks, the older CSS and XPath forms with positions, which score lower. Press plus to add the element to your list, pick the next one, and so on. Then open Generate, give the class a name such as InventoryPage, pick the framework (Playwright TypeScript here; Selenium Java and the others are in the list), and it writes the whole page class, page URL included. That is how the inventory page was done in class, and then the cart page: the cart heading, the item's title and price, and the checkout button.

In class, one generated locator was not valid, and it was flagged as a probable bug: read what it generates before you use it.

Snap Locator works on any site, and supports Playwright with TypeScript, Python and Java, Selenium with Java, Cypress, WebdriverIO and Robot Framework. It keeps a history of the elements you picked, and a search box tests any locator against the page and reports the match count. Searching #checkout on the cart page gave zero matches: the button has no id, only a data-test attribute, and the locator built from that is unique. Highlighting did not work in class; features like that are left for you to add.

The two classes, as pushed. Both find elements through TTACart's data-test attribute with a small helper, byTest, and the class names, Inventorypage and Cartpage, have a small p, so imports must match them exactly:

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

export class Inventorypage {
  readonly page: Page;
  readonly title: Locator;
  readonly firstItemName: Locator;
  readonly firstItemAddToCart: Locator;
  readonly cartLink: Locator;

  constructor(page: Page) {
    this.page = page;

    // TTACart tags elements with data-test, not Playwright's default
    // data-testid, so the page object owns that detail instead of making
    // every spec declare test.use({ testIdAttribute: 'data-test' }).
    const byTest = (id: string) => page.locator(`[data-test="${id}"]`);

    this.title = byTest('title');
    this.firstItemName = byTest('inventory-item-name').first();
    this.firstItemAddToCart = page.getByRole('button', { name: 'Add to cart' }).first();
    this.cartLink = byTest('shopping-cart-link');
  }

  async goto() {
    await this.page.goto('https://app.thetestingacademy.com/playwright/ttacart/inventory');
  }

  /** Adds the first product and returns its name, so the test can assert on it. */
  async addFirstItemToCart(): Promise<string> {
    const name = (await this.firstItemName.innerText()).trim();
    await this.firstItemAddToCart.click();
    return name;
  }

  async openCart() {
    await this.cartLink.click();
  }
}
TypeScript
import { type Locator, type Page } from '@playwright/test';

export class Cartpage {
  readonly page: Page;
  readonly title: Locator;
  readonly itemName: Locator;
  readonly itemPrice: Locator;
  readonly checkout: Locator;

  constructor(page: Page) {
    this.page = page;

    // TTACart uses data-test, not Playwright's default data-testid.
    const byTest = (id: string) => page.locator(`[data-test="${id}"]`);

    this.title = byTest('title');
    this.itemName = byTest('inventory-item-name');
    this.itemPrice = byTest('inventory-item-price');
    this.checkout = byTest('checkout');
  }

  async goto() {
    await this.page.goto('https://app.thetestingacademy.com/playwright/ttacart/cart');
  }
}

Four questions from the room:

  • My company will not allow an extension its security team has not approved. Then take the repository, read the code (there is nothing in it a security team should object to), and make your own copy under your own name. It is MIT, free for the batch.
  • Why not ask Claude to write the page objects? Tokens. At the instructor's company the AI budget is about 100 to 200 dollars a month, with plenty of other work to spend it on, and page objects are not where it goes.
  • Is Playwright's codegen the same thing? Similar, not the same. Codegen comes with Playwright (npx playwright codegen) and records what you do in a browser window; Snap Locator is the instructor's own DevTools extension.
  • Could we build an extension like this ourselves? Yes: give an AI agent a screenshot of this one, and it will build one.
09

The healer: three-way fallback chains

The feature the instructor singled out is the healer. For an element such as the checkout button, Snap Locator can give a healed path: three locators chained, so that if one stops working, another still finds the element. Not double sure, triple sure. Spec 291 already has two chains like this, built with .or(), and on screen, the login button in LoginPage was one, from the deck:

TypeScript
        this.loginButton = page.getByTestId("login-button").or(page.getByText("Login")).or(page.locator("#login-button"));

For dynamic values, Snap Locator also offers XPath axes, and its fallbacks come with a confidence score. And yes, the same chain can be written by hand.

A HEALER CHAIN IS ONE LOCATOR, NOT THREE TRIES IN TURN getByRole("button", ...) .or(getByTestId("login-button")) .or(getByText("Login to ...")) ONE LOCATOR the union of all three matches whatever any alternative matches ONE ELEMENT FOUND the action runs even with alternative 1 missing TWO ELEMENTS FOUND strict mode error the action never runs Playwright waits once, under one timeout, for the union to find the element. So every alternative in a chain must point at the same element on the page.
What the healer chain really does. Playwright does not try the first alternative, time out, then move to the next: it waits for the union, so the timeouts never stack.

How Playwright reads the chain. In class the healer was described as Playwright trying the first locator, then the second, then the third. What locator.or() actually builds is one locator that matches anything either side matches, and Playwright waits once, under one timeout, for it to find the element. Checked on the TTACart login page: a chain whose first alternative matched nothing clicked the button at once, with no wait for the first to time out, and a chain whose alternatives matched the username and the password boxes failed with strict mode violation ... resolved to 2 elements. So the conclusion holds, a chain is harder to break than one locator, as long as every alternative points at the same element.

10

Spec 293: three page objects in one test

With the login, inventory and cart classes in place, by hand or by Snap Locator, the next step joins them in one test. This time the spec was written by AI, with Ponytail asked to keep it to the minimum. The request, as dictated:

  • Navigate to the login page.
  • Add the first item on the inventory page.
  • Click the cart and navigate to the cart page.
  • Use the page object model to create spec 293, the cart add, with an assertion.
  • Use Ponytail for a minimum-code review.
ONE PAGE CLASS PER SCREEN: SIX ON TTACART, THREE BUILT IN THIS CLASS LOGIN SCREEN LoginPage written by hand goto(), login() INVENTORY SCREEN Inventorypage generated with Snap Locator addFirstItemToCart() CART SCREEN Cartpage generated with Snap Locator title, itemName, checkout CHECKOUT STEP ONE a class of its own not built yet CHECKOUT STEP TWO a class of its own not built yet CHECKOUT COMPLETE a class of its own not built yet Checkout 293 drives the top row in one test: log in, add the first item, find it in the cart.
TTACart's flow, one class per screen. The class built the first three, LoginPage by hand and the other two with Snap Locator, and joined them in spec 293.

From this class on, the batch has full permission to use AI. The reasoning: you now understand Playwright, page objects included, so repetitive work can go to AI. The rule has not changed since the start of the batch: when you understand the code, use AI; when you do not, do not. Ponytail installs with one command for Codex, Claude Code, Gemini or Copilot CLI; for any other agent, hand it Ponytail's install file and it installs itself.

The spec, as pushed:

TypeScript
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { Inventorypage } from './pages/InventoryPage';
import { Cartpage } from './pages/CartPage';
import { shot } from '../../utils/stepScreenshot';

test('add first item to cart and verify it in the cart', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const inventoryPage = new Inventorypage(page);
    const cartPage = new Cartpage(page);
    let itemName = '';

    await test.step('open the login page', async () => {
        await loginPage.goto();
        await shot(page, 0, 'login page');
    });

    await test.step('login as standard_user', async () => {
        await loginPage.login('standard_user', 'tta_secret');
        await expect(inventoryPage.title).toHaveText('Products');
        await shot(page, 1, 'inventory');
    });

    await test.step('add the first item to the cart', async () => {
        itemName = await inventoryPage.addFirstItemToCart();
        await shot(page, 2, 'item added');
    });

    await test.step('open the cart', async () => {
        await inventoryPage.openCart();
        await expect(cartPage.title).toHaveText('Your Cart');
        await shot(page, 3, 'cart');
    });

    await test.step('verify the item is in the cart', async () => {
        await expect(cartPage.itemName).toHaveText(itemName);
        await expect(cartPage.checkout).toBeVisible();
        await shot(page, 4, 'verified');
    });
});

Run with its steps printed:

Text
Running 1 test using 1 worker

     1.1 [chromium] › tests/20_Page_Object_Model/293_cart_add_assertion.spec.ts:7:5 › add first item to cart and verify it in the cart › open the login page (944ms)
     1.2 [chromium] › tests/20_Page_Object_Model/293_cart_add_assertion.spec.ts:7:5 › add first item to cart and verify it in the cart › login as standard_user (571ms)
     1.3 [chromium] › tests/20_Page_Object_Model/293_cart_add_assertion.spec.ts:7:5 › add first item to cart and verify it in the cart › add the first item to the cart (116ms)
     1.4 [chromium] › tests/20_Page_Object_Model/293_cart_add_assertion.spec.ts:7:5 › add first item to cart and verify it in the cart › open the cart (497ms)
     1.5 [chromium] › tests/20_Page_Object_Model/293_cart_add_assertion.spec.ts:7:5 › add first item to cart and verify it in the cart › verify the item is in the cart (29ms)
  ✓  1 [chromium] › tests/20_Page_Object_Model/293_cart_add_assertion.spec.ts:7:5 › add first item to cart and verify it in the cart (2.2s)

  1 passed (2.5s)
Step Page object call What the test checks
1. Open the login page loginPage.goto() nothing yet; a screenshot
2. Log in loginPage.login(...) inventoryPage.title reads Products
3. Add the first item inventoryPage.addFirstItemToCart() returns the item's name: Test.allTheThings() T-Shirt (Red)
4. Open the cart inventoryPage.openCart() cartPage.title reads Your Cart
5. Verify none cartPage.itemName equals that name; checkout is visible

addFirstItemToCart() returns the product's name, so the last step checks the item that was actually added, not a hard-coded string. As the README puts it, that is the kind of logic worth putting in a page object.

11

Screenshots per step, and the Ponytail review

The first run passed and the custom report showed the test, but with no screenshots and no video. The change asked of AI: put the actions into test.steps, and switch capture on in the config. Repetitive work, which is what AI is for now. The config, as pushed:

TypeScript
    /* Media for the custom report. 'on' captures for every test, pass or fail.
       For a real suite prefer 'only-on-failure' / 'retain-on-failure'. */
    screenshot: 'on',
    video: 'on',
    trace: 'on',

The repo's README calls this a teaching default: trace: 'on' writes about 1.3 MB per test, and a real suite should capture only on failure.

The AI's first attempt added helpers of its own to the spec, a stepper among them: AI slop, which is what a Ponytail review is for, and the review removed them. The screenshot logic then moved out of the spec into one shared helper beside the custom reporter, for every spec to use, leaving one shot() call per step:

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

/**
 * Attaches a screenshot that CustomReporter will pin to a specific test.step.
 *
 * The attachment name is the contract. CustomReporter.onTestEnd() matches
 * attachments to steps by a `step-<index>-` prefix with a 0-based index
 * (see "Associate screenshots with steps" in CustomReporter.ts). Any other
 * name still reaches the report, but as a loose test-level attachment rather
 * than a thumbnail beside its step.
 *
 * Usage:
 *   import { shot } from '../../utils/stepScreenshot';
 *
 *   await test.step('open the cart', async () => {
 *       await inventoryPage.openCart();
 *       await shot(page, 3, 'cart');
 *   });
 */
export async function shot(page: Page, index: number, name: string) {
    await test.info().attach(`step-${index}-${name}`, {
        body: await page.screenshot(),
        contentType: 'image/png',
    });
}

Playwright's own screenshot: 'on' takes one shot at the end of a test, so every step would share one image. shot() attaches a screenshot per step, named step-<index>-<name>, and the custom reporter matches that prefix to put each image beside its step. After the change, the report had its screenshots, and the video.

One line that looked like slop, and was not. The AI's spec opened with this, as shown in class:

TypeScript
// This app tags elements with data-test, not Playwright's default data-testid.
test.use({ testIdAttribute: 'data-test' });

It looked like more slop, so the question went back to the agent: do I need this? Rather than answer, it proved it: it removed the line and ran the test, which failed at once. The line stayed, flagged as a temporary hack to look at again. The reason it mattered: TTACart tags its elements with data-test, while getByTestId() looks for data-testid by default. Checked on TTACart: getByTestId('login-button') finds 0 elements by default and 1 with testIdAttribute: 'data-test', and on the cart page getByTestId('checkout') finds 0.

The pushed 293 has no test.use line. By the push, the fix had moved into the page objects: each builds its locators from [data-test="..."] itself, through the byTest helper, and the on-screen LoginPage's getByTestId chain became a single data-test locator. The commit gives the reason: the practice pages in earlier modules use data-testid, so a global setting would break them. The login page from spec 291 is one: there getByTestId("login-button") finds the button with the default setting, and a data-test locator finds nothing.

That exchange is the instructor's case for knowing the code first: AI will happily make a fool of you if you cannot judge what it wrote.

12

The limits of the page object model

Asked what POM's limits are, three came up, and the deck lists the same three:

  1. A weak locator breaks everything that uses it. Every spec that logs in goes through LoginPage's three locators. Make one of them weak and login breaks, and the inventory and cart steps behind it break with it. Specs 292, 293 and 294 all log in through it.
  2. Giant classes. The login page has three locators. Real pages can need a hundred, with methods to match, and that is where POM struggles: the class becomes a giant file. Do not create giant classes. Divide one into two or three parts, or, as a question from the room suggested, into components.
  3. Too much abstraction. A login page split into header, footer and body, the body into sections, and so on, until finding where something lives is the hard part.

The repo makes the second point concrete with HeavyPage, a page object with 100 locators and 112 methods, 685 lines in all. Its opening:

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

/**
 * A deliberately oversized page object: 100 locators in one constructor.
 *
 * It exists to make one point concrete. Locators are LAZY. Building one does
 * not touch the browser, query the DOM, or wait for anything. It only records
 * a selector. The DOM is queried when you act on it, with click(), fill(),
 * or an expect().
 *
 * So a constructor with 100 locators costs microseconds, and a page object
 * can describe an entire screen without the test paying for the parts it
 * never uses. Run 294_heavy_page.spec.ts to see the measured cost.
 *
 * It also carries 100 locator-backed methods and 10 page-level ones, to show
 * that methods live on the prototype: they are shared by every instance
 * and cost nothing per `new HeavyPage(page)`.
 */
export class HeavyPage {
  readonly page: Page;
  readonly buttonSubmit0: Locator;
  readonly linkCancel1: Locator;
  readonly textboxSave2: Locator;
  readonly checkboxDelete3: Locator;

Spec 294 measures what it costs:

TypeScript
import { test, expect } from '@playwright/test';
import { HeavyPage } from './pages/HeavyPage';
import { LoginPage } from './pages/LoginPage';

test('a 100-locator page object costs nothing to build', async ({ page }) => {
    const start = Date.now();
    const heavy = new HeavyPage(page);
    const buildMs = Date.now() - start;

    // Locators are per-instance: built in the constructor, one set per object.
    // Methods are on the prototype: declared once, shared by every instance.
    const methods = Object.getOwnPropertyNames(HeavyPage.prototype)
        .filter(m => m !== 'constructor');

    console.log(`built ${heavy.allLocators().length} locators in ${buildMs}ms`);
    console.log(`${methods.length} methods on the prototype, shared by all instances`);

    expect(heavy.allLocators()).toHaveLength(100);
    expect(methods.length).toBeGreaterThanOrEqual(110);
    expect(buildMs).toBeLessThan(100);

    // A second instance re-creates the 100 locators but adds no methods.
    const another = new HeavyPage(page);
    expect(Object.getOwnPropertyNames(HeavyPage.prototype).length)
        .toBe(methods.length + 1);   // +1 for constructor
    expect(another.allLocators()).toHaveLength(100);

    // Only acting on a locator reaches the DOM. The other 99 cost nothing.
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('standard_user', 'tta_secret');

    await expect(heavy.title).toHaveText('Products');
    expect(await heavy.isTitleVisible()).toBe(true);   // locator-backed method
    expect(heavy.getUrl()).toContain('inventory');     // page-level method
});
Text
Running 1 test using 1 worker

built 100 locators in 0ms
112 methods on the prototype, shared by all instances
  ✓  1 [chromium] › tests/20_Page_Object_Model/294_heavy_page.spec.ts:5:5 › a 100-locator page object costs nothing to build (1.3s)

  1 passed (1.6s)

Building the 100 locators took a millisecond or less. Locators are lazy: creating one only records a selector, and the browser is queried when you act on it, with click(), fill() or an expect(). The methods live on the class's prototype, declared once and shared by every instance. So the limit of a giant page object is not speed. It is the person who has to read and maintain 685 lines.

A BIG PAGE OBJECT IS CHEAP TO BUILD AND HARD TO READ new HeavyPage(page) PER INSTANCE 100 locators built in the constructor, each one only a selector ON THE PROTOTYPE 112 methods declared once, shared by all ONLY WHEN A TEST ACTS the browser is queried await expect(heavy.title) the other 99 cost nothing Size costs nothing at run time. What it costs is 685 lines for a person to read, which is why a giant page class gets split into parts or components.
From the repo's HeavyPage.ts and spec 294. A page object's size is a reading problem, not a speed problem.
13

Tasks and announcements

  • The push. The class's code went up at the end with the /go command, which updates the README and pushes: specs 291 to 294, the four page objects and the screenshot helper, in the tests/20_Page_Object_Model folder of the batch repo, documented as README section 38.
  • Recordings. The Playwright MCP versus CLI session from the SDET masterclass answers the question asked at the end; Meeti is attaching it, and the Playwright CLI session, to the batch. Window handling and calendar handling videos are being added to the misc section.
  • Doubts go in today's doubt thread.
  • Coming up: the advanced Playwright framework, starting with fixtures. Regex, in two parts, is coming soon.

Task, Snap Locator and Ponytail. Download the Snap Locator repository and make it your own copy (rename it if you like), install the extension with Developer mode on, and install Ponytail for your coding agent if you have not yet.