|

Day 16: Playwright Page Object Model, Fixtures, and Course Projects

Compact diagram of LoginPage injected into a test via a Playwright fixture

This is Day 16 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright Test now. Framework later.

Days 1–7 were the language. Day 8 was the object model — a LoginPage idea in plain JavaScript. Day 9 typed that object. Days 10–15 opened the browser, found fields, saved a session, crossed frames, pierced shadow, asserted, hooked, and looped CSV. Today the spec stops owning the page.

I am Pramod Dutta. I teach SDETs in India for a living. The week I open Page Object Model, someone always copies every getByRole into the next three specs. The login button text changes. Three files fail. That is not a locator problem. That is a ownership problem. The page details have no single owner.

This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts later in the stack. This series started at console.log. Day 16 is the first day the page gets a class, and the first day I have to tell you a folder is a placeholder.

All labs come from my public fundamentals repo: LearningPlaywrightFundamentals on branch main. I fetched five trees from GitHub: tests/20_Page_Object_Model, tests/21_Fixture, tests/22_Misc_Concepts, tests/Projects, and TTACartProject. I quote those files. I will not invent a file that is not there.

Two of those trees are not complete labs. tests/21_Fixture is a README plus 272_Fixture_Placeholder.spec.ts wrapped in test.describe.skip. tests/22_Misc_Concepts is a README plus 273_Misc_Concepts_Placeholder.spec.ts wrapped in test.skip. I will say that in every section that touches them. I will not write a fake test.extend and pretend it lives in this repo.

Classroom spellings stay. Lab 270 says Only 1 Daya in a comment and titles the describe DDT Simple. Lab 271 says valid credns. The cart describe is TTA Cart Autoamtion. The cart login class is Loginpage, not LoginPage. The inventory class is TtacartinventorypageTs. The checkout class is TtacartcheckoutpageTs. I do not rename files or identifiers to make this post prettier.

If you want the video plus project path after you finish these 21 posts, the course is here: Playwright Automation Mastery. The series hub for every day lives here: JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.

*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Compact diagram of LoginPage injected into a test via a Playwright fixture

Contents

What you will be able to do after Day 16

By the end of this post you can:

  1. Tell a spec that owns every locator from a spec that only names a flow.
  2. Read 270_WithOut_POM.spec.ts and point at every page detail that should not live in a second test.
  3. Read LoginPage.ts and say what the constructor stores, what goto() opens, and what login() does.
  4. Run 271_Login_With_POM.spec.ts and see new LoginPage(page) — a class, not a fixture.
  5. Admit that tests/21_Fixture is a skipped placeholder, and that custom test.extend is not implemented on main.
  6. Admit that tests/22_Misc_Concepts is a skipped placeholder, and that traces / UI mode / network are planned README topics, not coded labs.
  7. Separate built-in fixtures (page, browser from Day 10) from custom fixtures (folder 21, not written).
  8. Walk TTA Bank Task1.spec.ts: signup → transfer $5,000 → confirm → dashboard $45,000.00. Helpers, not page objects.
  9. Open Project_5_QA_Profile and say it is a README scaffold. There is no Task1.spec.ts in that folder.
  10. Walk TTACart: Loginpage → TtacartinventorypageTs → TtacartcheckoutpageTs → URL asserts on cart and checkout-step-two.
  11. Load TTACartProject/.env the way the spec does — dotenv inside the spec, not from the commented block in playwright.config.ts.
  12. Draw the Day 16 diagram from memory: spec vs LoginPage vs fixture. Only the first two exist as working code.

That is the skill. Not the selector. The skill is who owns the page — the spec, a class, or a fixture Playwright injects. Today only the first two are on disk.

The labs we are actually using

Clone the fundamentals repo and stay on main:

git clone https://github.com/PramodDutta/LearningPlaywrightFundamentals.git
cd LearningPlaywrightFundamentals
git checkout main

Five trees. I fetched every file GitHub lists in them. Those files, as GitHub serves them:

tests/20_Page_Object_Model — this is the real POM lesson. Four files. No BasePage. No fixture.

  • README.md — same login flow two ways; non-POM vs LoginPage
  • 270_WithOut_POM.spec.ts — locators, fills, click, URL assert, all inside the spec
  • 271_Login_With_POM.spec.ts — new LoginPage(page), Faker email/password, title assert
  • LoginPage.ts — constructor locators, goto(), login(username, password)

