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-testattribute belongs - The limits of POM: weak locators, giant classes and too much abstraction
- Tasks and announcements
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.
LoginPage and keeps only the assertion.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.
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 |
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:
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);
});
});
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".
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:
// 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:
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
Locatorbelongs to onepage, and the page does not exist until a test hands it over. Playwright gives every test a freshpage, so the constructor is the first moment there is one. - What
readonlydoes. Areadonlyfield 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:
// 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:
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.
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:
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:
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:
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:
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");
});
});
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:
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
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:
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();
}
}
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.
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:
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.
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.
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.
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:
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:
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.
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:
/* 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:
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:
// 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.
The limits of the page object model
Asked what POM's limits are, three came up, and the deck lists the same three:
- 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. - 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.
- 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:
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:
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
});
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.
HeavyPage.ts and spec 294. A page object's size is a reading problem, not a speed problem.Tasks and announcements
- The push. The class's code went up at the end with the
/gocommand, 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.