Framework Guide September 22, 2026 20 min read

Build a Playwright Framework from Scratch in 2026

A complete, layer-by-layer guide to building a production-ready Playwright test automation framework. From project structure and Page Object Model to custom fixtures, test data management, reporting, and CI/CD integration.

Framework ROI

A well-architected framework cuts test maintenance by 60% and onboarding time from weeks to days.

Running npx playwright test on a handful of spec files works fine for a small project. But the moment your test suite grows past 50 tests, the cracks start to show. Duplicated locators scattered across files. Login logic copy-pasted into every test. Environment URLs hardcoded in three different places. Test data that collides when you run tests in parallel. A reporting setup that consists of scrolling through terminal output.

A framework solves all of this. It gives your test suite a repeatable architecture that separates concerns, reduces duplication, and scales from 10 tests to 10,000. In this guide, we build one from scratch — layer by layer — using the same patterns that power production test suites at companies running hundreds of Playwright tests per day.

If you are completely new to Playwright, start with our Playwright automation for beginners guide first, then come back here to level up your project structure.


Why You Need a Custom Framework

The default Playwright setup — a playwright.config.ts and a tests/ folder — is intentionally minimal. It gets you running tests fast, but it makes no decisions about how you organize your code at scale. Here is what happens without a framework:

  • Locator duplication: The same getByRole('button', { name: 'Submit' }) appears in 30 test files. When the button text changes, you update 30 files.
  • Authentication overhead: Every test file has its own login setup. A change to the login flow means touching every test.
  • Environment fragility: URLs, credentials, and API endpoints are hardcoded. Switching from staging to production requires a search-and-replace.
  • Data collisions: Tests create users with the same email. Run them in parallel and they fail randomly.
  • Onboarding friction: New team members spend days understanding an unstructured test suite because there are no conventions to follow.

A framework addresses every one of these problems. It establishes conventions, separates concerns into layers, and gives your team a predictable structure that scales. The investment pays for itself within the first sprint.

The 6-Layer Framework Architecture

Production Playwright frameworks share a common architecture. We break it into six layers, each with a single responsibility. Here is the complete project structure:

Project Structure
playwright-framework/
|
|-- playwright.config.ts        # Layer 1: Config
|-- .env.staging                # Environment variables
|-- .env.production             # Environment variables
|
|-- src/
|   |-- pages/                    # Layer 2: Page Objects
|   |   |-- BasePage.ts
|   |   |-- LoginPage.ts
|   |   |-- DashboardPage.ts
|   |   |-- CheckoutPage.ts
|   |
|   |-- fixtures/                 # Layer 3: Custom Fixtures
|   |   |-- auth.fixture.ts
|   |   |-- api.fixture.ts
|   |   |-- test-data.fixture.ts
|   |   |-- index.ts           # Merges all fixtures
|   |
|   |-- data/                     # Layer 4: Test Data
|   |   |-- factories/
|   |   |-- staging.json
|   |   |-- production.json
|   |
|   |-- utils/                    # Shared helpers
|       |-- api-client.ts
|       |-- date-helpers.ts
|
|-- tests/                        # Layer 5: Tests
|   |-- auth/
|   |   |-- login.spec.ts
|   |   |-- registration.spec.ts
|   |-- checkout/
|   |   |-- cart.spec.ts
|   |   |-- payment.spec.ts
|   |-- dashboard/
|       |-- widgets.spec.ts
|
|-- reports/                      # Layer 6: Reporting
|-- .github/workflows/
    |-- playwright.yml

Each layer depends only on the layer below it. Tests import fixtures. Fixtures import page objects. Page objects import the base page. Config sits at the foundation. This dependency direction is what makes the framework maintainable — you can change a page object without touching a fixture, and change a fixture without touching a test.

Let us build each layer, starting from the bottom.

Layer 1: Project Setup and Config

The configuration layer is the foundation of your framework. A well-designed playwright.config.ts handles multi-browser testing, environment switching, and sensible defaults — so individual test files never need to worry about these concerns.

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';

// Load environment-specific variables
const env = process.env.TEST_ENV || 'staging';
dotenv.config({ path: `.env.${env}` });

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: 'reports/results.json' }],
    process.env.CI ? ['github'] : ['list'],
  ],

  use: {
    baseURL: process.env.BASE_URL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    actionTimeout: 15_000,
    navigationTimeout: 30_000,
  },

  projects: [
    { name: 'setup', testMatch: '**/*.setup.ts' },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
      dependencies: ['setup'],
    },
  ],
});