tests/21_Fixture — placeholder. Say so. Two files. No test.extend.

  • README.md — planned: custom test data, POM fixtures, worker-scoped auth, teardown
  • 272_Fixture_Placeholder.spec.ts — test.describe.skip('Custom fixtures placeholder', ...)

tests/22_Misc_Concepts — placeholder. Say so. Two files. No traces lab. No network lab.

  • README.md — planned: metadata, timeouts/retries, traces/video/UI mode, artifacts, network/API, env config
  • 273_Misc_Concepts_Placeholder.spec.ts — test.skip('miscellaneous Playwright concepts placeholder', ...)

tests/Projects — practice projects, separate from numbered modules.

  • README.md — index: TTA Bank is the E2E; QA Profile is a scaffold
  • Project_4_TTA_BANK/README.md — signup, transfer $5,000, confirm, dashboard $45,000
  • Project_4_TTA_BANK/Task1.spec.ts — the only project spec in this tree
  • Project_5_QA_Profile/README.md — scaffold only. Suggested Task1.spec.ts / pages/ / fixtures/ are not on disk

TTACartProject/ — outside tests/. The config testMatch includes it.

  • README.md — login → cart → checkout via page objects
  • .env — TTACART_USERNAME / TTACART_PASSWORD (demo values are committed; do not put real passwords here)
  • pages/TTACartLoginPage.ts — class name Loginpage
  • pages/TTACartInventoryPage.ts — class name TtacartinventorypageTs
  • pages/TTACartCheckoutPage.ts — class name TtacartcheckoutpageTs
  • tests/ttacartE2E.spec.ts — dotenv + required-env guard + one E2E

I am not opening tests/23_Advance_Framework as a finished lab. That folder is also a skipped placeholder (274_Advanced_Framework_Placeholder.spec.ts). It is tomorrow’s teaser, not today’s curriculum. The real framework layers live in a different repo. I will name that at the end. I will not quote a src/pages/BasePage.ts that this fundamentals tree does not have.

Root playwright.config.ts on main is part of the story:

  • testDir: './'
  • testMatch: ['tests/**/*.spec.ts', 'TTACartProject/tests/**/*.spec.ts']
  • trace / video / screenshot: 'on'
  • headless: false
  • viewport 1920×1080
  • HTML reporter plus ./utils/CustomTTAReporter.ts
  • the Allure reporter line is still commented
  • the root dotenv import is still commented

TTACart does not get its .env from that commented config block. The spec loads dotenv itself.

Run commands from the READMEs, as they are written:

npx playwright test tests/20_Page_Object_Model
npx playwright test tests/21_Fixture
npx playwright test tests/22_Misc_Concepts
npx playwright test tests/Projects
npx playwright test tests/Projects/Project_4_TTA_BANK/Task1.spec.ts
npx playwright test TTACartProject/tests/ttacartE2E.spec.ts

Folder 21 and folder 22 will collect and skip. That is the designed behavior. The 21 README says the placeholder exists so the folder can sit in the suite without a failing or incomplete test. The 22 README says the same. If you run those two folders and see a green skip, you have not “done fixtures.” You have confirmed the placeholder is still a placeholder.

Public demo sites need network. app.thetestingacademy.com and the Cloud Run TTA Bank URL are live hosts in these files. If a host is down, the spec fails. That is a classroom fact, not a Playwright bug.

Why Day 16 is the next framework decision

Day 8 asked what an object is. Day 9 asked how TypeScript types that object. Day 10 gave you { page }. Day 11 asked which locator. Day 12 asked whether you must log in again. Day 15 asked which row of data. Day 16 asks who owns the page.

Three answers. Only two are implemented in this repo.

  1. The spec owns the page. Every getByRole, every URL, every fill lives next to the assertion. Lab 270 is that answer. It is honest. It is also how suites rot.
  2. A class owns the page. The spec news up LoginPage, calls goto() and login(), and asserts an outcome. Lab 271 and TTACartProject/pages are that answer. This is Page Object Model as it exists on main.
  3. A fixture owns the setup. Playwright constructs the page object (or an authenticated context) and injects it into the test argument. Folder 21_Fixture plans that answer. The spec that would teach it is skipped. I will not write the missing test.extend in this draft and call it a lab.

