The Testing Academy · Class Notes Wednesday, 26 August (IST)
Live class · study guide

Fixtures: handing every test a page that is already logged in

The session the last four classes were building towards. One file, test-base.ts, extends Playwright's own test object so every spec is handed page objects that are already constructed and a session that is already logged in. Also: what use() actually does, keeping credentials out of the repository, a visualStep wrapper that puts a screenshot on every step, and the trace viewer.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright 2x class, rebuilt from the session recording and cross-checked against the batch Eraser board. The fixture mechanics were executed against Playwright 1.62.1 before publishing rather than taken from the recording, including the ordering around use() and one performance claim that did not survive testing. Where the class repository does not yet contain the code shown, that is said plainly.

01

A fixture is setup and teardown you inject

A fixture provides setup and teardown: the things that happen before a test runs and after it finishes.

That sounds like a hook, and the difference is the whole session:

Hook: attached to the tests in one file login.spec.ts beforeEach test 1test 2 Runs for these tests. Another file needs its own. Fixture: defined once, injected wherever it is asked for test-base.ts defined one time login.spec.tse2e-checkout.spec.tscart.spec.ts One definition, any number of files. A test gets it only if it asks for it by name. Nothing is global.
A hook belongs to a file. A fixture belongs to the project and travels by injection.

Asked live, and worth keeping the answer: is a fixture a global function? No. Global would mean available everywhere. A fixture is available only where a test names it in its arguments. The word used in class was injection, and it is the right one.

02

The fixtures you have been using already

Fixtures are not new to this batch. Every Playwright test already uses them, because page is one:

Built-in fixture What it hands you
page A fresh page, not logged in
context The browser context, so cookies and storage
browser The browser instance
browserName Which browser this run is using
request An API request context, for calling endpoints with no page open
TypeScript
import { test } from "@playwright/test";

test("see all built-in fixtures", async ({ page, context, browser, browserName, request }) => {
    console.log("Browser:", browserName);
    console.log("Browser connected:", browser.isConnected());
    console.log("Page URL:", page.url());

    // an API call without opening a browser page
    const response = await request.get("https://jsonplaceholder.typicode.com/posts/1");
    console.log("API status:", response.status());

    const cookies = await context.cookies();
    console.log("Cookies:", cookies.length);
});

request is the one to file away. It is how API testing gets done later without opening a browser at all.

03

The problem custom fixtures solve

The built-in page is raw. It is not logged in, and it knows nothing about your application.

Without a custom fixture, every test log ininventorycart the thing you wanted to test and again for test 2 and again for test 3 Twenty tests means the same three steps written twenty times, and run twenty times. With a custom fixture the fixture logged in, pages ready the test starts at the thing you wanted to test Written once. Every test that asks gets it. The phrase from the session: "give me a logged-in admin page with the dashboard ready".
The restaurant version used in class: you order biryani and it arrives, because it was already made.
04

test-base.ts, the project's own test object

This is the file the whole session was about. It takes Playwright's test, extends it with one fixture per page object, and exports the result.

TypeScript
import { test as base } from '@playwright/test';
import { LoginPage }     from '@pages/LoginPage';
import { InventoryPage } from '@pages/InventoryPage';
import { CartPage }      from '@pages/CartPage';
// ...and the rest of the page objects

type TestFixtures = {
    loginPage: LoginPage;
    inventoryPage: InventoryPage;
    cartPage: CartPage;
};

export const test = base.extend<TestFixtures>({
    loginPage:     async ({ page }, use) => { await use(new LoginPage(page)); },
    inventoryPage: async ({ page }, use) => { await use(new InventoryPage(page)); },
    cartPage:      async ({ page }, use) => { await use(new CartPage(page)); },
});

export { expect } from '@playwright/test';

Three details that were called out and are easy to get wrong:

  • base is just an alias for test. It is imported as test as base only so the file can then declare its own test without a name clash. base.extend is test.extend.
  • You must export it. Nothing can use a fixture from a file that does not export it.
  • Export expect too, so specs import both from one place.
