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 |
2. The Recommended Framework Structure¶
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
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¶
- Tests describe behaviour; Page Objects describe interaction. Never put a raw CSS selector in a test.
- One selector, one place. If you're editing the same locator in two files, you need a Page Object.
- Set up via API, verify via UI. Don't click through 5 screens to reach the one you're testing.
- Data lives outside tests. Hard-coded data is the enemy of reuse.
- If it only runs on your machine, it doesn't count. Wire up CI early.
7. Where to Go Next¶
- TypeScript Cheat Sheet — the language your framework is written in
- Playwright Project Anatomy — the underlying project files
- Test-Case Generator Agent — using AI to generate Playwright tests
- Official docs: playwright.dev — Page Object Models and Fixtures pages