A fourth answer sits in TTA Bank: helper functions in the same file. fillSignUpForm, transferFunds, confirmTransfer, verifyDashboardBalance. Not a class. Not a fixture. A step between 270 and 271. I will not relabel those helpers as page objects. They are functions that take page.

If you came here hoping Day 16 is “the framework day,” stop. Day 16 is the ownership day. The framework layers — config, BasePage, typed fixtures, reporters, env — are Day 17, and they are not in this repo as working labs. Folder 23_Advance_Framework is a skip. The working layers are in AdvancePlaywrightFramework1x on feat-cucumber. Tomorrow we open that tree. Today we stay honest about this one.

Module 20 — the same login, two owners

The module README is the lesson in one paragraph. I am quoting the idea, not inventing a third spec:

  • 270_WithOut_POM.spec.ts keeps everything inside the spec: test data, navigation, locator definitions, form filling, button click, and the final URL assertion.
  • 271_Login_With_POM.spec.ts uses a page object. The spec creates new LoginPage(page), calls loginPage.goto(), calls loginPage.login(username, password), and then asserts the page title.
  • LoginPage.ts wraps the login page details: constructor locators, goto(), login().

The point of the comparison is not “POM is always better.” The point is where the page details live when the button text changes tomorrow.

Lab 270 — 270_WithOut_POM.spec.ts owns every locator

Open the file. The describe title is leftover classroom text: DDT Simple. This is not a data-driven module. Day 15 was data-driven. This file has one object named data. The comment above it is Only 1 Daya — classroom spelling, as GitHub serves it.

test.describe('DDT Simple', () => {

    // Only 1 Daya
    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');
        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"));
        await textboxEmailAddress.fill(data.username);
        await textboxPassword.fill(data.password);
        await buttonLoginToPracticeAccount.click();
        await expect(page).toHaveURL(data.expectedURL);
    });
});

Read that as an SDET, not as a fan of clean architecture.

The spec knows the URL. The spec knows three locators. The spec knows the .or() fallbacks. The spec knows the credential object. The spec knows the assertion is a URL matching /admin/. If I add a second login test in another file, I will copy those three locator lines. When the button accessible name changes from Login to Practice Account to Sign in, I will hunt strings.

That is why we write page objects. Not because a class is fashionable. Because a button name should have one home.

Notice the password locator in 270 is wider than the one in LoginPage.ts. Here it is role or #password or [name="password"]. In LoginPage.ts the password field is only getByRole('textbox', { name: 'Password' }). I will not “fix” that in this post. I will not pretend the two files are identical wrappers. They are the same flow with different locator resilience. If the role name breaks and the id survives, 270 still finds the field. LoginPage does not. That is a classroom inconsistency. It is on main. I leave it.

shouldPass: true is unused. The test never branches on it. Day 15’s CSV lab did. This object kept the shape. I do not invent a fail-path test that is not in the file.

The built-in fixture { page } is already here. Day 10 taught that. Folder 21 is not required to use page. Custom fixtures are the missing layer, not the page argument.

Lab 271 — 271_Login_With_POM.spec.ts names the flow

Four imports. One class. One test.

import { test, expect } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { faker } from '@faker-js/faker';


test.describe('POM with Login Page Simple', () => {



    test('Login with valid credns', async ({ page }) => {
        const loginPage = new LoginPage(page);
        await loginPage.goto();
        await loginPage.login(faker.internet.email(), faker.internet.password());
        await expect(page).toHaveTitle('Multiple Element Filter Login — The Testing Academy');

    });
});

Classroom title: valid credns. Keep it.

What the spec no longer contains:

  • the practice-account URL
  • the email role name
  • the password locator
  • the login button .or() chain

What the spec still contains:

  • construction: new LoginPage(page)
  • the flow: goto, then login
  • the assertion: title Multiple Element Filter Login — The Testing Academy
  • the data: Faker email and Faker password, generated at runtime