@playwright/test test, imported as base src/pages/* LoginPage, CartPage, InventoryPage, checkout... test-base.ts base.extend<TestFixtures> exports test + expect e2e-checkout.spec.tslogin.spec.tscart.spec.ts Specs import from test-base, NOT from @playwright/test. That is the whole switch: import { test, expect } from '@fixtures/test-base'; Import from @playwright/test by mistake and your custom fixtures simply are not there.
One file replaces Playwright's test object with your project's version of it.
05

What `use()` actually does

This came from a question in the room and is the part people find strangest.

use is a callback Playwright hands to your fixture. You build the thing, then pass it to use, and Playwright pauses your fixture there while the test runs. Anything before use is setup. Anything after it is teardown.

TypeScript
loginPage: async ({ page }, use) => {
    const login = new LoginPage(page);   // setup
    await use(login);                    // the test body runs here
    // teardown would go here
},

Verified on Playwright 1.62.1 by logging each stage:

Code
setup before use() -> constructed page object -> test body -> teardown after use()

Interview question shape: what happens if you never call use? The fixture builds its value and never hands it over, so the test that asked for it never receives it. Building the object is not enough; use is what delivers it and what marks the boundary between setup and cleanup.

06

Writing the spec against your own test object

TypeScript
import { test, expect } from '@fixtures/test-base';
import { DataGenerator } from '@utils/DataGenerator';

test.describe('TTACart end to end', () => {
    test.beforeEach(async ({ loginPage }) => {
        await loginPage.open();
        await loginPage.loginAs(credentials.standardUser, credentials.password);
    });

    test('completes a checkout @p0', async ({ inventoryPage, cartPage, checkoutStepOnePage }) => {
        const customer = DataGenerator.checkoutCustomer();
        // login already happened, so the test starts here
    });
});

Notice loginPage is injected into beforeEach but not into the test itself. The class made this point deliberately: login is done by a fixture and a hook working together, so the test body never mentions it. Injecting something you do not use is harmless, but using it when you did not need to is the thing to avoid.

Also flagged as a real interview question, and it connects straight to yesterday's 3x session on modules: the curly braces in import { DataGenerator } are there because it is a named import. A default import would take no braces.

07

Credentials that never reach the repository

A fixture that logs in needs a username and password, and those must not be committed.

TypeScript
// src/config/credentials.ts
export const credentials = {
    standardUser: process.env.TTA_USER ?? '',
    password:     process.env.TTA_SECRET ?? '',
};
Terminal
export TTA_SECRET='...'
npx playwright test src/tests/e2e-checkout.spec.ts
export TTA_SECRET terminal, or Jenkins process.env read at run time credentials.ts holds no values the login fixture The repository gets credentials.ts, and never a single secret A parameterised Jenkins job can set them per run.
The file is committed. The values are not. That is the whole trick.

If export VAR=value in a terminal is unfamiliar, that is the gap to close rather than work around. It sets a variable for that shell, and process.env.VAR is how code reads it back. A .env file with dotenv is the other route, and this framework already loads one.

08

visualStep, and the flag that keeps it sane

test.step groups actions in the report but attaches nothing. visualStep is a small wrapper written for this batch that takes the page as a first argument and attaches a screenshot to each step.

test.step await test.step('open cart', fn) In the report: a named step, a duration, pass or fail. Screenshots only on failure. visualStep await visualStep(page, 'open cart', fn) Everything test.step gives you, plus a screenshot attached to every single step. Costs disk and time on every run. Gated by an ATTACH_SCREENSHOT flag added live, defaulting to false. Turn it on to debug, off to ship.
Not a Playwright feature. A wrapper, and optional: plain test.step is a fine choice.

The honest summary given in class: this is lovely when you are debugging and wasteful when you are not, which is why the flag exists and why it defaults to off.

09

The trace viewer

Terminal
npx playwright show-trace path/to/trace.zip

The trace is a recording of the run. You step through it, scrub the timeline, play at half speed, and at any frame you can inspect the DOM and try a locator against that exact moment. On a failure it shows you what the page looked like when it broke rather than what it looks like now.

It also makes fixtures visible: the before-hook section shows the browser being created, the page created, login navigating, and the fixtures loading in order.

10

Composable fixtures

Once test-base.ts exists, the pattern generalises. The session generated several more live:

Fixture Hands the test
loginPage The login page, constructed, not logged in
valid login A session already logged in with good credentials
invalid login A session where login was attempted and failed, for negative tests
login with inventory Logged in and already on the inventory page
login with selected item Logged in, on inventory, with one item already chosen

Each one is a starting state you no longer pay for in the test body. The build order is the same every time: page objects, then fixtures, then the test.

A caveat from the room worth carrying: if you cache a session and run a few thousand tests, the session can expire mid-run. The answer given was to handle it deliberately rather than hope, by detecting expiry and re-establishing the session.

11

About "it becomes very fast"

The session explained the speed-up as locators and actions being preloaded into memory by the fixture. That part does not hold up, and the real reason is better news.

Playwright locators are lazy. Constructing one stores a selector and touches the browser not at all. Measured on Playwright 1.62.1: constructing 500 locators for elements that do not exist took 1ms and threw nothing. The first .count() on one of them took 17ms, because that is when the work actually happens.

So a page object costs effectively nothing to construct, which means preloading it saves effectively nothing.

What fixtures genuinely save is the repeated setup work: the logins, the navigations, the form fills that every test would otherwise redo. On a suite that logs in twenty times, removing nineteen logins is a large saving, and it has nothing to do with locators being in memory. The right claim is "each test starts further along", not "the locators are preloaded". Both lead to the same design, but only one survives an interview follow-up.

12

Questions from the floor

Can a fixture be treated as an initialised page-object instance? Yes, that is exactly what these are.

Different fixtures for system admin and normal user? Yes. One fixture per starting state, and they can share code.

Can we take several screenshots on one page? Yes, by adding more steps. The screenshot follows the step.

How do we rerun only failed tests? Deferred to a later session, and --last-failed is the flag to look up meanwhile.

Can we roll back a bad change? Yes, through git rather than anything Playwright provides.

Is Playwright MCP used here? Yes, it is what lets an assistant drive and record a session.

13

Tasks and announcements