Day 16: Playwright Page Object Model, Fixtures, and Course Projects
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.*

Contents
What you will be able to do after Day 16
By the end of this post you can:
- Tell a spec that owns every locator from a spec that only names a flow.
- Read
270_WithOut_POM.spec.tsand point at every page detail that should not live in a second test. - Read
LoginPage.tsand say what the constructor stores, whatgoto()opens, and whatlogin()does. - Run
271_Login_With_POM.spec.tsand seenew LoginPage(page)— a class, not a fixture. - Admit that
tests/21_Fixtureis a skipped placeholder, and that customtest.extendis not implemented onmain. - Admit that
tests/22_Misc_Conceptsis a skipped placeholder, and that traces / UI mode / network are planned README topics, not coded labs. - Separate built-in fixtures (
page,browserfrom Day 10) from custom fixtures (folder 21, not written). - Walk TTA Bank
Task1.spec.ts: signup → transfer $5,000 → confirm → dashboard$45,000.00. Helpers, not page objects. - Open
Project_5_QA_Profileand say it is a README scaffold. There is noTask1.spec.tsin that folder. - Walk TTACart:
Loginpage→TtacartinventorypageTs→TtacartcheckoutpageTs→ URL asserts on cart and checkout-step-two. - Load
TTACartProject/.envthe way the spec does —dotenvinside the spec, not from the commented block inplaywright.config.ts. - Draw the Day 16 diagram from memory: spec vs
LoginPagevs 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 vsLoginPage270_WithOut_POM.spec.ts— locators, fills, click, URL assert, all inside the spec271_Login_With_POM.spec.ts—new LoginPage(page), Faker email/password, title assertLoginPage.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, teardown272_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 config273_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 scaffoldProject_4_TTA_BANK/README.md— signup, transfer $5,000, confirm, dashboard $45,000Project_4_TTA_BANK/Task1.spec.ts— the only project spec in this treeProject_5_QA_Profile/README.md— scaffold only. SuggestedTask1.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 nameLoginpagepages/TTACartInventoryPage.ts— class nameTtacartinventorypageTspages/TTACartCheckoutPage.ts— class nameTtacartcheckoutpageTstests/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
dotenvimport 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.
- The spec owns the page. Every
getByRole, every URL, every fill lives next to the assertion. Lab270is that answer. It is honest. It is also how suites rot. - A class owns the page. The spec news up
LoginPage, callsgoto()andlogin(), and asserts an outcome. Lab271andTTACartProject/pagesare that answer. This is Page Object Model as it exists onmain. - A fixture owns the setup. Playwright constructs the page object (or an authenticated context) and injects it into the test argument. Folder
21_Fixtureplans that answer. The spec that would teach it is skipped. I will not write the missingtest.extendin 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.tskeeps everything inside the spec: test data, navigation, locator definitions, form filling, button click, and the final URL assertion.271_Login_With_POM.spec.tsuses a page object. The spec createsnew LoginPage(page), callsloginPage.goto(), callsloginPage.login(username, password), and then asserts the page title.LoginPage.tswraps 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, thenlogin - 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:
- There is no
BasePage.LoginPagedoes not extend anything in this folder. Day 8’s JavaScript inheritance talk was the idea. Day 17’s advanced repo has theBasePage. Do not inventclass LoginPage extends BasePagein this tree. - Locators are constructed once.
readonlyfields, assigned in the constructor, from thepageyou passed in. They are lazy Playwright locators — they do not search the DOM atnewtime. Day 11 already taught that. expectis imported and unused. I do not delete it in this post. I do not add anexpectcall to “complete” the class. The class as served has no assertion. The spec asserts.login()is fill, fill, click. No wait for URL. No title check. No return of a next page object. Fluentreturn 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
testfixture 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 withexpectassertions in lesson specs.272_Fixture_Placeholder.spec.tsis intentionally skipped withtest.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.
| Layer | Who 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.extend | Placeholder 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.tssetstrace: '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:uiexists 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 taskProject_5_QA_Profile/— scaffold for a future QA Profile project
Run every project spec:
npx playwright test tests/Projects
That command discovers Project_4_TTA_BANK/Task1.spec.ts. It does not discover a QA Profile spec, because that file is not there.
Keep each project self-contained, the README says. Add specs, page objects, fixtures, or test data inside the matching project folder. TTA Bank did not add page objects. QA Profile did not add anything but a README.
Project 4 — TTA Bank is helpers, not POM
Project_4_TTA_BANK/README.md:
Task1.spec.tssigns up a new user, transfers$5,000, confirms the transfer, and verifies the dashboard balance is$45,000.00. The spec includes helper functions for filling the sign-up form, starting a transfer, confirming the transfer, and checking the dashboard balance and recent activity.
Hosted app, hardcoded in the spec:
const BASE_URL = 'https://tta-bank-digital-973242068062.us-west1.run.app/';
Two TypeScript interfaces. This is the light TS from Day 9, used for real data shapes:
interface SignUpData {
fullName: string;
email: string;
password: string;
}
interface TransferData {
amount: string; // numeric string, e.g. "5000"
note?: string;
fromAccountValue?: string; // e.g. "acc1" | "acc2"
beneficiaryValue?: string; // e.g. "b1" | "b2"
}
Four helpers. I am summarizing them from the file, then quoting the pieces that surprise people.
fillSignUpForm(page, data) — goto(BASE_URL), click the button named ' Sign Up' (leading space, as the accessible name is written), expect Create your digital account, fill placeholder John Doe, placeholder you@example.com, input[type="password"], click Create Account.
That leading space in ' Sign Up' is not a typo I added. If you “clean” it to 'Sign Up', the role locator can miss. Classroom rule: copy the name Playwright sees, not the name you wish the designer had shipped.
transferFunds(page, data) — click Transfer Funds, expect the heading, click Transfer Money, optionally selectOption on select first and select nth(1), fill input[type="number"][placeholder="0.00"], optional note placeholder e.g. Rent for October, click Continue.
The test call site does not pass fromAccountValue or beneficiaryValue. Those if branches exist for later tasks. Task1 only sends amount: '5000' and note: 'Test transfer'. I will not invent a selected beneficiary in this post.
confirmTransfer(page) — expect Confirm Transfer visible, click it.
verifyDashboardBalance(page, expectedBalance) — click sidebar Dashboard, expect heading Dashboard, then:
const totalBalanceCard = page.locator('text=Total Balance').locator('..');
await expect(totalBalanceCard).toContainText(expectedBalance);
await expect(page.getByText('Transfer to Sarah Smith')).toBeVisible();
await expect(page.getByText('-$5000.00')).toBeVisible();
The parent of the Total Balance text is the card. That locator('..') is an XPath-ish parent step via Playwright’s ... It is in the file. It is not a page object method.
The one test:
test.describe('TTA Bank - Sign Up, Transfer, and Verify', () => {
test('signs up, transfers $5,000, and verifies balance is $45,000', async ({ page }) => {
await fillSignUpForm(page, {
fullName: 'Jane Smith',
email: 'jane.smith@example.com',
password: 'StrongP@ssw0rd!',
});
await expect(page.getByRole('button', { name: 'Transfer Funds' })).toBeVisible();
await transferFunds(page, {
amount: '5000',
note: 'Test transfer',
});
await confirmTransfer(page);
await verifyDashboardBalance(page, '$45,000.00');
});
});
Starting balance is implied, not asserted before the transfer. $5,000 out, $45,000.00 left. The recent activity line is hardcoded to Transfer to Sarah Smith and -$5000.00. If the app changes Sarah’s label, this spec fails in the helper, not in a DashboardPage.
The file exports the helpers and the interfaces. That is unusual for a Playwright spec and useful for a classroom: another file *could* import them. No other file in tests/Projects does. I do not invent that importer.
Why this is not POM: the locators still live in functions in Task1.spec.ts. Changing Total Balance means editing the spec file. The functions are a good halfway house — the test body reads as a story — but the page has no class. After you finish TTACart, come back here and *you* can extract SignUpPage / TransferPage / DashboardPage. I will not extract them in this draft and drop them into a folder GitHub does not have.
Flake notes I will not hide:
- The Cloud Run URL can cold-start. First
gotomay be slower than a local app. - Signup uses a fixed email
jane.smith@example.com. If the app rejects a duplicate, the second run on a persistent backend fails. If the app is ephemeral, you are fine. I cannot see the server from this post. Run it twice and write down what happens. That observation is the lesson. - Demo password is in source. The project README says keep credentials out of the folder. The spec still has
StrongP@ssw0rd!. Classroom tension. I am not going to pretend the README won.
npx playwright test tests/Projects/Project_4_TTA_BANK/Task1.spec.ts
Project 5 — QA Profile is a README scaffold
I fetched tests/Projects/Project_5_QA_Profile/. GitHub lists one file: README.md.
The README says:
This folder is a scaffold for a future QA Profile Playwright project. Add project-specific specs here when the QA Profile app or task requirements are available. A typical first task could start as
Task1.spec.ts, with page objects or test data added locally if the flow grows.
Suggested structure in the README:
Project_5_QA_Profile/
README.md
Task1.spec.ts
pages/
fixtures/
Then the README says: Only add folders when they are needed. Keep the scaffold small until the first real test flow is defined.
So the suggested tree is a plan, not a listing. There is no Task1.spec.ts. There is no pages/. There is no fixtures/. If I write a QA Profile login test in this post, I am inventing a project. I will not.
When someone in the batch asks “where is project 5?” the answer is: it is a named folder and a paragraph. Use it as a blank canvas after you finish Bank and Cart. Do not wait for me to publish a spec that is not on main.
TTACartProject — three page objects, one E2E
This folder sits next to tests/, not inside it. Root tests/README.md says that out loud: the TTACart page-object example is outside the numbered lesson tree. playwright.config.ts still picks it up because testMatch includes TTACartProject/tests/**/*.spec.ts.
The project README:
pages— login, inventory/cart, checkouttests— the E2E spec.env— local credentials. Do not commit real credentials.
And then the README shows you how to create the env file. On main, the file already exists with demo keys. I fetched it. I will name the variable names and the fact that demo values are committed. I will not treat those demo values as a production secret, and I will not tell you to commit *your* password next to them.
npx playwright test TTACartProject/tests/ttacartE2E.spec.ts
App URL, from the login page object: https://app.thetestingacademy.com/playwright/ttacart/.
TTACartLoginPage.ts — class name Loginpage
export class Loginpage {
readonly page: Page;
readonly textboxUsername: Locator;
readonly textboxPassword: Locator;
readonly buttonLogin: Locator;
constructor(page: Page) {
this.page = page;
this.textboxUsername = page.getByRole("textbox", { name: "Username" }).or(page.getByTestId("username")).or(page.locator("#user-name"));
this.textboxPassword = page.getByRole("textbox", { name: "Password" }).or(page.getByTestId("password")).or(page.locator("#password"));
this.buttonLogin = page.getByRole("button", { name: "Login" }).or(page.getByTestId("login-button")).or(page.locator("#login-button"));
}
async goto() {
await this.page.goto("https://app.thetestingacademy.com/playwright/ttacart/");
}
async login(username: string, password: string) {
await this.textboxUsername.fill(username);
await this.textboxPassword.fill(password);
await this.buttonLogin.click();
}
}
Same shape as module 20’s LoginPage. Different class name: Loginpage. Different app: TTACart, SauceDemo-style. The locators are more resilient than module 20’s password field — role or test id or CSS id on every control.
This is the pattern I want you to steal: one .or() chain per control that the practice app has renamed more than once. Do not copy the chain into a second class. Do not put the chain back in the spec.
TTACartInventoryPage.ts — class name TtacartinventorypageTs
Classroom codegen leftover in the identifier. Keep it.
export class TtacartinventorypageTs {
readonly page: Page;
readonly addToCartTestAllthethings: Locator;
readonly addToCartTtaFleece: Locator;
readonly linkShoppingCart: Locator;
readonly checkout: Locator;
constructor(page: Page) {
this.page = page;
this.addToCartTestAllthethings = page.locator('[data-test="add-to-cart-test-allthethings-tshirt-red"]');
this.addToCartTtaFleece = page.locator('[data-test="add-to-cart-tta-fleece-jacket"]');
this.linkShoppingCart = page.locator('[data-test="shopping-cart-link"]');
this.checkout = page.getByText("Checkout");
}
// async goto() {
// await this.page.goto("https://app.thetestingacademy.com/playwright/ttacart/");
// }
async addToInventory() {
await this.addToCartTestAllthethings.click();
await this.addToCartTtaFleece.click();
await this.linkShoppingCart.click();
}
async checkoutCart() {
await this.checkout.click();
}
}
Two products, hardcoded: test-allthethings-tshirt-red and tta-fleece-jacket. addToInventory() adds both and opens the cart. It does not take a product name. If you want a reusable inventory page, you will change that signature later. Today the method is a script with a class around it.
goto() is commented out. The E2E never lands on inventory via this class. It lands via login. I will not uncomment the method in prose and call it live.
Checkout from this page is getByText("Checkout") — not a data-test chain. Inconsistent with the add-to-cart locators. On main as fetched.
TTACartCheckoutPage.ts — class name TtacartcheckoutpageTs
export class TtacartcheckoutpageTs {
// ...
async fillCheckoutPage() {
await this.textboxFirstName.fill("pramod")
await this.textboxLastName.fill("dutta");
await this.textboxZipPostalCode.fill("560012");
await this.buttonContinue.click();
}
}
Locators in the constructor are role or test id or CSS id, same resilience as login. The data is hardcoded inside the method: pramod, dutta, 560012. No argument list. Lab 270 put data in the spec. Lab 271 put data in the spec (Faker). TTA Bank put data in the spec and passed it into helpers. Cart checkout put data in the page object.
That is a POM smell I want the batch to see. A page object should know how to fill checkout. It should not know that I live in pincode 560012. When you extract this into the Day 17 framework, fillCheckoutPage(first, last, zip) or a CheckoutInfo type is the fix. I am not applying that fix in this fundamentals folder. I am pointing at the line.
Missing semicolon after "pramod" is as the file serves it. TypeScript’s ASI accepts it. I do not “correct” classroom punctuation in a quote.
ttacartE2E.spec.ts — dotenv in the spec, not in config
import { test, expect } from '@playwright/test';
import { Loginpage } from '../pages/TTACartLoginPage';
import { TtacartinventorypageTs } from '../pages/TTACartInventoryPage';
import { TtacartcheckoutpageTs } from '../pages/TTACartCheckoutPage';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
const ttacartUsername = getRequiredEnv('TTACART_USERNAME');
const ttacartPassword = getRequiredEnv('TTACART_PASSWORD');
test.describe('TTA Cart Autoamtion', () => {
test('Login with valid credns', async ({ page }) => {
const loginPage = new Loginpage(page);
await loginPage.goto();
await loginPage.login(ttacartUsername, ttacartPassword);
const ttacartinventorypageTs = new TtacartinventorypageTs(page);
await ttacartinventorypageTs.addToInventory();
await expect(page).toHaveURL('https://app.thetestingacademy.com/playwright/ttacart/cart');
await ttacartinventorypageTs.checkoutCart();
const ttacartCheckout = new TtacartcheckoutpageTs(page);
await ttacartCheckout.fillCheckoutPage();
await expect(page).toHaveURL('https://app.thetestingacademy.com/playwright/ttacart/checkout-step-two');
});
});
Describe title: TTA Cart Autoamtion. Test title: valid credns. Same classroom keyboard as 271.
The spec still does new three times. Still not a fixture. The diagram’s right-hand card is still dashed.
What the spec owns, correctly:
- env load and the required-env guard
- the story: login, add, assert cart URL, checkout, fill, assert step-two URL
- construction of the three classes
What the spec no longer owns:
- username/password field locators
- add-to-cart
data-testvalues - checkout field locators
- the hardcoded Bangalore pincode (that leaked into the page class)
Root playwright.config.ts has a commented dotenv block pointing at a repo-root .env. TTACart does not use that block. If you uncomment the config import and create a root .env with different keys, this spec still reads TTACartProject/.env via __dirname. Two dotenv stories. Only one is live for Cart.
getRequiredEnv throws before the test body if a key is missing. That fail is a Node throw at load time, not an expect. If you delete the .env file, the worker dies with Missing required environment variable: TTACART_USERNAME. That is the guard working.
The committed .env on main contains demo keys TTACART_USERNAME and TTACART_PASSWORD. The README still says do not commit real credentials. Treat the committed pair as classroom demo data for the practice cart, same way 270 commits admin@gmail.com. Rotate anything that is a real password. Do not copy a work password into this file and push it.
Asserts are full absolute URLs, not regex, not baseURL-relative paths. Config baseURL is commented. The spec and the page objects agree: hardcode the host.
There is no assertion on inventory URL after login. The first expect is the cart URL after addToInventory(). If login fails, you will likely fail on the add-to-cart click or on the cart URL, not on a dedicated “logged in” assert. Add that assert in your branch if you want a sharper failure. I will not add it to a file I am only allowed to quote.
Three implementations, one idea
Put the files next to each other. This is the Day 16 table I use on the whiteboard.
| 270 spec | 271 + LoginPage | TTA Bank helpers | TTACart pages | |
|---|---|---|---|---|
| Owner of locators | the spec | LoginPage class | functions in Task1.spec.ts | three page classes |
| How the spec starts | page.goto | new LoginPage(page) | fillSignUpForm(page, data) | new Loginpage(page) |
| Data | object in spec | Faker in spec | object in spec | env for login; hardcoded checkout in the page class |
| Assertion | URL /admin/ | title string | balance + activity text | cart URL + checkout-step-two URL |
| Fixture | built-in { page } | built-in { page } | built-in { page } | built-in { page } |
| Custom fixture | no | no | no | no |
BasePage | no | no | no | no |
None of these is the Day 17 framework. All of them are legal. The one I want you to outgrow first is 270, the moment a second spec needs the same login. The one I want you to outgrow second is Bank helpers, the moment a second spec needs the same dashboard. The one I want you to outgrow third is new Loginpage(page) in every test, the moment twenty tests need a logged-in inventory. That third outgrow is a fixture. Folder 21 has not written it. Day 17’s src/fixtures has.
What Day 16 is not
I am repeating this because this is the day people over-claim.
- Not a custom fixtures course.
272_Fixture_Placeholder.spec.tsisdescribe.skip. The README is a plan. - Not a traces / UI mode / network course.
273_Misc_Concepts_Placeholder.spec.tsistest.skip. Traces are already on in config. Teaching them is later. - Not the advanced framework.
tests/23_Advance_Frameworkis also a skip (274). Config, BasePage, reporters-as-a-layer, tagging, sharding — not implemented here. - Not a BasePage lesson. No file in today’s trees extends a base page.
- Not a QA Profile project.
Project_5_QA_Profileis a README. - Not a second TTACart in
tests/. Cart lives inTTACartProject/. - Not Day 8’s JavaScript
LoginPageagain. Day 8 was language. Today is Playwright Test plus a TypeScript class plus two apps.
If you need a one-line status for a manager: *POM comparison and two runnable projects are on main. Fixture and misc modules are reserved skipped folders.*
Homework — run what exists, do not invent the rest
Do this on main before you “improve” anything.
npx playwright test tests/20_Page_Object_Model --headed
Write down 270’s final URL and 271’s final title. They are different asserts. Say so in your notes.
- Break
LoginPagelogin button role name. Re-run 271. Confirm 270 still uses its own locator copy. Restore the file. Do not commit the break. npx playwright test tests/21_Fixture
Confirm skip. Open the README. Copy the planned topic list into your notebook under Not implemented.
npx playwright test tests/22_Misc_Concepts
Same. Skip. Planned list. Not implemented.
npx playwright test tests/Projects/Project_4_TTA_BANK/Task1.spec.ts --headed
Watch signup, $5,000, confirm, $45,000.00, Transfer to Sarah Smith. Run it a second time. Note whether the fixed email survives.
ls tests/Projects/Project_5_QA_Profile
You should see README.md only. If you see Task1.spec.ts, you are not on the main I fetched.
npx playwright test TTACartProject/tests/ttacartE2E.spec.ts --headed
Login from .env, two products, cart URL, checkout, step-two URL. Read fillCheckoutPage() and circle the hardcoded name.
- Draw the three-card diagram from the top of this post without looking. Spec. Class. Dashed fixture. If you cannot draw the dashed card, you will over-claim fixtures in an interview tomorrow.
Optional, after the runs: extract Bank into page classes in your own branch. Do not ask me to pretend those classes are in Project_4_TTA_BANK today. They are not.
Common mistakes I see in this week of the batch
Calling new LoginPage(page) a fixture. It is a constructor call. A fixture is an argument Playwright provides because you extended test. 271 has the constructor. 272 would have been the argument. 272 is skipped.
Copying LoginPage locators back into a new spec “just for this one test.” That is how you undo Day 16 in an afternoon. If the flow is the practice-account login, call login(). If the flow is different, the page object needs a new method, not a fork of the locator list.
Treating TTA Bank as POM because the test body is clean. Clean helpers are not classes. The locators are still in Task1.spec.ts. Interviewers will ask you to point at the page object file. There isn’t one in that folder.
Uncommenting Cart inventory goto() and then wondering why login vanished. The comment is there because login already opened the app. A second goto to the same host can drop you on login if the session is URL-gated. Leave it commented until you have a reason.
Putting a work password in TTACartProject/.env and pushing. The README told you not to. The committed demo keys are for the practice cart. Your bank password is not.
Opening folder 21, seeing green, marking fixtures complete. Green skip is not complete. Read the describe title: Custom fixtures placeholder.
Inventing BasePage in a fundamentals PR. Wait for Day 17. The advanced repo has the base. This repo’s LoginPage is standalone on purpose.
Expecting npx playwright test to skip Cart. It will not. testMatch includes TTACartProject/tests/**/*.spec.ts. Cart is in the default run. If .env is missing on a fresh clone that someone stripped, the worker throws at import. On the public main I fetched, .env is present.
Using 271’s Faker login as proof the practice account accepts any email. The assert is a title, not a dashboard URL. Do not cite 271 as a successful authenticated session unless you watched the URL after click.
FAQ
Is Playwright Page Object Model the same as a Playwright fixture?
No. A page object is a class you construct, like LoginPage in tests/20_Page_Object_Model/LoginPage.ts or Loginpage in TTACartProject/pages/TTACartLoginPage.ts. A fixture is a value Playwright injects into the test function after setup and before teardown. { page } is a built-in fixture (Day 10). A custom loginPage fixture would come from test.extend. That custom lab is 272_Fixture_Placeholder.spec.ts, and it is skipped.
Where is the custom fixture example in LearningPlaywrightFundamentals?
It is not implemented on main. tests/21_Fixture/README.md lists planned topics: test.extend, POM fixtures, worker-scoped auth, teardown. 272_Fixture_Placeholder.spec.ts is test.describe.skip. Do not treat the README list as code.
What files are in tests/20_Page_Object_Model?
Four: README.md, 270_WithOut_POM.spec.ts, 271_Login_With_POM.spec.ts, LoginPage.ts. There is no BasePage.ts. There is no fixture file. I fetched the GitHub tree. I will not invent a fifth file.
Why does lab 270 assert a URL and lab 271 assert a title?
Because the files do. 270 uses fixed admin@gmail.com / admin123 and expect(page).toHaveURL(/admin/). 271 uses faker.internet.email() / faker.internet.password() and expect(page).toHaveTitle('Multiple Element Filter Login — The Testing Academy'). Same page class target, different outcomes. I do not merge them.
Why is the 270 describe called DDT Simple?
Classroom leftover. Day 15 was data-driven testing. 270 has one data object and a comment Only 1 Daya. It is not a DDT lesson. The module README calls it the non-POM login.
Does TTA Bank use page objects?
No. tests/Projects/Project_4_TTA_BANK/Task1.spec.ts uses helper functions: fillSignUpForm, transferFunds, confirmTransfer, verifyDashboardBalance. Interfaces SignUpData and TransferData live in the same file. There is no pages/ directory under Project 4.
Is Project 5 QA Profile implemented?
No. tests/Projects/Project_5_QA_Profile/README.md is a scaffold. It suggests Task1.spec.ts, pages/, and fixtures/ as a future layout and then says only add folders when needed. GitHub lists the README only.
Where does TTACart live, and how do I run it?
Outside tests/, in TTACartProject/. From the repo root: npx playwright test TTACartProject/tests/ttacartE2E.spec.ts. Root playwright.config.ts sets testDir: './' and testMatch to tests/**/*.spec.ts plus TTACartProject/tests/**/*.spec.ts.
Why are the TTACart class names Loginpage, TtacartinventorypageTs, and TtacartcheckoutpageTs?
That is how the files are spelled on main. I do not rename them in this series. Import the identifiers as GitHub serves them.
Does playwright.config.ts load TTACartProject/.env?
No. The dotenv import at the top of playwright.config.ts is commented out. ttacartE2E.spec.ts calls dotenv.config({ path: path.resolve(__dirname, '../.env') }) and then getRequiredEnv('TTACART_USERNAME') / getRequiredEnv('TTACART_PASSWORD').
Are traces part of Day 16?
Not as a lab. tests/22_Misc_Concepts plans traces, video, screenshots, and UI mode, but 273_Misc_Concepts_Placeholder.spec.ts is test.skip. Tracing is already 'on' in playwright.config.ts from Day 10. Day 20 is the CLI / trace / UI / CI day.
What is Day 17 of this series?
Advanced framework layers: config, pages, fixtures, reporters. Not folder 23 in this fundamentals repo — that folder is another skipped placeholder (274_Advanced_Framework_Placeholder.spec.ts). Day 17 opens AdvancePlaywrightFramework1x on feat-cucumber, where src/config, src/pages (with BasePage), src/fixtures, and reporters actually exist.
<script type=”application/ld+json”> { “@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [ { “@type”: “Question”, “name”: “Is Playwright Page Object Model the same as a Playwright fixture?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. A page object is a class you construct, such as LoginPage in tests/20_Page_Object_Model/LoginPage.ts. A fixture is a value Playwright injects into the test after setup. { page } is a built-in fixture. A custom loginPage fixture would come from test.extend. That lab is 272_Fixture_Placeholder.spec.ts and is skipped on main.” } }, { “@type”: “Question”, “name”: “Where is the custom Playwright fixture example in LearningPlaywrightFundamentals?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “It is not implemented. tests/21_Fixture/README.md lists planned test.extend, POM fixtures, worker-scoped auth, and teardown. 272_Fixture_Placeholder.spec.ts uses test.describe.skip. Do not treat the README plan as a coded lab.” } }, { “@type”: “Question”, “name”: “What files are in tests/20_Page_Object_Model?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Four files on main: README.md, 270_WithOut_POM.spec.ts, 271_Login_With_POM.spec.ts, and LoginPage.ts. There is no BasePage.ts and no fixture file in that folder.” } }, { “@type”: “Question”, “name”: “Why does Playwright lab 270 assert a URL and lab 271 assert a title?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “270_WithOut_POM.spec.ts uses admin@gmail.com and admin123 and expects toHaveURL(/admin/). 271_Login_With_POM.spec.ts uses Faker email and password and expects title Multiple Element Filter Login — The Testing Academy. Same practice login page, different asserts in the files.” } }, { “@type”: “Question”, “name”: “Does the TTA Bank Playwright project use page objects?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. tests/Projects/Project_4_TTA_BANK/Task1.spec.ts uses helper functions fillSignUpForm, transferFunds, confirmTransfer, and verifyDashboardBalance plus SignUpData and TransferData interfaces in the same spec. There is no pages directory in Project 4.” } }, { “@type”: “Question”, “name”: “Is Project 5 QA Profile implemented in LearningPlaywrightFundamentals?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. tests/Projects/Project_5_QA_Profile/README.md is a scaffold that suggests Task1.spec.ts, pages/, and fixtures/ later. GitHub lists only the README.” } }, { “@type”: “Question”, “name”: “How do I run the TTACart Playwright page-object project?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “From the LearningPlaywrightFundamentals root: npx playwright test TTACartProject/tests/ttacartE2E.spec.ts. The folder sits outside tests/. playwright.config.ts testMatch includes TTACartProject/tests/**/*.spec.ts. The spec loads TTACartProject/.env via dotenv.” } }, { “@type”: “Question”, “name”: “Why are TTACart page class names Loginpage and TtacartinventorypageTs?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Those are the exported class names on main in TTACartLoginPage.ts, TTACartInventoryPage.ts, and TTACartCheckoutPage.ts (TtacartcheckoutpageTs). The series quotes classroom identifiers as GitHub serves them.” } }, { “@type”: “Question”, “name”: “Does playwright.config.ts load TTACartProject/.env?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. The dotenv import in playwright.config.ts is commented out. ttacartE2E.spec.ts calls dotenv.config with path.resolve(__dirname, ‘../.env’) and getRequiredEnv for TTACART_USERNAME and TTACART_PASSWORD.” } }, { “@type”: “Question”, “name”: “What is Day 17 of the JS to Playwright Framework series?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Day 17 covers advanced framework layers — config, pages, fixtures, reporters — from AdvancePlaywrightFramework1x on feat-cucumber. tests/23_Advance_Framework in LearningPlaywrightFundamentals is only 274_Advanced_Framework_Placeholder.spec.ts, a skipped placeholder, not that framework.” } } ] } </script>
Tomorrow — Day 17: advanced framework layers
A page object is a class. A fixture is a lifecycle. A framework is layers: config that owns env and baseURL, pages that share a BasePage, fixtures that inject those pages, reporters that write a human HTML file, tests that only name the story.
tests/23_Advance_Framework in today’s repo is not that stack. I fetched it so I would not tease a file I had not seen. On main it is README.md plus 274_Advanced_Framework_Placeholder.spec.ts wrapped in test.describe.skip('Advanced framework placeholder'). Planned bullets there: folder structure, shared fixtures, globalSetup, storage-state login, env config, tagging, sharding. Those bullets are a reservation, the same kind as 272 and 273.
Day 17 of this series leaves LearningPlaywrightFundamentals for the working tree: AdvancePlaywrightFramework1x on branch feat-cucumber. That is where src/config, src/pages (BasePage plus the TTACart pages), src/fixtures (test.extend), and the custom reporter actually live. I will quote those files tomorrow. I will not invent a BasePage in today’s fundamentals folder. I will not pretend lab 274 is complete.
If you only remember one sentence from Day 16: new LoginPage(page) is POM. { loginPage } would be a fixture. Only the first is on main.
Series hub (bookmark this): JavaScript → TypeScript → Playwright Advanced Framework — 21-Day Guide.
Master Playwright end to end
If you want these labs as a live classroom — LoginPage vs the raw spec, TTA Bank, TTACart, and the fixture-and-framework layers we assemble on Day 17 through Day 21 — join Playwright Automation Mastery at The Testing Academy. Lifetime access. Real projects. A job-ready suite, not a folder of skipped placeholders you marked complete because the skip was green.
*This is Day 16 of 21. Draft only. Not published.*