That last point matters. Lab 270 uses a fixed admin@gmail.com / admin123 and asserts URL /admin/. Lab 271 uses random credentials and asserts title. They are not two encodings of one identical test. They are two encodings of one page. The outcomes are different on purpose or by classroom drift. I will not merge them in prose and say “same assert, cleaner spec.” The asserts are not the same.

If the Faker user is not a valid practice account, 271 can still pass if the title is already the login-page title before or after the click. I am not going to invent a dashboard assert that the file does not write. I am telling you what the file asserts: toHaveTitle('Multiple Element Filter Login — The Testing Academy'). When you run it headed, watch the URL bar. Compare it with 270’s /admin/ expect. That comparison is the homework, not a sentence I will fake.

{ page } is still a built-in fixture. The spec still receives Playwright’s page and hands it to the constructor. That is the line I want in the diagram: new LoginPage(page). A custom fixture would hide that new. Folder 21 has not written that hide.

LoginPage.ts — the class, not a fixture

This is the entire page object on disk for module 20. I am quoting the file, unused import included.

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: "Email Address" });
        this.passwordInput = page.getByRole('textbox', { name: 'Password' });
        this.loginButton = page
            .getByRole('button', { name: 'Login to Practice Account' })
            .or(page.getByTestId('login-button'))
            .or(page.getByText('Login to Practice Account'));

    }


    // Page Actions

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

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

}

Four facts I make every batch write down:

  1. There is no BasePage. LoginPage does not extend anything in this folder. Day 8’s JavaScript inheritance talk was the idea. Day 17’s advanced repo has the BasePage. Do not invent class LoginPage extends BasePage in this tree.
  2. Locators are constructed once. readonly fields, assigned in the constructor, from the page you passed in. They are lazy Playwright locators — they do not search the DOM at new time. Day 11 already taught that.
  3. expect is imported and unused. I do not delete it in this post. I do not add an expect call to “complete” the class. The class as served has no assertion. The spec asserts.
  4. login() is fill, fill, click. No wait for URL. No title check. No return of a next page object. Fluent return new InventoryPage(this.page) is a later style. It is not in this file.

The login button uses .or(). Role name, then getByTestId('login-button'), then visible text. That is resilience, not decoration. The practice app has shipped more than one login control. The chain is the classroom memory of that.

goto() hardcodes the URL. There is no baseURL in playwright.config.ts (the line is commented). There is no env var for this module. The page object is the config for this URL. That is acceptable for a lesson class. It is not acceptable as the final framework. Day 17 moves URLs into env-backed config. I am not going to pretend LoginPage.goto() is that config layer.

Run the pair headed:

npx playwright test tests/20_Page_Object_Model --headed

Watch 270 land on a URL that should match /admin/. Watch 271 type a Faker email you will never see again. Then open LoginPage.ts and change only the button role name to something wrong. Run 271 again. It should fail in one place. That is the whole sales pitch for POM. One home for one control.

Module 21 — custom fixtures are a placeholder. Say so.

I fetched tests/21_Fixture/ from GitHub. This is the complete folder:

README.md

This lesson folder is reserved for custom Playwright fixture examples. Planned topics: – Extending the base test fixture with custom test data. – Creating reusable page object fixtures. – Sharing authenticated setup through worker-scoped fixtures. – Using fixture teardown for cleanup after a test. – Combining custom fixtures with expect assertions in lesson specs. 272_Fixture_Placeholder.spec.ts is intentionally skipped with test.describe.skip. It lets the lesson folder exist in the suite without adding a failing or incomplete test.

272_Fixture_Placeholder.spec.ts — the entire spec:

import { test } from '@playwright/test';

test.describe.skip('Custom fixtures placeholder', () => {
    test('will cover custom fixture setup patterns', async () => {
        // Placeholder for upcoming custom fixture lessons.
    });
});

That is not a custom fixture lab. That is a reserved parking space.

I will not:

  • write a test.extend({ loginPage }) block and call it lab 272
  • show worker-scoped auth as if it lives under tests/21_Fixture
  • tell you to “open the fixture file” that does not exist
  • treat the README bullet list as implemented curriculum

If an article, a slide, or an AI summary says “Day 16 covers Playwright fixtures with test.extend,” it is wrong for this repo on main. Day 16 names fixtures, separates them from page objects, and shows you the skip.

