The Testing Academy · Class Notes Monday, 10 August (IST)
Live class · study guide

Building the advanced Playwright framework: fixtures, structure, and config

Why flat spec files stop scaling, what a fixture actually does, the custom fixture that logs in once and injects the session everywhere, and the playwright.config settings that make one framework run against five environments.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright class, rebuilt from the session recording. Configuration values and commands are reproduced as they were set on screen.

01

What a framework actually is

The batch has been writing individual spec files. This session starts the framework that those specs will live inside, and the first job was defining the word.

A framework is a structured way of writing and managing your code, built for three things:

  • Reusability. One place for a locator, not twenty.
  • Manageability. One place for a module, test data, configuration, utilities.
  • Scalability. Thousands of tests that still run and still make sense.

The problem it solves is what happens when flat spec files grow. The same locator gets retyped in every file. Login gets re-implemented in every test. Values get hardcoded. Nothing is wrong on day one and everything is wrong by test three hundred.

The framework being built here is the one running more than 12,000 test cases at Tekion, and the same shape used across the 40-plus companies Pramod has consulted for. It carries a custom reporter and an agent factory, which is the part most teams have not implemented.

The build is deliberately from scratch. The previous batch's repository is not being reused.

02

The tech stack, which is an interview question

"What is the tech stack you have used?" gets asked in interviews, and a vague answer costs you. This is the answer for this framework:

Layer Choice
Core Playwright with TypeScript
Design pattern Page Object Model
Test data JSON fixtures with a factory
Reporting Allure now, custom TTA reporter later
Logging Winston
Fake data Faker
Config dotenv
Linting ESLint
Database SQL connection utility
CI/CD GitHub Actions, Jenkins, Docker
AI layer LLM gateway: OpenRouter, Groq, OpenAI, or a local model

Three agents are planned on top: a failure RCA agent, a test data generator, and visual regression. The framework is also intended to work with Cucumber BDD and to carry API testing alongside the UI suite.

03

Fixtures: the setup and teardown you are already doing

A fixture is Playwright's way of providing setup and teardown: the things that happen before a test runs and after it finishes.

The class worked it out from the test's own needs. Before a test can do anything, three things have to exist, and the shorthand used in class is BCP:

  • Browser
  • Context
  • Page

Start them before, close them after. Both halves are the fixture. Playwright already does this for you.

Five built-in fixtures, destructured straight into the test:

Fixture What it gives you
page a fresh page
context the browser context, cookies and storage
browser the browser instance
browserName the name of the browser you are running on
request the API request context, used for API tests
TypeScript
test("uses the built-in fixtures", async ({ page, context, browser, browserName, request }) => {
  console.log(browserName);
  console.log(page.url());
  console.log(await context.cookies());
});

If you come from a TestNG or JUnit background, this is the same idea as the before and after hooks. The concept is not new; only the injection syntax is.

04

Custom fixtures: log in once, inject everywhere

Built-in fixtures hand you a raw page, a raw browser, a raw request. Real suites do not want raw. They want a session that is already signed in.

Without a fixture, two tests look like this, and the duplication is the whole point:

TypeScript
test("test one", async ({ page }) => {
  await page.goto("/");
  // log in as admin
  // assert something on the dashboard
});

test("test two", async ({ page }) => {
  await page.goto("/");
  // log in as admin, again
  // assert something else
});

The tests are different. The login is identical. With a custom fixture, the login disappears from the test:

TypeScript
test("test one", async ({ adminPage }) => {
  // already logged in as admin, start at step two
});

The mechanism is the one the batch already met: log in once, store the session in storage.json, and reuse that state. The login runs a single time, the tests that consume it run many times, and they run faster because they skip the login every time.

One fixture per role, and the test picks the one it needs:

Fixture Session it injects
admin logged in as an administrator
user logged in as a standard user
guest logged in as a guest

The analogy from class: a pre-made pizza base. Add vegetables and it is a veg pizza, add meat and it is non-veg, add only cheese and it is a margherita. The base is common and made once. Whatever is common in your tests gets separated out and injected when it is needed. Python developers will recognise the decorator.

Asked how this differs from a utility: a utility is any helper you call. A fixture is specifically the thing injected around your test to handle what happens before and after it.

The obvious risk was raised in class and confirmed: if the login fails, every test that depends on that fixture fails. That is the trade for the speed.

05

Scaffolding the repository

New repository, named advanced-playwright-2x. Two ways to start it, and both work.

The recommended one:

Terminal
npm init playwright@latest

The prompts, answered as they were in class: TypeScript, the default test folder, and yes to GitHub Actions. Browsers were already installed, so that step only updated the existing WebKit and Firefox builds.

The older way still works:

Terminal
npm init
npm install --save-dev @playwright/test typescript

It works, but it creates almost nothing: no test folder, no configuration file, no sample spec. Everything then has to be built by hand, which is why the init command is the one to use.

The scaffold generated a deep-eval folder that nobody asked for, most likely from a local configuration. Delete it. DeepEval is a framework for LLM testing and has no place in this project.

06

The folder structure

Everything lives under src, with configuration files at the root:

Code
.github/            copilot instructions and workflows
.env                environment values
package.json
playwright.config.ts
tsconfig.json
src/
  api/              API test helpers
  config/           configuration
  fixtures/         custom fixtures
  pages/            page objects
  data/             test data
  tests/            spec files
  utils/            utilities

Each folder holds exactly one kind of thing. Pages contain pages, fixtures contain fixtures, utilities contain utilities, tests contain tests. That is the whole discipline.

Screenshot this structure. It is the shape the rest of the course builds on, and it is also the answer when an interviewer asks you to describe your framework.

