If you want to build Playwright test framework that survives real product change, you need more than a few test files and a couple of locators. The framework is the part that turns browser automation from a demo into a maintainable system, and that means making decisions about structure, fixtures, data, reporting, retries, and CI before the suite gets large enough to punish you for skipping them.

I have seen teams start with a clean Playwright install, add a few tests, and then slowly accumulate pain, duplicated login steps, inconsistent waits, brittle selectors, and CI runs that are impossible to debug. The good news is that Playwright gives you a strong base, especially if you use TypeScript. The bad news is that Playwright is a library, not a complete framework, so you still have to design the architecture yourself.

In this Playwright framework tutorial, I will show the structure I use when I build a Playwright TypeScript framework from scratch, why each piece exists, and where the tradeoffs are. I will also cover when a full framework is worth it, and when a simpler platform like Endtest is the better choice for teams that want browser automation without owning all the framework plumbing.

What a Playwright test framework actually needs

A useful Playwright test architecture usually includes these parts:

  • A test runner configuration
  • Shared fixtures for browser, page, auth state, and test data
  • Page objects or another abstraction for reusable UI flows
  • A clear locator strategy
  • Environment-specific configuration
  • Assertions and helpers that keep tests readable
  • Reporting and artifacts for failures
  • CI integration with deterministic browser setup
  • A maintenance strategy for selectors, test data, and flaky tests

If you leave out any of these, the suite may still run locally, but it will become harder to scale. The goal is not to over-engineer. The goal is to make the suite predictable enough that people trust it.

A framework is not just a folder structure, it is a set of conventions that prevent every new test from becoming a special case.

Start with a sensible project structure

When I build Playwright test framework projects, I like a structure that separates test intent from implementation details.

playwright-project/
  tests/
    auth/
    checkout/
    profile/
  pages/
    login-page.ts
    dashboard-page.ts
    checkout-page.ts
  fixtures/
    test-fixtures.ts
  utils/
    data.ts
    env.ts
    waits.ts
  test-data/
    users.ts
  playwright.config.ts
  global-setup.ts
  package.json
  tsconfig.json

This is not the only good structure, but it works because it answers a simple question: where does each kind of logic belong?

  • tests/ contains scenario-level test cases
  • pages/ contains page objects or reusable screen models
  • fixtures/ contains extended test context
  • utils/ contains helpers that are not tied to one page
  • test-data/ contains stable inputs and test accounts
  • global-setup.ts handles reusable state preparation, when needed

I prefer keeping tests business-focused. A test file should read like a user journey or a system check, not like a DOM script.

Install Playwright and TypeScript cleanly

The official Playwright docs are still the best starting point for installation and runner basics, so I always anchor the setup there first: Playwright documentation.

A minimal setup looks like this:

npm init -y
npm i -D @playwright/test typescript
npx playwright install

A basic package.json script section might be:

{ “scripts”: { “test:e2e”: “playwright test”, “test:e2e:ui”: “playwright test –ui”, “report”: “playwright show-report” } }

Then set up TypeScript with a tsconfig.json that matches your test code style. Keep it simple at first, avoid deep path aliasing until the codebase actually needs it.

Design your Playwright test architecture before writing tests

The biggest architectural decision is how much abstraction to use. Teams often swing between two extremes:

  1. No abstraction, every test directly manipulates locators
  2. Over-abstracted page objects that hide everything and become hard to debug

The right answer is usually in the middle.

I recommend using page objects for stable screens and common flows, but keeping assertions and scenario logic in the test file. A page object should help you interact with the UI, not make the test unreadable.

A good rule is this:

  • Put actions in page objects, for example login(), searchProduct(), addToCart()
  • Put scenario steps in tests, for example logged-in user can checkout with saved address
  • Put repeated cross-cutting setup in fixtures, not in every test

Use fixtures for reusable setup and test context

Playwright fixtures are one of the strongest parts of the framework. They let you extend the base test with shared services, authenticated pages, seeded data, or app-specific helpers.

Here is a simple example of a custom fixture file:

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

type Fixtures = { loginPage: LoginPage; };

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

export { expect };

Then use it in a test:

import { test, expect } from '../fixtures/test-fixtures';
test('user can log in', async ({ loginPage }) => {
  await loginPage.open();
  await loginPage.login('test@example.com', 'secret123');
  await expect(loginPage.successBanner).toBeVisible();
});

