How to build a scalable Playwright framework
A scalable Playwright framework is mostly Playwright defaults, organised: tests that read like scenarios, fixtures that inject page objects and auth, thin page objects built on role and label locators, test data in its own folder, API calls for setup, and CI that shards, retries and keeps traces. Everything else — wrappers, base classes, custom retry logic — waits until you can prove you need it.
Writing Playwright tests is easy. Keeping 500 of them fast, readable and trustworthy a year later is the actual job. This guide builds a framework layer by layer — every file is clickable in the explorer below and every snippet compiles against Playwright 1.63 — and it is just as explicit about what not to build.
Key takeaways
- Start from npm init playwright@latest and change defaults only when a test forces you to.
- Fixtures replace beforeEach and base-class inheritance: tests ask for loginPage and get one.
- Page objects hold locators and intent-level actions; assertions stay in the test so failures read clearly.
- Use the API for setup and cleanup, the UI only for the behaviour under test — and log in once with storageState.
- In CI: install browsers with --with-deps, shard across machines, retry to surface flakes, and merge the shards' blob reports into one HTML report with traces.
The architecture in one picture
Each layer only talks to the one below it. Hover a layer to highlight it; Replay runs the walk-through again.
- TestsScenarios and assertions. Read like a user story; no selectors, no waits.
- FixturesInject page objects, API clients, test data and auth into each test.
- Page objects & APILocators and intent actions per screen; typed API helpers for setup.
- PlaywrightAuto-waiting, locators and browser contexts on Chromium, Firefox and WebKit.
- Reports & tracesHTML report, screenshots, video and a trace for every retried failure.
- CI/CDSharded runs on every pull request; artifacts kept for debugging.
The folder structure — click any file
Eleven files cover a real framework. Each one has a single job.
Logs in once per run and saves the session. Every browser project depends on it.
import { test as setup, expect } from '@playwright/test';
import { users } from '../data/users';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(users.valid.email);
await page.getByLabel('Password').fill(users.valid.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});Tests about login opt out of the saved session, use page objects from fixtures and assert in the test body.
import { test, expect } from '../fixtures/test';
import { users } from '../data/users';
// These tests are *about* logging in, so they start logged out.
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('login', () => {
test('valid credentials open the dashboard', async ({ page, loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login(users.valid.email, users.valid.password);
await expect(page).toHaveURL(/\/dashboard/);
await expect(dashboardPage.heading).toBeVisible();
});
test('wrong password shows an error', { tag: '@smoke' }, async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login(users.invalid.email, users.invalid.password);
await expect(loginPage.error).toHaveText(/invalid email or password/i);
});
});Pure API tests plus the pattern that matters most: create data through the API, verify it in the UI, clean up.
import { test, expect } from '@playwright/test';
test('GET /api/users returns a list', async ({ request }) => {
const response = await request.get('/api/users');
await expect(response).toBeOK();
const body = await response.json();
expect(Array.isArray(body.data)).toBe(true);
});
test('create a user via API, then check it in the UI', async ({ request, page }) => {
const created = await request.post('/api/users', { data: { name: 'Ada QA', role: 'tester' } });
await expect(created).toBeOK();
const { id } = await created.json();
await page.goto(`/users/${id}`);
await expect(page.getByRole('heading', { name: 'Ada QA' })).toBeVisible();
await request.delete(`/api/users/${id}`); // leave no data behind
});A thin page object: locators as readonly fields, actions named after user intent. No assertions, no waits.
import type { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly error: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Sign in' });
this.error = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
}One class per screen. Keep them small; split a screen before a class passes ~150 lines.
import type { Page, Locator } from '@playwright/test';
export class DashboardPage {
readonly heading: Locator;
readonly userMenu: Locator;
constructor(private readonly page: Page) {
this.heading = page.getByRole('heading', { name: 'Dashboard' });
this.userMenu = page.getByRole('button', { name: 'Account' });
}
async goto() {
await this.page.goto('/dashboard');
}
}Extends Playwright's test with your page objects. Import test and expect from here, not from @playwright/test.
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
type Pages = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
// Tests ask for what they need by name — no beforeEach, no base classes.
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect };Test data lives apart from tests. Credentials come from environment variables through requireEnv, so a missing secret fails the run with a clear message.
import { requireEnv } from '../utils/env';
export const users = {
// Real credentials come from env vars (CI secrets or a local .env).
valid: { email: requireEnv('E2E_USER'), password: requireEnv('E2E_PASSWORD') },
invalid: { email: 'nobody@example.com', password: 'wrong-password' },
} as const;Tiny helpers only — like requireEnv, used by data/users.ts. If a helper would only wrap a single Playwright call, call Playwright directly.
// Fail fast with a readable message instead of `undefined` deep inside a test.
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing env var ${name} — copy .env.example to .env`);
return value;
}One config: parallel, CI-aware retries, workers and reporter (blob on CI, HTML locally), traces on retry, a setup project and three browsers.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // a stray test.only fails CI
retries: process.env.CI ? 2 : 0, // retries surface flaky tests in the report
workers: process.env.CI ? 4 : undefined,
// CI shards write blob reports that one job merges; locally you get HTML.
reporter: process.env.CI ? 'blob' : [['list'], ['html', { open: 'never' }]],
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});Four shards in parallel with secrets from GitHub. Each shard uploads a blob report; the merge job turns them into one HTML report, even when tests fail.
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
playwright-tests:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- run: npm ci
- run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
BASE_URL: ${{ vars.BASE_URL }}
E2E_USER: ${{ secrets.E2E_USER }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: [playwright-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v5
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into one HTML report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14Scripts are the team's shared vocabulary: test, test:ui, test:smoke, report.
{
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:smoke": "playwright test --grep @smoke",
"test:chromium": "playwright test --project=chromium",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}The CI pipeline
What happens after git push, step by step.
- Push / PRA push to main or any pull request triggers the workflow.
- npm ciClean install from the lockfile — same versions as your laptop.
- Install browsersnpx playwright install --with-deps adds browsers and OS libraries.
- Run 4 shards--shard=1/4 … 4/4 run in parallel; each uploads a blob report.
- Merge reportsA merge job combines the blobs into one HTML report — even when tests fail.
- ReviewDownload the report, open the trace of any failed or flaky test.
Step 1 — Scaffold the project
The official initializer asks four questions and writes a working config, an example test and (optionally) a GitHub Actions workflow. Accept TypeScript, keep the tests folder, say yes to the workflow and to installing browsers.
npm init playwright@latest
# verify
npx playwright test
npx playwright show-reportPage objects: thin on purpose
A page object answers two questions: where is it, and what can a user do here. Expose locators and intent-level actions (login, addToCart). Keep expectations in the test, so a failure message says what the test expected rather than which helper failed. Prefer getByRole and getByLabel; fall back to getByTestId for elements with no accessible name.
Tip: Return Locators, not strings or booleans. A test can then assert with auto-retrying expect(locator).toHaveText(…) instead of reading textContent once and hoping.
Fixtures instead of beforeEach and base classes
Fixtures are created only when a test asks for them, torn down automatically, and composable — a test that needs a logged-in admin and a seeded cart says so in its signature. They replace base-class inheritance chains, which get harder to change with every test you add.
API for setup, UI for the behaviour
Creating a user through five screens tests the signup flow again and again. Create it with one request call, test the screen you care about, and delete it afterwards. Combined with storageState auth, this is usually where a suite wins back the most run time.
What not to build (yet)
Each of these costs maintenance from day one. Add one only when a real test cannot be written without it.
- Wrappers around click / fill / waitFor — Playwright already auto-waits and retries.
- Custom retry loops — use retries in the config, and fix what the report marks as flaky.
- Explicit waits and sleeps — web-first assertions wait for you.
- BaseTest / BasePage inheritance trees — use fixtures and composition.
- Excel or CSV readers before you have data-driven tests that need them.
- A custom reporter before the built-in HTML report stops being enough.
Pro tips
Small habits that keep a framework healthy as it grows.
- Tag tests ({ tag: '@smoke' }) and run subsets with --grep instead of maintaining separate suites.
- Keep every test independent: its own data, no order dependency, safe to run in parallel.
- Put BASE_URL and credentials in environment variables; commit a .env.example, never .env.
- Retries in CI are a detector, not a fix — open the trace of every test the report marks as flaky.
- Run npx playwright test --only-changed locally for fast feedback before pushing.
FAQ
tests/ for specs, pages/ for page objects, fixtures/ for a custom test with injected page objects, data/ for test data, utils/ for small helpers, plus playwright.config.ts and a CI workflow. Add folders only when a real need appears.
Sources
Related tools
Related guides
Playwright storageState: Log In Once, Reuse It in Every Test (2026)
Stop logging in before every Playwright test. Save the authenticated browser state once with storageState, reuse it across the suite, and see exactly how much CI time it saves with an interactive calculator.
Playwright Cheat Sheet 2026: Commands, Locators & Assertions (TS + Python)
A searchable Playwright quick reference with 134 copy-ready snippets in 26 sections — install, test structure, locators, actions, assertions, waits, frames, dialogs, popups, files, storage, API testing, emulation, traces, config, annotations and the CLI — in TypeScript and Python, for Playwright 1.63.
Playwright Complete Notes: Beginner to Advanced (TypeScript, 2026)
A 14-chapter Playwright course in TypeScript — architecture, setup, locators, auto-waiting, actions, frames and popups, waits, API mocking, fixtures, debugging, 2026 features and interview questions — with runnable code, self-checks and progress tracking.