The structure itself was created by handing the diagram to an AI coding tool rather than typing directories by hand. The point made in class: you are a coder, not a typewriter. Use the tool for the mechanical part.

07

The dependencies, and what each one is for

Installed as a batch after the scaffold:

Package Used for
dotenv reading values out of the .env file
csv-parse reading CSV test data
xlsx reading Excel test data
winston logging
@faker-js/faker generated test data
jsonpath reading fields out of API responses
ajv and ajv-formats JSON schema validation in API tests
allure-playwright Allure reporting

The JSON path, ajv and Allure packages belong to the API testing sessions and are being installed now so the framework is complete. They are not used yet.

Allure is the reporter for now. The custom TTA reporter comes later in the course, not in this session.

08

playwright.config, the brain of the framework

The generated configuration is a good starting point. A handful of values were changed:

TypeScript
export default defineConfig({
  testDir: "./src/tests",
  timeout: 60 * 1000,
  expect: { timeout: 10 * 1000 },
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: [["list"], ["html"]],
  use: {
    baseURL: resolveBaseURL(),
    screenshot: "only-on-failure",
    video: "on",
    trace: "on-first-retry",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  ],
});

What each line decides:

Setting Effect
testDir where the spec files live
timeout 60 seconds for a whole test
expect.timeout 10 seconds for a single assertion
fullyParallel tests run in parallel
retries 2 on CI, 0 locally
reporter list in the terminal, HTML on disk
screenshot captured only when a test fails
video recorded
trace captured on the first retry
projects Chromium only, the other browsers are dropped

The retries line is a ternary operator, and it is the piece worth understanding. process.env.CI reads an environment variable that comes from outside the project. If a CI flag is set, use 2 retries. If it is not, use 0.

Nothing inside the repository sets that flag. Jenkins sets it, GitHub Actions sets it, or you set it yourself on the command line. The configuration only reads it.

The class initially read the timeout as 60 minutes and corrected it on screen. It is 60 seconds. 60 * 1000 is milliseconds.

09

Environment switching, without hardcoding

The .env file holds everything that changes between environments: base URL, dev URL, staging URL, production URL, logging settings, and credentials.

Terminal
BASE_URL=
DEV_URL=
STAGE_URL=
PROD_URL=
QA_URL=
USERNAME=admin
PASSWORD=admin123

The problem this creates: baseURL in the configuration is a single hardcoded value, but the real base URL is dynamic. Sometimes staging, sometimes production, sometimes dev.

The fix is a function. baseURL accepts one, so instead of a string, call something that decides:

TypeScript
function resolveBaseURL(): string {
  if (process.env.BASE_URL) return process.env.BASE_URL;

  switch (process.env.TEST_ENV) {
    case "api":   return process.env.API_URL;
    case "dev":
    case "local": return process.env.DEV_URL;
    case "stage": return process.env.STAGE_URL;
    case "prod":  return process.env.PROD_URL;
    case "qa":    return process.env.QA_URL;
    default:      return process.env.PROD_URL;
  }
}

An explicit BASE_URL wins outright. Otherwise the switch reads TEST_ENV and returns the matching URL. Set nothing and you fall through to the default.

Set the environment on the command line before running:

Terminal
TEST_ENV=prod npm test

TypeScript writes the return type as string with a lowercase s. This came up live: do not carry the Java String habit across.

Credentials belong in .env and .env never goes to GitHub, the same rule the Python batch covered with python-dotenv.

10

The pages to model

The project for this framework is TTA Cart, a full end-to-end shopping flow. Walking it in the browser produced the page objects to build:

# Page
1 Login
2 Inventory
3 Cart
4 Checkout, step one
5 Checkout, step two
6 Order confirmation

Each becomes a class in src/pages. Building them is where the next session starts.

Walk the cart flow yourself and confirm the six pages before the next class. Identifying page boundaries is the skill; the code that follows is mechanical.

11

Side note: never waiting on a usage limit

This was not on the agenda. It surfaced when the instructor's own Claude Code reported it was running GPT-5 mini mid-session, which is not what anyone expected to see, and the explanation turned out to be worth keeping.

The problem is familiar to anyone on a paid coding-assistant plan. Heavy use exhausts the quota quickly, and then you wait hours for the window to reset. During a research-heavy build, that wait is dead time.

The fix shown in class is claude-code-router, a proxy that sits in front of the CLI and switches the model behind it. Configure a chain, and when one provider runs out the router moves to the next on its own. No manual switching:

Order Provider
1 Claude Code
2 Codex
3 OpenCode, backed by DeepSeek
4 Command Code

When the first provider's window resets, the cycle comes back around to it. The effect is a setup that keeps working around the clock instead of stopping every few hours.

It runs entirely on your own machine, and you can see which model is serving each request. The same mechanism explains the surprise on screen: the router was pointing Claude Code at a different model, and the fix was mapping each model name back to itself, Opus to Opus, Sonnet to Sonnet, Haiku to Haiku.

This has nothing to do with the framework and was flagged as a distraction at the time. A dedicated session was offered if there is enough interest, so say so in the batch thread if you want it.

12

Tasks and announcements

  • Today's task: create the folder structure exactly as shown, add a README with the project details, then commit and push to your own repository.
  • Starter code is being pushed to the advanced-playwright-2x repository.
  • Next session: Wednesday evening. This was stated several times and corrected explicitly: Wednesday, not Thursday.
  • Coming later: the custom reporter, screenshots on all tests rather than only failures, and splitting configuration across multiple environment files. All three were asked about in class and deferred on purpose.
  • Follow along after class, not during. The recording is available about an hour after the session. Copy-pasting live means you follow the keystrokes and miss the reasoning.