Back to blog

Playwright Automation Framework from Scratch (2026)

Written by Kajal · Reviewed and published by Prasandeep

9 min readTest Automation
Playwright Automation Framework from Scratch (2026)

Building a Playwright automation framework from scratch is one of the best ways to move from writing tests to designing a test system. A solid framework gives you structure, reusability, stability, and CI/CD readiness so automation scales as the product grows.

This guide walks through a practical framework step by step—the same ideas product teams use: clean folders, Page Object Model, fixtures, test data, utilities, API helpers, reporting, and pipeline-friendly execution.

For CI depth, see GitHub Actions + Playwright CI/CD pipeline. For flaky debugging, see Playwright Flaky Test Debugging in VS Code and Fix Flaky Tests: 2026 Masterclass. For framework choice context, see Playwright vs Selenium vs Cypress: 2026 Comparison.

Why build a framework

Playwright runs simple tests out of the box, but raw spec files do not scale. As test count grows you see duplicated locators, repeated login code, inconsistent data setup, and painful maintenance.

A framework gives a repeatable way to organize automation—easier to read, update, and run in pipelines. Experienced SDETs usually design the skeleton before writing dozens of tests.

A production-shaped Playwright stack typically delivers:

  • Reusable page actions
  • Centralized configuration
  • Separated test data
  • Better reporting and debugging
  • Parallel and cross-browser execution
  • API + UI reuse for faster setup

What you will build

By the end of this guide, the framework includes:

  • TypeScript-based Playwright project setup
  • Page Object Model for UI actions
  • Fixtures for reusable setup and teardown
  • Test data files and generators
  • Utility helpers for common operations
  • API layer for setup and validation
  • HTML (and optional Allure) reporting
  • CI-friendly execution structure

The goal is a small but real layout you can grow on a product team—not a demo that collapses after ten tests.

Roadmap at a glance

StepTopic
1Project setup
2Folder structure and design principles
3playwright.config.ts
4Baseline smoke test
5Page Object Model, assertions, dynamic UI
6Custom fixtures
7Test data
8Utilities
9API helpers
10Reporting and parallel runs
11CI/CD integration
12Example end-to-end flow

Step 1 — Project setup

Start with the official initializer:

Bash
npm init playwright@latest

When prompted, choose:

  • TypeScript
  • Tests under tests/ (adjust if your team prefers e2e/)
  • GitHub Actions workflow if you want CI scaffolding
  • Browser downloads for Chromium, Firefox, and WebKit

Then:

Bash
npm install npx playwright install

You now have a working baseline to extend into a framework.

Step 2 — Folder structure and design principles

Keep concerns separated as the suite grows:

Clike
playwright-framework/ ├── tests/ │ ├── ui/ │ ├── api/ │ └── smoke/ ├── pages/ ├── fixtures/ ├── test-data/ ├── utils/ ├── helpers/ ├── config/ ├── reports/ ├── playwright.config.ts ├── package.json └── tsconfig.json
FolderPurpose
tests/uiEnd-user journeys
tests/apiAPI-only or contract-style checks
tests/smokeFast PR gate suite
pages/Page objects—locators and actions
fixtures/Extended Playwright fixtures
test-data/JSON, factories, env-specific inputs
utils/Generic helpers (not screen-specific)
helpers/API clients, auth, data builders

Adjust names to your org, but keep test logic, page logic, and utilities apart.

Core design principles

Before more code, agree on team rules:

  1. Keep tests readable — describe behavior and outcomes, not selector mechanics.
  2. Centralize locators — page objects own UI targeting; one place to fix when the UI moves.
  3. Reuse setup — login, env switching, and cleanup via fixtures or helpers.
  4. Separate data — JSON, factories, or env vars—not literals copied in every spec.
  5. Make failures debuggable — traces, screenshots, and reports on by default.

These principles keep the framework sustainable, not fragile.

Step 3 — Configuring Playwright

playwright.config.ts defines browsers, timeouts, retries, artifacts, and reporters.

Typescript
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30_000, retries: process.env.CI ? 1 : 0, fullyParallel: true, reporter: [['html', { open: 'never' }], ['line']], use: { baseURL: process.env.BASE_URL ?? 'https://example.com', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', actionTimeout: 10_000, navigationTimeout: 30_000, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], });

Good defaults matter: without traces and failure artifacts, debugging CI becomes expensive quickly.

Step 4 — Writing your first test

Validate the install:

Typescript
import { test, expect } from '@playwright/test'; test('homepage loads correctly', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/Example/); });

Fine for smoke-checking setup—not enough for a growing suite. Next: POM, fixtures, and helpers.

Step 5 — Page Object Model and UI stability

Each screen or feature area gets a class with locators and actions. Deep patterns: Page Object Model 2026: Best Practices.

Page Object Model

Typescript
import { Page, Locator } from '@playwright/test'; export class LoginPage { readonly page: Page; readonly usernameInput: Locator; readonly passwordInput: Locator; readonly loginButton: Locator; constructor(page: Page) { this.page = page; this.usernameInput = page.locator('#username'); this.passwordInput = page.locator('#password'); this.loginButton = page.getByRole('button', { name: 'Login' }); } async open() { await this.page.goto('/login'); } async login(username: string, password: string) { await this.usernameInput.fill(username); await this.passwordInput.fill(password); await this.loginButton.click(); } }

Test focused on intent:

Typescript
import { test, expect } from '@playwright/test'; import { LoginPage } from '../../pages/LoginPage'; test('user can log in', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.open(); await loginPage.login('john.doe', 'Password123!'); await expect(page).toHaveURL(/dashboard/); });

Assertions and stability

Playwright assertions auto-wait. Prefer:

