A test framework does not become maintainable because it has many folders, abstractions, or design patterns. It becomes maintainable when another person can quickly answer three questions: What is this test proving? Where should I change it? Why did it fail?

This guide starts with the smallest useful Playwright project and adds structure only when it solves a real problem. The examples use TypeScript because types can catch incorrect assumptions before a browser test runs, while Playwright provides the runner, browser control, assertions, isolation, reporting, and debugging tools.

The central idea

Begin with readable tests. Extract reusable code after repetition appears. A simple framework that the team understands is better than an impressive architecture nobody can safely change.

01 Β· FoundationWhat does β€œmaintainable” actually mean?

A maintainable automation suite should remain useful as the application changes. That normally requires four qualities:

01 Readable

The test describes business behaviour instead of implementation details.

02 Reliable

Tests wait for meaningful conditions and do not depend on arbitrary delays.

03 Isolated

One test can run alone without relying on another test's data or state.

04 Diagnosable

Failures include enough evidence to distinguish a product defect from a test problem.

Playwright already gives us isolation, auto-waiting locators, web-first assertions, traces, screenshots, and reports. Our framework should expose those strengths, not hide them behind unnecessary custom code.

02 Β· SetupCreate your Playwright project

Install a current Node.js LTS release first. Then open a terminal in the folder where you want the project and run:

mkdir playwright-starter
cd playwright-starter
npm init playwright@latest

Choose TypeScript when prompted. Keep the suggested tests directory. You may also allow the installer to add a GitHub Actions workflow and install Playwright browsers.

Run the example test and open the report:

npx playwright test
npx playwright show-report
Do not start by deleting everything

Run the generated project once before restructuring it. A passing baseline confirms that Node.js, Playwright, browsers, and your operating system dependencies are working together.

03 Β· StructureUse the smallest useful folder structure

Beginners often create folders for helpers, services, constants, factories, models, utilities, components, and fixtures before writing one test. That creates decisions without solving problems.

Start with this:

playwright-starter/
β”œβ”€β”€ tests/
β”‚   └── auth/
β”‚       └── login.spec.ts
β”œβ”€β”€ pages/
β”‚   └── login.page.ts
β”œβ”€β”€ test-data/
β”‚   └── users.ts
β”œβ”€β”€ playwright.config.ts
β”œβ”€β”€ package.json
└── tsconfig.json
  • tests/ contains behaviour-focused test files.
  • pages/ contains reusable interactions after they become useful.
  • test-data/ contains clearly named non-secret data.
  • playwright.config.ts defines shared execution behaviour.

Add a new folder only when at least two pieces of code need the same responsibility. The structure should explain the project, not decorate it.

04 Β· First testWrite one readable test before adding abstractions

Assume the application has a login page and a dashboard. With a baseURL configured later in this guide, a first test can look like this:

import { test, expect } from '@playwright/test';

test('registered user can sign in', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('beginner@example.com');
  await page.getByLabel('Password').fill('safe-demo-password');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(
    page.getByRole('heading', { name: 'Dashboard' })
  ).toBeVisible();
});

The test has a visible flow:

  1. Arrange: open the page and prepare the user input.
  2. Act: submit the sign-in form.
  3. Assert: verify an outcome that matters to the user.

Notice what is missing: no manual sleep, no CSS chain, no shared global page, and no dependency on another test. That simplicity is a strong starting point.

05 Β· StabilityChoose locators that survive UI changes

Locators are one of the biggest differences between stable and fragile browser tests. Playwright recommends prioritising user-facing attributes and explicit testing contracts.

PreferExampleWhy
RolegetByRole('button', { name: 'Save' })Matches how users and assistive technologies understand the control.
LabelgetByLabel('Email')Connects a form field to its visible label.
TextgetByText('Order confirmed')Useful for visible messages and content.
Test IDgetByTestId('product-row')Provides an explicit contract when user-facing locators are not enough.
Avoid.form > div:nth-child(2) > buttonBreaks when layout or styling changes.

Use Playwright's web-first assertions such as toBeVisible(), toHaveText(), and toHaveURL(). They retry until the expected condition is met or the assertion timeout expires.

await expect(page.getByText('Order confirmed')).toBeVisible();
await expect(page).toHaveURL(/orders\/\d+/);
await expect(page.getByTestId('order-status')).toHaveText('Paid');
Avoid fixed waits

waitForTimeout(3000) always waits three seconds when the page is fast and may still fail when the page is slower. Wait for the actual UI or network condition you care about.

