Skip to main content
Back to Blog

How to Build a Playwright Test Framework from Scratch (2026)

By Aston Cook7 min read
playwright test frameworkplaywright project structureplaywright page object modelplaywright tutorialplaywright ci cdbuild playwright frameworkplaywright best practices 2026

A well-structured test framework is the difference between a test suite your team trusts and one they ignore. This guide walks you through building a production-ready Playwright framework from zero, covering everything from project setup to CI integration.

By the end, you will have a framework that is maintainable, scalable, and ready to show in interviews. This is not a toy example — it is the structure used by automation engineers at companies like Adobe and Meta.

Why Framework Structure Matters

Most automation projects start clean and degrade into chaos within months. Tests duplicate setup logic. Selectors are scattered across files. A single page redesign breaks fifty tests. The framework structure you choose on day one determines whether your suite scales or collapses.

The patterns in this guide prevent those problems: page objects centralize selectors, fixtures handle setup and teardown, and a clear folder convention makes it obvious where new tests belong.

Step 1: Project Setup

Start with Playwright's built-in initializer:

npm init playwright@latest

Choose TypeScript when prompted. TypeScript catches bugs before you run tests — wrong property names, missing arguments, and type mismatches show up in your editor, not in CI.

Your initial structure:

playwright-framework/
  tests/           # Test files
  playwright.config.ts
  package.json

We will expand this significantly.

Step 2: Design Your Folder Structure

Here is the structure that scales from 10 tests to 1,000:

playwright-framework/
  src/
    pages/              # Page Object classes
      LoginPage.ts
      DashboardPage.ts
      components/       # Shared UI components
        Navbar.ts
        SearchBar.ts
    fixtures/           # Custom test fixtures
      auth.fixture.ts
      index.ts
    helpers/            # Utility functions
      test-data.ts
      api-client.ts
      date-utils.ts
    types/              # TypeScript interfaces
      user.ts
      order.ts
  tests/
    auth/               # Tests grouped by feature
      login.spec.ts
      registration.spec.ts
      password-reset.spec.ts
    dashboard/
      overview.spec.ts
      analytics.spec.ts
    api/                # API tests (no browser needed)
      users.api.spec.ts
      orders.api.spec.ts
  playwright.config.ts
  .github/
    workflows/
      tests.yml         # CI pipeline

Key principles:

  • Source code (src/) is separate from tests (tests/). Page objects, fixtures, and helpers are not tests — they are framework code.
  • Tests are grouped by feature, not by type. All authentication tests live together, whether they test the login form or the login API.
  • Page objects mirror the application structure. One page object per page or major component.

Step 3: Build the Page Object Layer

The page object model is the most important pattern in browser test automation. Each page in your application gets a class that encapsulates its selectors and actions.

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Selector strategy

Use accessibility-first selectors in this order:

  1. getByRole — buttons, links, headings, form controls
  2. getByLabel — form inputs with associated labels
  3. getByText — visible text content
  4. getByTestIddata-testid attributes (last resort)

Avoid CSS selectors tied to layout (.container > div:nth-child(3)). They break when the UI is refactored without changing functionality.

Page object rules

  • Page objects expose actions (login, addToCart, search), not raw element interactions
  • Page objects do not contain assertions — that is the test's job
  • One page object per page or significant component
  • If a component appears on multiple pages (navbar, footer), give it its own class

Step 4: Custom Fixtures

Fixtures handle the setup and teardown that tests need. Instead of repeating login logic in every test, create a fixture that provides an authenticated page:

// src/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type AuthFixtures = {
  loginPage: LoginPage;
  authenticatedPage: DashboardPage;
};

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

  authenticatedPage: async ({ page }, use) => {
    // Use storage state for fast auth (no UI login needed)
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('test@example.com', 'password');
    const dashboard = new DashboardPage(page);
    await use(dashboard);
  },
});

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

Now tests use authenticatedPage directly:

// tests/dashboard/overview.spec.ts
import { test, expect } from '../../src/fixtures';

test('dashboard shows recent activity', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.activityFeed).toBeVisible();
  await expect(authenticatedPage.activityFeed).not.toBeEmpty();
});

This is cleaner, faster, and eliminates duplicated setup code across hundreds of tests.

Step 5: Test Data Management

Hardcoded test data is a maintenance nightmare. Use factory functions:

// src/helpers/test-data.ts
import { randomUUID } from 'crypto';

export function createTestUser(overrides: Partial<TestUser> = {}): TestUser {
  return {
    name: `Test User ${randomUUID().slice(0, 8)}`,
    email: `test-${randomUUID().slice(0, 8)}@example.com`,
    password: 'SecurePass123!',
    ...overrides,
  };
}

export function createTestOrder(overrides: Partial<TestOrder> = {}): TestOrder {
  return {
    productId: 'prod-001',
    quantity: 1,
    ...overrides,
  };
}

Each test generates unique data, so tests never conflict with each other when running in parallel.

Step 6: Add API Testing

Playwright's API testing runs without a browser, making it fast and lightweight. Use it for testing your backend directly:

// tests/api/users.api.spec.ts
import { test, expect } from '@playwright/test';
import { createTestUser } from '../../src/helpers/test-data';

test.describe('Users API', () => {
  test('creates a new user', async ({ request }) => {
    const userData = createTestUser();
    const response = await request.post('/api/users', { data: userData });

    expect(response.status()).toBe(201);
    const body = await response.json();
    expect(body.email).toBe(userData.email);
  });

  test('rejects duplicate email', async ({ request }) => {
    const userData = createTestUser();
    await request.post('/api/users', { data: userData });

    const duplicate = await request.post('/api/users', { data: userData });
    expect(duplicate.status()).toBe(409);
  });
});

For a complete guide on API testing, see our dedicated post.

Step 7: Configuration

A solid playwright.config.ts sets up your framework for success:

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['json', { outputFile: 'test-results/results.json' }],
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'api', use: { baseURL: process.env.API_URL || 'http://localhost:3000' }, testMatch: '**/*.api.spec.ts' },
  ],
});

Key decisions:

  • fullyParallel: true — tests run concurrently for speed
  • retries: 2 in CI — handles intermittent failures without blocking the pipeline
  • trace: 'on-first-retry' — captures trace data only when a test fails and retries, keeping storage costs low
  • Separate API project — API tests run without browser overhead

Step 8: CI Integration

Add a GitHub Actions workflow to run tests on every pull request:

name: Playwright Tests
on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/3, 2/3, 3/3]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --shard=${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: results-${{ matrix.shard }}
          path: test-results/

This shards your tests across 3 machines for faster feedback.

What Makes This Interview-Ready

If you are preparing for automation engineer or SDET interviews, this framework demonstrates:

  • Clean architecture with separation of concerns
  • The page object model applied correctly
  • Custom fixtures for DRY setup
  • Both browser and API testing in one framework
  • CI integration with parallelization
  • TypeScript for type safety

Walk interviewers through your design decisions. Explain why you chose each pattern and what problems it prevents. That is what separates senior candidates from junior ones.

If you want a production-ready framework to learn from and customize, the Playwright Project Pack includes a complete framework with all these patterns implemented, plus advanced features like visual regression testing and multi-environment support. See all resources for Playwright engineers.