Typescript
await expect(page).toHaveURL(/dashboard/); await expect(page.getByText('Welcome')).toBeVisible(); await expect(page.locator('.cart-count')).toHaveText('2');

Assert outcomes that matter, not that a click occurred. Avoid waitForTimeout() except when debugging—rely on locators and web-first expectations.

Handling dynamic elements

Wrap recurring patterns in helpers when they appear often:

  • Dropdowns, date pickers, modals
  • iframes and multiple tabs
  • File uploads, dialogs
  • Network-driven content
Typescript
import { Page } from '@playwright/test'; export async function uploadFile(page: Page, selector: string, filePath: string) { await page.setInputFiles(selector, filePath); }

Step 6 — Fixtures for reusability

Playwright fixtures inject shared setup—logged-in sessions, page objects, API clients.

Typescript
import { test as base } from '@playwright/test'; import { LoginPage } from '../pages/LoginPage'; type AppFixtures = { loginPage: LoginPage; }; export const test = base.extend<AppFixtures>({ loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, }); export { expect } from '@playwright/test';

Usage:

Typescript
import { test, expect } from '../fixtures/test-fixtures'; test('login flow', async ({ loginPage, page }) => { await loginPage.open(); await loginPage.login('john.doe', 'Password123!'); await expect(page).toHaveURL(/dashboard/); });

Setup stays out of specs; large suites stay maintainable.

Step 7 — Test data management

Hardcoded values fail at scale. Options:

  • JSON for static personas
  • Factory functions for unique emails/orders
  • Environment variables for secrets and baseURL
  • Builders for complex API payloads

test-data/users.json:

Json
{ "validUser": { "username": "john.doe", "password": "Password123!" }, "invalidUser": { "username": "wrong.user", "password": "badpass" } }

Dynamic helper:

Typescript
export function generateEmail() { return `user_${Date.now()}@example.com`; }

Useful for registration and order flows that require unique data per run.

Step 8 — Utility functions

Utilities handle non–screen-specific tasks: read JSON, format dates, build auth headers.

Typescript
import fs from 'fs'; export function readJson<T>(path: string): T { return JSON.parse(fs.readFileSync(path, 'utf-8')) as T; }

If a helper becomes tied to one page’s DOM, move it into that page object.

Step 9 — API layer in the framework

Strong frameworks are not UI-only. API setup is faster and more stable for data prep and cleanup.

Typescript
import { APIRequestContext } from '@playwright/test'; export class ApiClient { constructor(private request: APIRequestContext) {} async createUser(payload: object) { return this.request.post('/api/users', { data: payload }); } async deleteUser(userId: string) { return this.request.delete(`/api/users/${userId}`); } }

Use APIs to:

  • Seed data before UI steps
  • Clean up after tests
  • Assert backend state alongside UI

Pair with API Contract Testing with Pact.js when services have many consumers.

Step 10 — Reporting and parallel execution

Reporting setup

Turn on artifacts early:

ArtifactWhen
HTML reportSuite overview
TraceStep-by-step replay (on-first-retry or on)
Screenshotonly-on-failure
Videoretain-on-failure

Optional Allure or similar for trend dashboards. Many teams use HTML locally and upload HTML + traces from CI—see GitHub Actions + Playwright CI/CD pipeline.

Parallel execution

Playwright runs tests in parallel by default. For reliable parallelism:

  • Avoid shared mutable state
  • Isolate tests (unique users/records)
  • Clean up data after runs
  • Do not depend on execution order

Well-isolated tests cut runtime without multiplying flake—especially with fullyParallel: true.

Step 11 — CI/CD integration

Validate in the pipeline, not only locally. Typical GitHub Actions flow:

Yaml
name: Playwright Tests on: push: branches: [main] 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 env: BASE_URL: ${{ secrets.BASE_URL }} - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/

Upload reports with if: always() so failures still leave artifacts.

Best practices

  • Prefer getByRole, getByLabel, and data-testid locators
  • Keep specs short and intention-driven
  • Put UI actions in page objects; assertions in tests (or thin helpers)
  • Use fixtures for setup; APIs for data
  • Separate data from spec logic
  • Capture traces and screenshots on failure
  • Run in CI on every PR

Common mistakes

  • Putting locators, setup, assertions, and data in one spec file
  • Copy-pasting login in every test
  • Brittle CSS-only selectors everywhere
  • Using sleep() instead of Playwright waits
  • Ignoring HTML reports and traces when CI fails
  • Sharing mutable data across parallel workers

Avoid these early and the framework stays manageable for years.

Step 12 — Example end-to-end flow

Scenario: User logs in and can open the cart.

Structure: LoginPage for auth; optional ProductPage for cart actions; API helper for user seeding; fixture injects loginPage; credentials in test-data/users.json.

Typescript
import { test, expect } from '../fixtures/test-fixtures'; import { readJson } from '../utils/readJson'; type Users = { validUser: { username: string; password: string } }; test('user can log in and access cart', async ({ loginPage, page }) => { const { validUser } = readJson<Users>('test-data/users.json'); await loginPage.open(); await loginPage.login(validUser.username, validUser.password); await expect(page.getByText('Welcome')).toBeVisible(); await expect(page.getByRole('link', { name: 'Cart' })).toBeVisible(); });

The spec stays short; the framework carries structure and reuse.

Conclusion

A Playwright framework from scratch is not just folders—it is a system for maintainability, fast feedback, and team consistency: page objects, fixtures, APIs, utilities, separated data, and CI-ready defaults.

Build those pieces deliberately from day one and automation becomes an engineering asset instead of scattered scripts. Extend with smoke vs regression projects, self-healing only where justified, and contract tests at service boundaries as the architecture matures.