Skip to content

Playwright Framework Tutorial — Building a Real Test Framework

Prerequisite: Read Playwright Project Anatomy first — it explains the bare project files (package.json, node_modules, playwright.config.ts, tsconfig.json). This page goes one level up: how you turn that bare project into a maintainable test framework — and what every file in it is for.


1. Bare Project vs Framework

A fresh npm init playwright gives you a project — somewhere to write tests. A framework is the structure you add so that 5, 50, or 500 tests stay readable, reusable, and not a copy-paste nightmare.

Bare project Framework
Tests call page.click() directly Tests call loginPage.login(user)
Selectors scattered across tests Selectors live in one Page Object
Test data hard-coded in tests Test data in fixtures / JSON
One environment Dev / staging / prod via config
Copy-paste setup in every test Shared fixtures and helpers

my-framework/
├── tests/                      ← the test specs (the "what to verify")
│   ├── auth/
│   │   └── login.spec.ts
│   └── checkout/
│       └── payment.spec.ts
├── pages/                      ← Page Objects (the "how to interact")
│   ├── BasePage.ts
│   ├── LoginPage.ts
│   └── CheckoutPage.ts
├── fixtures/                   ← custom test setup / shared state
│   └── test-fixtures.ts
├── data/                       ← test data, separate from code
│   ├── users.json
│   └── testData.ts
├── utils/                      ← reusable helpers
│   ├── apiClient.ts
│   ├── dateHelper.ts
│   └── logger.ts
├── config/                     ← environment configuration
│   └── env.ts
├── .env                        ← secrets & per-env values (NOT committed)
├── playwright.config.ts        ← Playwright control panel
├── tsconfig.json               ← TypeScript rules
├── package.json                ← dependencies + scripts
└── .github/workflows/ci.yml    ← run tests automatically on push
flowchart TB
    SPEC["🧪 tests/login.spec.ts<br/>describes WHAT to verify"] --> PO["📄 pages/LoginPage.ts<br/>knows HOW to interact"]
    SPEC --> FIX["🔧 fixtures/test-fixtures.ts<br/>provides ready-to-use objects"]
    PO --> BASE["📄 pages/BasePage.ts<br/>shared page behaviour"]
    SPEC --> DATA["📦 data/users.json<br/>WHAT data to use"]
    PO --> UTIL["🛠️ utils/apiClient.ts<br/>reusable helpers"]
    FIX --> CFG["⚙️ config/env.ts<br/>WHICH environment"]
    CFG --> ENV["🔐 .env<br/>secrets & URLs"]

3. Every File Explained — and Why It Matters

tests/ — The Specs (the what)

The actual tests. Each file groups related scenarios. A spec should read like plain English describing behaviour — all the messy "how" is delegated elsewhere.

// tests/auth/login.spec.ts
import { test, expect } from '../../fixtures/test-fixtures';

test.describe('Login', () => {
  test('valid user can log in', async ({ loginPage }) => {
    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');
    await expect(loginPage.page).toHaveURL(/inventory/);
  });
});

Why it matters: when this test fails, anyone — even a non-coder — can read it and understand what broke. No selectors, no waits, no clutter.


pages/ — Page Objects (the how)

The Page Object Model (POM) is the single most important framework pattern. Each page/component of your app gets one class that holds: (a) its selectors, and (b) the actions you can do on it.

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class LoginPage extends BasePage {
  readonly username: Locator;
  readonly password: Locator;
  readonly submitBtn: Locator;

  constructor(page: Page) {
    super(page);
    this.username  = page.getByLabel('Username');
    this.password  = page.getByLabel('Password');
    this.submitBtn = page.getByRole('button', { name: 'Login' });
  }

  async goto() { await this.page.goto('/login'); }

  async login(user: string, pass: string) {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submitBtn.click();
  }
}

Why it matters: if a selector changes, you fix it in one place — not in 40 tests. This is what makes a suite survivable over time.


pages/BasePage.ts — Shared Page Behaviour

A parent class every Page Object extends. Holds the page reference and common helpers (wait for spinner, accept cookies, take screenshot).

// pages/BasePage.ts
import { Page } from '@playwright/test';

export class BasePage {
  constructor(public readonly page: Page) {}

  async waitForLoad() {
    await this.page.waitForLoadState('networkidle');
  }
  async dismissCookies() {
    const banner = this.page.getByRole('button', { name: 'Accept' });
    if (await banner.isVisible()) await banner.click();
  }
}

Why it matters: behaviour shared by every page is written once. DRY (Don't Repeat Yourself) at the page level.


fixtures/ — Custom Test Setup

Playwright fixtures give each test ready-made objects without repeating setup. Instead of const loginPage = new LoginPage(page) in every test, you declare it once and Playwright injects it.

// fixtures/test-fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { CheckoutPage } from '../pages/CheckoutPage';

type MyFixtures = {
  loginPage: LoginPage;
  checkoutPage: CheckoutPage;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => { await use(new LoginPage(page)); },
  checkoutPage: async ({ page }, use) => { await use(new CheckoutPage(page)); },
});

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