Key decisions in this config:

  • Environment switching: The TEST_ENV variable loads the right .env file, so TEST_ENV=production npx playwright test runs against production without changing any code.
  • Setup project: The setup project runs authentication flows once before all browser projects, saving time on repeated logins.
  • Conditional reporters: HTML and JSON reporters always run; GitHub annotations appear only in CI; the list reporter shows in local development.
  • Failure artifacts: Traces, screenshots, and videos are captured only on failure — keeping storage lean while preserving debugging context when you need it.

Tip: Keep your .env files out of version control. Add .env.* to .gitignore and document the required variables in a .env.example file. For CI/CD, inject these values as pipeline secrets.

Layer 2: Page Object Model

The Page Object Model (POM) is the most impactful pattern in test automation. It encapsulates every locator and action for a page into a single class, so when the UI changes, you update one file — not fifty tests.

Start with a base page class that every page object extends:

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

export abstract class BasePage {
  constructor(protected page: Page) {}

  // Common navigation
  async navigate(path: string) {
    await this.page.goto(path);
    await this.page.waitForLoadState('domcontentloaded');
  }

  // Common waits
  async waitForPageReady() {
    await this.page.waitForLoadState('networkidle');
  }

  // Reusable toast/notification check
  getToast(): Locator {
    return this.page.getByRole('alert');
  }

  // Screenshot helper for debugging
  async takeScreenshot(name: string) {
    await this.page.screenshot({ path: `reports/screenshots/${name}.png` });
  }
}

Then build individual page objects that extend the base:

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

export class LoginPage extends BasePage {
  // Locators — defined once, used everywhere
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

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