Built-in fixtures you already used — not folder 21

Folder 21 being empty of real code does not mean you have never seen a fixture.

Day 10, tests/02_first_tests, labs 211 and 215–218: { page }, { browser }, test.use(). That is Playwright’s built-in fixture system. The runner constructs a browser, a context, a page, and tears them down. You did not write test.extend to get { page } in 270 and 271. You destructured it.

A custom fixture is when *you* add a new argument:

// NOT in this repo. Do not treat this as lab 272.
// Shape only, so the vocabulary is not a mystery tomorrow.
// const test = base.extend<{ loginPage: LoginPage }>({
//   loginPage: async ({ page }, use) => {
//     await use(new LoginPage(page));
//   },
// });

I am wrapping that in comments on purpose. It is the shape the 21 README is pointing at. It is not a file I fetched. Day 17’s advanced repo is where a real src/fixtures tree exists. If I paste a working extend into this fundamentals post, I am inventing a lab. I refuse.

What the skip buys the suite: npx playwright test tests/21_Fixture does not fail CI. forbidOnly does not care about skip. The folder number stays aligned with the live classroom (lab 272). When the lesson is recorded, this file gets replaced. Until then, a green skip is the honest status.

Spec vs class vs fixture — say it out loud

Write this on a sticky note. It is the diagram at the top of the post.

LayerWho constructs the page object?In this repo today
Spec (270)Nobody. There is no page object.Implemented
Class (271, TTACart)The spec: new LoginPage(page)Implemented
Fixture (272 planned)The runner: your test.extendPlaceholder skip

A page object is a class. A fixture is a lifecycle. Students mash the two words because both hide setup. They are not the same hide. new LoginPage(page) still sits in 271. A fixture would remove that line from the spec and put loginPage in the argument list next to page. That removal is the whole point of folder 21. The folder has not done it.

If you already know test.extend from docs.playwright.dev, good. Use it in your own branch. Do not send me a PR comment that says “Day 16 forgot the fixture lab.” Day 16 did not forget it. Day 16 fetched it and found a skip.

Module 22 — miscellaneous concepts are a placeholder. Say so.

I fetched tests/22_Misc_Concepts/. Complete folder:

README.md planned topics:

  • Test metadata and annotations for organizing lesson coverage
  • Timeouts, retries, and worker behavior in local and CI runs
  • Debugging helpers such as traces, videos, screenshots, and Playwright UI mode
  • Test artifacts and report review workflows
  • Network inspection, request handling, and lightweight API checks
  • Environment configuration patterns for lesson-specific data

Key file named in that README: 273_Misc_Concepts_Placeholder.spec.ts.

The spec:

import { test } from '@playwright/test';

test.skip('miscellaneous Playwright concepts placeholder', async () => {
    // Planned lessons for this module will replace this skipped placeholder.
});

There is no traces exercise here. There is no page.route lab. There is no retry lab. There is no UI-mode walkthrough file.

Some of those topics already leaked into earlier days as config, not as a numbered lesson:

  • Day 10: playwright.config.ts sets trace: 'on', video: 'on', screenshot: 'on'. You have been recording artifacts since the first Playwright day. Folder 22 was supposed to *teach* how to open them. It has not.
  • Day 10: test:ui exists as a package script. There is no lab 273 that opens UI mode.
  • Day 15: annotations and hooks. Folder 22’s “metadata and annotations” bullet would overlap 18_Test_hooks. It is still unwritten.
  • Root config: retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, forbidOnly: !!process.env.CI. Timeout/retry/worker behavior is in the config file. It is not a 273 spec.

I will not spend 800 words teaching traces from memory and labeling it “module 22.” Day 20 of this series is Playwright CLI, codegen, trace, UI mode, and CI. That day can open the config and the workflow file. Today I am only allowed to say: folder 22 is a reserved name.

Run it if you want the skip on record:

npx playwright test tests/22_Misc_Concepts

The README says the placeholder should not fail the suite. If it fails, your Playwright version is not honoring test.skip the way this file expects, or you edited the file. On main as fetched, it skips.

tests/Projects — two folders, one spec

The projects README is small and honest:

  • Project_4_TTA_BANK/ — end-to-end banking task
  • Project_5_