Fixtures become especially valuable when you need authenticated contexts. Instead of logging in through the UI in every test, store auth state once and reuse it when appropriate.

That said, do not use fixtures to hide too much. If a fixture silently performs several app steps, debugging failures gets harder. Keep fixture behavior explicit and narrow.

Build page objects that stay small

A page object should represent a screen or a meaningful app region. It should not become a mini-framework of its own.

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

export class LoginPage { readonly email: Locator; readonly password: Locator; readonly submit: Locator; readonly successBanner: 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.successBanner = page.getByText(‘Welcome back’); }

async open() { 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(); } }

This is simple, and that is the point. The moment you start putting expect() everywhere inside page objects, you can make failures harder to locate. I prefer leaving most assertions in the test file, with a few helper assertions when the interaction is truly reusable.

Choose selectors like you expect the UI to change

Selector strategy is one of the most important reasons a suite becomes flaky or stable. The default rule I use is:

  • Prefer accessible locators, like getByRole, getByLabel, and getByText when the text is stable
  • Use data-testid for elements that do not have strong accessible semantics
  • Avoid CSS selectors tied to layout or generated class names
  • Avoid XPath unless you have a very specific reason

Example with stable locators:

typescript

await page.getByRole('button', { name: 'Create account' }).click();
await page.getByLabel('Company name').fill('Acme');
await page.getByTestId('save-profile').click();

This is not just a style choice. Good locators encode intent and reduce how often a visual redesign breaks tests.

Handle auth and environment setup without making tests slow

Login is one of the first things that causes unnecessary runtime. If every test logs in through the UI, your suite gets slower and more fragile.

For authenticated suites, I usually prefer one of these approaches:

  • Use storageState to reuse an authenticated session
  • Seed auth through API calls before the test starts
  • Run a dedicated setup project that prepares reusable state

A setup project example in Playwright config can keep auth preparation separate from the main suite.

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

export default defineConfig({ use: { baseURL: ‘https://app.example.com’, trace: ‘on-first-retry’, screenshot: ‘only-on-failure’, video: ‘retain-on-failure’ }, projects: [ { name: ‘setup’, testMatch: /.*.setup.ts/ }, { name: ‘chromium’, use: { …devices[‘Desktop Chrome’] }, dependencies: [‘setup’] } ] });

This gives you reproducibility and keeps the actual tests focused on behavior, not bootstrapping.

Configure retries, traces, and artifacts intentionally

Playwright gives you excellent debugging tooling, but only if you configure it well. I usually think of three layers:

  1. Local developer feedback, fast and readable
  2. CI debugging, traces and artifacts on failure
  3. Maintenance visibility, reports that tell you what is failing repeatedly

A solid baseline config might look like this:

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

export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 4 : undefined, reporter: [ [‘html’], [‘list’], [‘junit’, { outputFile: ‘results/junit.xml’ }] ] });

Retries are useful, but they are not a cure for flaky tests. If a test needs retries to pass regularly, treat that as a defect in the test or the system.

Retries are for signal recovery, not for hiding instability.

Add reporting that helps both engineers and managers

A framework is easier to trust when the output is useful. At minimum, I want:

  • Pass/fail summary
  • Failure stack trace
  • Screenshot on failure
  • Trace or video for intermittent bugs
  • Machine-readable output for CI, like JUnit XML

For smaller teams, Playwright’s built-in HTML report is often enough at first. As suites grow, many teams add a CI report artifact and a trend view in their pipeline tool.

The important thing is not the report format, it is whether someone can answer these questions quickly:

  • What failed?
  • Where did it fail?
  • Is it a product bug, test bug, or environment issue?
  • What changed since the last green run?

Use test data deliberately

A lot of flaky behavior comes from test data, not locators. If several tests compete over the same account, record, or cart, they will eventually collide.

I recommend separating test data into three categories:

  • Static reference data, such as a country list or fixed product catalog items
  • Ephemeral test data, created during the test and cleaned up after
  • Shared seeded data, created by setup jobs or API helpers and reused carefully

If possible, create data through API calls rather than the UI. UI-based setup is slower and creates unrelated failure points.

A simple API helper can be enough:

export async function createUser(token: string) {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ role: 'tester' })
  });

if (!response.ok) throw new Error(‘Failed to create user’); return response.json(); }

Make CI part of the framework, not an afterthought

A Playwright framework that only works on one laptop is incomplete. The CI job is where the real contract lives.

A GitHub Actions workflow might look like this:

name: e2e
on:
  push:
  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: npm test - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report

This is the minimum I expect. If your app depends on environment variables, secrets, or pre-seeded databases, declare those dependencies clearly in the pipeline. Hidden setup is one of the fastest paths to surprise failures.

For broader background on the practice, the concept of continuous integration is still the right mental model, short feedback loops, small changes, and automated validation on every change.

Flaky test control is framework work

If you build Playwright test framework code without thinking about flakiness, you are really just postponing maintenance. Flaky tests usually come from a few recurring causes:

  • Relying on fixed sleeps instead of expected UI state
  • Using brittle selectors
  • Sharing mutable test data
  • Overusing network-dependent setup
  • Asserting before the page has truly settled
  • Ignoring environment differences between local and CI

Playwright already waits intelligently for many actions, so you should avoid sprinkling waitForTimeout() everywhere. Use explicit waits only when you are synchronizing on a specific state, such as a network request or a toast message.

typescript

await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/checkout') && resp.ok()),
  page.getByRole('button', { name: 'Place order' }).click()
]);

This pattern is much better than sleeping and hoping the request completed.

When to add extra abstractions, and when not to

As your suite grows, you may want helpers for navigation, assertions, or data builders. That is fine, but every abstraction should earn its place.

Good candidates for reuse:

  • Login helpers
  • API data factories
  • Common assertion wrappers
  • App-specific browser utilities, such as cookie consent dismissal

Bad candidates for reuse:

  • Overly generic BasePage classes that every page inherits from
  • Magic wrappers around every Playwright call
  • Helpers that do half the test without revealing the steps

The best Playwright TypeScript framework code stays close to the native Playwright API. The closer it is, the easier it is for engineers to debug, upgrade, and onboard.

A practical decision framework for architecture

Here is the question I ask teams before they invest heavily in framework design:

  • Do we have enough product complexity to justify code ownership?
  • Do we have engineers who are comfortable maintaining TypeScript test code?
  • Do we need tight integration with APIs, databases, or custom tooling?
  • Will multiple people contribute to the automation code over time?
  • Do we want full control over the test stack, including CI and reports?

If the answer is yes to most of those, building a Playwright framework makes sense.

If the answer is no, or if your team mainly wants browser coverage without becoming framework maintainers, a managed platform is often better. That is where a simpler alternative like Endtest can be a strong fit, because it gives teams browser automation with no TypeScript team, no framework to own, and no CI setup to maintain. Endtest’s agentic AI model also matters here, because it can reduce the amount of manual setup required while still producing editable platform-native steps inside the product.

A balanced view on framework ownership

I like Playwright a lot. It is fast, modern, and excellent for teams that want code-first browser automation. But owning a framework is an investment. You are not just writing tests, you are also maintaining conventions, dependencies, CI behavior, and failure analysis.

That ownership is worth it when:

  • Your product logic is complex
  • Your team is strong in code-based automation
  • You need deep control over infrastructure and integrations
  • You want a framework that can evolve with the application

It is less attractive when:

  • The team is small and mostly non-developers
  • You need broad participation from QA, product, or design
  • You want less maintenance overhead
  • You prefer a managed solution over an internal automation platform

For readers comparing the ecosystem more broadly, my related note on Playwright vs Selenium in 2026 covers where each approach still makes sense.

A starter checklist for your first version

If you want a practical first milestone, keep it focused on the essentials:

  • Install Playwright with TypeScript
  • Create a clean folder structure
  • Add a custom fixture file
  • Build 2 to 3 page objects for the core app flows
  • Configure HTML and JUnit reporting
  • Add screenshots and traces on failure
  • Integrate one CI pipeline
  • Prepare one authenticated state strategy
  • Make locator conventions explicit
  • Document how new tests should be written

That is enough to support a real team without turning the project into a science experiment.

Final thoughts

The best way to build Playwright test framework code is to start small, but design as if the suite will be maintained for years. Keep the architecture understandable, keep the abstractions close to the native Playwright model, and treat flakiness as a design problem rather than a retry problem.

If your team is comfortable owning that stack, Playwright is an excellent choice. If you want browser coverage but do not want to maintain a framework, CI, and test infrastructure, a managed platform like Endtest can be the simpler path. The right answer depends less on which tool is more powerful, and more on how much ownership your team actually wants.

Either way, the goal is the same, trustworthy automated checks that help your team ship faster without creating a second product to maintain.