  // Actions — encapsulate multi-step interactions
  async login(email: string, password: string) {
    await this.navigate('/login');
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  // Assertions — page-specific expectations
  async expectLoginError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

Notice the pattern: locators are declared as readonly properties in the constructor, and multi-step interactions are wrapped in named methods. This gives tests a clean, readable API: loginPage.login(email, password) instead of three separate fill-and-click steps.

Composition over deep inheritance

Keep your inheritance tree shallow — one base class, then page classes. If multiple pages share a navigation bar or a sidebar, extract those into component classes and compose them:

  • NavComponent.ts — handles top nav interactions
  • SidebarComponent.ts — handles sidebar navigation
  • DashboardPage.ts — composes NavComponent + SidebarComponent + its own locators

This avoids the brittle "diamond inheritance" problem and keeps each class focused on a single piece of the UI. For the full POM pattern with advanced examples, see our Playwright Page Object Model tutorial.

Layer 3: Custom Fixtures

Custom fixtures are the secret weapon of a well-architected Playwright framework. They let you inject pre-configured objects — authenticated pages, API clients, test data — into your tests without any setup boilerplate.

src/fixtures/index.ts — merging all fixtures
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { ApiClient } from '../utils/api-client';

// Define the shape of your custom fixtures
type FrameworkFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  apiClient: ApiClient;
  authenticatedPage: Page;
};

export const test = base.extend<FrameworkFixtures>({
  // Page object fixtures — auto-instantiated per test
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  // API client for backend setup/teardown
  apiClient: async ({}, use) => {
    const client = new ApiClient(process.env.API_URL!);
    await client.authenticate(
      process.env.API_USER!,
      process.env.API_PASS!
    );
    await use(client);
    // Teardown: cleanup created resources
    await client.cleanup();
  },

  // Pre-authenticated browser context
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'auth/user.json',
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

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

Now your tests import from this fixtures file instead of from @playwright/test directly. The result is tests that are dramatically simpler:

  • Authentication fixture: Uses saved storageState to skip the login flow. Tests start already logged in.
  • API fixture: Sets up backend data before the test and cleans it up after, with automatic teardown.
  • Page object fixtures: Instantiate page objects automatically — no manual new LoginPage(page) in every test file.

For a deep dive into fixtures, hooks, and worker-scoped fixtures, see our Playwright fixtures and hooks guide.

Why fixtures over beforeEach? Fixtures are lazy — they only run when a test actually requests them. A beforeEach runs for every test regardless. Fixtures also handle teardown automatically through the use callback pattern, which is less error-prone than afterEach. And fixtures compose — you can build complex setups by combining simple fixtures.

Layer 4: Test Data Management

Test data management is the layer most teams neglect — and the one that causes the most flaky tests. When two parallel tests create a user with the same email, or a test expects specific data that another test deleted, you get failures that have nothing to do with application bugs.

The solution is a combination of three strategies:

1. Factory functions with dynamic data

Use factory functions that generate unique data for every test run. Libraries like @faker-js/faker make this straightforward:

  • Generate unique emails: `test-${faker.string.uuid()}@example.com`
  • Generate unique usernames: faker.internet.username()
  • Generate realistic but random addresses, phone numbers, and product names

2. Environment-specific static data

Some tests need specific data that exists in the environment — admin credentials, known product IDs, API keys. Store these in environment-specific JSON files:

  • src/data/staging.json — staging-specific test accounts and IDs
  • src/data/production.json — production read-only test accounts

Load them based on the TEST_ENV variable, the same one your config uses.

3. Cleanup routines

Every test that creates data should clean it up. Use fixture teardown (the code after await use()) or API calls in afterEach to delete created users, orders, and records. This prevents data accumulation and cross-test pollution.

Warning: Never hardcode test data directly in test files. When a test account's password expires or a product ID changes, you want to update one data file — not search through 100 spec files. Centralizing test data is one of the highest-leverage improvements you can make.

Layer 5: Writing Scalable Tests

With the foundation layers in place, your test files become remarkably clean. Here is what a test looks like when it uses your framework's fixtures, page objects, and test data:

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

test.describe('Payment flow', { tag: ['@checkout', '@critical'] }, () => {

  test('completes credit card payment',
    async ({ authenticatedPage, dashboardPage, apiClient }) => {

    // Arrange: create a product via API
    const product = await apiClient.createProduct({
      name: 'Test Widget',
      price: 29.99,
    });

    // Act: add to cart and complete checkout
    await dashboardPage.navigateToProduct(product.id);
    await dashboardPage.addToCart();
    await dashboardPage.goToCheckout();
    await dashboardPage.fillPayment({
      card: '4242424242424242',
      expiry: '12/28',
      cvc: '123',
    });
    await dashboardPage.submitOrder();

    // Assert: order confirmation appears
    await expect(dashboardPage.confirmationBanner)
      .toContainText('Order confirmed');
    await expect(dashboardPage.orderTotal)
      .toContainText('$29.99');
  });

  test('shows error for declined card',
    { tag: '@negative' },
    async ({ authenticatedPage, dashboardPage }) => {

    // Arrange & Act
    await dashboardPage.goToCheckout();
    await dashboardPage.fillPayment({
      card: '4000000000000002', // Decline card
      expiry: '12/28',
      cvc: '123',
    });
    await dashboardPage.submitOrder();

    // Assert
    await expect(dashboardPage.paymentError)
      .toContainText('Card was declined');
  });
});

Key patterns to follow in your test layer:

  1. Arrange-Act-Assert: Every test follows this structure. Setup, action, verification — clearly separated with comments for readability.
  2. Tagging: Use tag to categorize tests. Run critical path tests with npx playwright test --grep @critical. Run the full suite nightly.
  3. Describe blocks: Group related tests in test.describe. Apply shared tags at the describe level.
  4. Feature folders: Organize test files by feature (tests/auth/, tests/checkout/, tests/dashboard/) — not by type. This mirrors how your team thinks about the product.
  5. No page object instantiation: Tests receive page objects through fixtures. No new LoginPage(page) in test files.

For more patterns including parallel test design and retry strategies, see our Playwright best practices for 2026 guide.

Layer 6: Reporting and CI/CD Integration

A framework is not complete until it produces actionable reports and runs automatically in your pipeline. This layer ties everything together.

Reporting

Playwright's built-in HTML reporter is excellent for local development. For team-wide visibility, add Allure or a custom reporter:

  • HTML Reporter: Ships with Playwright. Generates an interactive report with traces, screenshots, and video for every failed test.
  • Allure Reporter: Richer dashboards with historical trends, categories, and flaky test tracking. Install with npm i -D allure-playwright.
  • JSON Reporter: Machine-readable output for custom dashboards or Slack notifications.

For a full comparison of reporting options, see our Playwright test reporting tools guide.

GitHub Actions CI/CD

Here is a production-grade GitHub Actions workflow with parallel sharding, artifact uploads, and Slack notifications:

.github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }}
        env:
          TEST_ENV: staging
          BASE_URL: ${{ secrets.STAGING_URL }}
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: report-${{ strategy.job-index }}
          path: playwright-report/
          retention-days: 14

