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.

```ts // 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.

```ts // 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).

```ts // 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.

```ts // 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({ 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.

json // data/users.json { "standard": { "username": "standard_user", "password": "secret_sauce" }, "lockedOut": { "username": "locked_out_user", "password": "secret_sauce" } }

ts // 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.

```ts // 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.

```bash

.env (git-ignored)

BASE_URL=https://staging.myapp.com API_TOKEN=super-secret-token ```

```ts // 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.)

```ts 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.

yaml 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