Now any test that writes async ({ loginPage }) => {...} gets a fresh LoginPage automatically.

Why it matters: eliminates boilerplate, guarantees clean state per test, and is the idiomatic Playwright way to share setup (login-as-user, seed-data, etc.).


data/ — Test Data (separate from code)

Keep what you test with apart from how you test. JSON for static data, .ts for typed/generated data.

// data/users.json
{
  "standard":  { "username": "standard_user", "password": "secret_sauce" },
  "lockedOut": { "username": "locked_out_user", "password": "secret_sauce" }
}
// data/testData.ts
export const checkoutInfo = {
  firstName: 'Jane', lastName: 'Doe', postcode: 'SW1A 1AA',
};

Why it matters: update test data without touching test logic; reuse the same data across many tests; easy to extend to data-driven testing (loop over many users).


utils/ — Reusable Helpers

Generic, app-agnostic tools: an API client to seed/clean data, date formatting, logging, random-data generation.

// utils/apiClient.ts
import { request, APIRequestContext } from '@playwright/test';

export async function createUserViaApi(baseURL: string, user: object) {
  const ctx: APIRequestContext = await request.newContext({ baseURL });
  const res = await ctx.post('/api/users', { data: user });
  await ctx.dispose();
  return res.json();
}

Why it matters: UI tests are slow; using an API helper to set up preconditions (create a user, place an order) makes tests faster and more reliable than clicking through the UI to get there.


config/env.ts + .env — Environment Handling

.env holds per-environment values and secrets (never committed). config/env.ts reads them into typed config.

# .env   (git-ignored)
BASE_URL=https://staging.myapp.com
API_TOKEN=super-secret-token
// config/env.ts
import 'dotenv/config';

export const env = {
  baseURL:  process.env.BASE_URL  ?? 'http://localhost:3000',
  apiToken: process.env.API_TOKEN ?? '',
};

Why it matters: the same test suite runs against dev, staging, or prod by swapping one .env file — and secrets stay out of source control.


playwright.config.ts — Wiring It Together

The config points at your tests, sets the baseURL from .env, registers reporters, and defines the browser matrix. (Full breakdown in Project Anatomy.)

import { defineConfig, devices } from '@playwright/test';
import { env } from './config/env';

export default defineConfig({
  testDir: './tests',
  use: { baseURL: env.baseURL, trace: 'on-first-retry' },
  reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile',   use: { ...devices['Pixel 7'] } },
  ],
});

.github/workflows/ci.yml — Automated Runs

Runs the suite automatically on every push/PR, so broken code is caught before merge.

name: e2e
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: report, path: playwright-report/ }

Why it matters: tests that only run on your machine protect nothing. CI makes the suite a real quality gate.


4. How a Single Test Flows Through the Framework

sequenceDiagram
    participant CI as CI / npx playwright test
    participant Cfg as playwright.config.ts
    participant Fix as fixtures
    participant Spec as login.spec.ts
    participant PO as LoginPage
    participant App as Browser/App

    CI->>Cfg: read config, baseURL, browsers
    Cfg->>Fix: set up fixtures for the test
    Fix->>Spec: inject ready loginPage
    Spec->>PO: loginPage.login(user, pass)
    PO->>App: fill, click (real browser actions)
    App-->>Spec: page state
    Spec->>Spec: expect(...) assertion
    Spec-->>CI: pass / fail + trace

5. Framework File Cheat-Sheet

File / Folder Role One-line importance
tests/ The specs Plain-English behaviour — what to verify
pages/ Page Objects Selectors + actions in one place — change once, not everywhere
pages/BasePage.ts Shared page logic Common behaviour written once (DRY)
fixtures/ Custom setup Inject ready objects, clean state per test, no boilerplate
data/ Test data Separate data from logic; enables data-driven tests
utils/ Helpers API seeding, logging, dates — fast & reliable setup
config/env.ts + .env Environments Same suite on dev/staging/prod; secrets stay out of Git
playwright.config.ts Control panel Browsers, retries, reporters, baseURL
tsconfig.json TS rules Type-safety + import aliases
.github/workflows/ci.yml Automation Makes the suite a real quality gate

6. The Golden Rules

  1. Tests describe behaviour; Page Objects describe interaction. Never put a raw CSS selector in a test.
  2. One selector, one place. If you're editing the same locator in two files, you need a Page Object.
  3. Set up via API, verify via UI. Don't click through 5 screens to reach the one you're testing.
  4. Data lives outside tests. Hard-coded data is the enemy of reuse.
  5. If it only runs on your machine, it doesn't count. Wire up CI early.

7. Where to Go Next