  notify:
    needs: test
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Playwright tests failed on ${{ github.ref }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

This workflow shards tests across 4 parallel runners, cutting execution time by 75%. Each shard uploads its report as an artifact. If any shard fails, the notify job sends a Slack message to your team. For the complete CI/CD setup, see our Playwright GitHub Actions CI/CD tutorial.

Accelerate with Claude AI

Building a framework from scratch is powerful, but it takes time. Claude AI with the Playwright MCP server can accelerate the process dramatically. Instead of manually inspecting every page to write page objects, you can let Claude do the heavy lifting.

Here is what the AI-accelerated workflow looks like:

  1. Generate page objects: Tell Claude Code to "navigate to the checkout page and create a Page Object Model class with all interactive elements." Claude opens the page, reads the DOM, and generates a typed POM class with accurate locators.
  2. Generate fixtures: Ask Claude to "create an authentication fixture that saves storageState after login." Claude generates the fixture code following Playwright's official patterns.
  3. Generate tests from acceptance criteria: Paste your user stories and ask Claude to generate test files. Claude creates tests that follow the Arrange-Act-Assert pattern and use your framework's page objects.
  4. Review and refine: Claude's output is 80-90% production-ready. Review the generated code, adjust locators if needed, and commit.

The MCP server is the key — it lets Claude see your actual application, not just guess at it. Locators are derived from the real DOM, which means they work on the first run. For the complete Claude Code workflow, see our Playwright + Claude Code tutorial.

Pro tip: Use Claude Code to generate the boilerplate layers (page objects, fixtures, data factories) and write the test logic yourself. This gives you the best of both worlds — AI speed for the repetitive structural code and human judgment for the actual test scenarios.


Ready to build your framework?

Hands-on Udemy course: build a production Playwright framework from scratch, then accelerate it with Claude AI. From zero to enterprise-grade automation.

Enroll on Udemy →

Frequently Asked Questions

What's the best project structure for Playwright?

The best Playwright project structure uses a 6-layer architecture: config (playwright.config.ts with multi-project and environment switching), page objects (base page class plus individual page classes), custom fixtures (authentication, API setup, test data), test data management (factories, environment-specific data), tests (organized by feature with tagging), and reporting (HTML reporter, Allure, CI/CD integration). This separation of concerns makes the framework maintainable, scalable, and easy for new team members to understand.

Should I use Page Object Model with Playwright?

Yes. Page Object Model (POM) is the recommended pattern for production Playwright frameworks. It encapsulates page-specific locators and actions into reusable classes, reducing duplication and making tests resilient to UI changes. When a locator changes, you update it in one place — the page object — instead of across dozens of test files. Playwright's official documentation endorses POM as a best practice.

How do I manage test data across environments?

Use environment-specific data files (e.g., test-data/staging.json, test-data/production.json) loaded based on a TEST_ENV environment variable. Combine this with factory functions that generate dynamic data using libraries like Faker.js, and implement cleanup routines that run after each test or test suite to prevent data pollution. For sensitive credentials, use .env files or CI/CD secrets — never hardcode them in test files.

Can I generate framework code with AI?

Yes. Tools like Claude Code with the Playwright MCP server can generate page object classes, custom fixtures, and complete test files by inspecting your live application. Claude navigates your app through a real browser, reads the DOM structure, and produces framework code with accurate selectors and proper typing. This can accelerate framework development by 5-10x compared to writing everything by hand.

How many layers should a Playwright framework have?

A production Playwright framework typically has 6 layers: configuration, page objects, custom fixtures, test data management, test files, and reporting/CI integration. Smaller projects can start with 3-4 layers (config, page objects, tests, reporting) and add fixture and test data layers as complexity grows. The key principle is separation of concerns — each layer has a single responsibility, making the framework easier to maintain and extend.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn

Playwright + Claude AI Course

Build Your Framework with AI — 10x Faster

The course walks you through building a complete production framework, then shows how to use Claude AI to generate page objects and tests automatically.

  • Build a 6-layer Playwright framework architecture from scratch
  • Implement Page Object Model, custom fixtures, and test data factories
  • Use Claude AI + MCP Server to generate page objects from your live app
  • Deploy your framework to GitHub Actions with parallel sharding and Slack alerts
Build Your Framework on Udemy →