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:
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.
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 |
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.
The problem custom fixtures solve
The built-in page is raw. It is not logged in, and it knows nothing about your application.
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.
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:
baseis just an alias fortest. It is imported astest as baseonly so the file can then declare its owntestwithout a name clash.base.extendistest.extend.- You must export it. Nothing can use a fixture from a file that does not export it.
- Export
expecttoo, so specs import both from one place.
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.
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:
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.
Writing the spec against your own test object
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.
Credentials that never reach the repository
A fixture that logs in needs a username and password, and those must not be committed.
// src/config/credentials.ts
export const credentials = {
standardUser: process.env.TTA_USER ?? '',
password: process.env.TTA_SECRET ?? '',
};
export TTA_SECRET='...'
npx playwright test src/tests/e2e-checkout.spec.ts
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.
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.
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.
The trace viewer
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.
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.
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.
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.
Tasks and announcements
- Build it in this order: page objects, then fixtures, then tests. Finish the remaining page objects (cart, checkout one and two, checkout complete, inventory, item detail) before writing
test-base.ts. - Keep credentials in environment variables.
export TTA_SECRET=...before running, or a.envfile. Never in a commit. - Claude Code 101 tomorrow evening, with certification.
- AI Fluency: if you finished the certificate, post it on LinkedIn. Notes: the 4D framework and the plain-English version.
- Batch board: the 2x Playwright and AI Mastery Eraser workspace.
- Previous: the build plan recap, BasePage, utilities and the logger.
- Coming next: API testing, cucumber, and the agent factory.