06 Β· DefaultsConfigure behaviour once

The configuration file prevents every test from making its own decisions about URLs, retries, reports, and failure evidence.

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['list'], ['html', { open: 'never' }]],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

This is intentionally modest. Begin with Chromium while learning. Add Firefox and WebKit when cross-browser coverage is a real requirement:

{
  name: 'firefox',
  use: { ...devices['Desktop Firefox'] },
},
{
  name: 'webkit',
  use: { ...devices['Desktop Safari'] },
}

Keep TypeScript strictness enabled in tsconfig.json. Strict checking catches accidental any values and possible null or undefined cases earlier.

07 Β· ReuseAdd page objects when repetition appears

A page object can group the locators and actions for one page or meaningful component. It should make tests easier to read, not move every line into another file.

import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly signInButton: Locator;

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

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

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

The test now reads at a business level while keeping the assertion visible:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../../pages/login.page';

test('registered user can sign in', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.open();
  await loginPage.signIn(
    'beginner@example.com',
    'safe-demo-password'
  );

  await expect(
    page.getByRole('heading', { name: 'Dashboard' })
  ).toBeVisible();
});
A useful extraction rule

Keep an interaction in the test the first time. Consider extracting it when the same meaningful flow appears again or when the test becomes difficult to understand.

08 Β· DataKeep test data explicit and typed

Named test data is easier to understand than unexplained strings repeated across tests.

export type TestUser = {
  email: string;
  password: string;
};

export const standardUser: TestUser = {
  email: 'beginner@example.com',
  password: 'safe-demo-password',
};

Do not commit real credentials. Use CI secrets or environment variables for sensitive values. Keep data generation close to the test until multiple tests genuinely need a shared factory.

09 Β· WorkflowAdd commands the team can remember

Useful scripts turn project conventions into short, repeatable commands. Merge these into the scripts section of package.json:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:debug": "playwright test --debug",
    "report:e2e": "playwright show-report"
  }
}

10 Β· AutomationRun the same tests in CI

A test suite becomes much more valuable when it runs automatically for pull requests or deployments. This minimal GitHub Actions workflow installs exact dependencies, installs browsers and Linux dependencies, runs the suite, and preserves the HTML report.

name: Playwright tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run tests
        run: npm run test:e2e

      - name: Upload HTML report
        if: always()
        uses: actions/upload-artifact@v5
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

The example configuration uses one worker in CI for stability. Once the suite is reliable and runtime becomes a problem, measure first and then consider parallel workers or sharding.

11 Β· InvestigationUse evidence instead of rerunning blindly

When a test fails, identify whether the application, environment, data, or test caused the failure. These commands provide different views:

# Run with the interactive UI
npm run test:e2e:ui

# Watch the browser
npm run test:e2e:headed

# Pause and step through the test
npm run test:e2e:debug

# Open the latest HTML report
npm run report:e2e

For CI failures, download the report and open the trace attached to the retry. Inspect the action timeline, DOM snapshots, console messages, network activity, and screenshots around the failure. The goal is not merely to make the test green; it is to explain what happened.

12 Β· GuardrailsCommon beginner mistakes

  • Automating every scenario: start with high-value, repeatable user journeys.
  • Using long CSS or XPath selectors: prefer roles, labels, text, and test IDs.
  • Adding fixed waits: assert the actual condition the user depends on.
  • Sharing state between tests: make each test independently runnable.
  • Creating a giant page object: represent a page or meaningful component, not the whole product.
  • Hiding every assertion in helpers: keep the test's purpose visible.
  • Adding retries to mask failures: retries provide evidence; they do not repair flaky design.
  • Running all browsers immediately: stabilise the flow in one browser, then expand intentionally.

13 Β· ReviewYour beginner framework checklist

  • Every test name describes a user-visible behaviour.
  • Tests use roles, labels, visible text, or explicit test IDs.
  • Assertions use Playwright's retrying web matchers.
  • No test requires another test to run first.
  • Repeated page interactions are extracted only when useful.
  • Sensitive credentials come from environment variables or CI secrets.
  • The configuration captures traces and screenshots for failures.
  • The same command works locally and in CI.
  • A new teammate can identify where tests, pages, and data belong.
Your next step

Choose one small flow in an application you are allowed to test. Automate it directly, make the assertions meaningful, run it repeatedly, and only then extract the repeated parts. Maintainability grows from feedback, not prediction.

Official references