Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Playwright Setup & Core Architecture

Every reliable Playwright suite rests on the same three-object model: a Browser launches an engine process, a BrowserContext carves out an isolated profile inside it, and a Page drives a single tab. Understanding how those objects nest — and how the test runner, config file, and fixtures wire them together — is what separates a suite that passes once from one that passes ten thousand times in parallel on CI. This guide maps the whole stack: installing browsers, declaring projects in playwright.config.ts, isolating state with contexts, running the same specs across Chromium, Firefox, and WebKit, structuring code with page objects, and shipping it all through a pipeline. Each technique area links to a focused guide where you can go deeper.

Playwright object and tooling architecture A layered diagram showing the test runner reading config and fixtures, which launch a Browser containing isolated BrowserContexts, each owning Page objects, all feeding a CI pipeline. Test runner playwright.config.ts fixtures + projects Browser (engine process) BrowserContext A Page Page BrowserContext B Page CI/CD pipeline (sharded runners)
The runner reads config and fixtures to launch a Browser; isolated contexts each own pages, and sharded CI runners replay the whole tree in parallel.

Installing Playwright and provisioning browsers

A new project starts with the official initializer, which scaffolds a TypeScript config, an example spec, and — if you opt in — a GitHub Actions workflow. It then downloads the three bundled engines so your local matrix matches CI. The wizard is interactive, so run it once per repository and commit the generated files.

import { execSync } from 'node:child_process';

// Scaffold config, example tests, and an optional CI workflow.
execSync('npm init playwright@latest', { stdio: 'inherit' });
// Download Chromium, Firefox, and WebKit plus their OS-level dependencies.
execSync('npx playwright install --with-deps', { stdio: 'inherit' });

After installation, run the example suite once to confirm every engine launches without permission errors. Pin the @playwright/test version in package.json so a future npm update cannot silently change browser builds underneath your assertions — version drift is one of the most common sources of "it passed yesterday" failures. Treat the browser binaries as a build dependency: the same npx playwright install command runs in your Dockerfile and your CI job so local and remote runs use identical engine revisions.

The directory layout the initializer produces is worth understanding because the rest of the architecture references it. playwright.config.ts sits at the root and is loaded automatically. tests/ (or whatever testDir you set) holds the specs. A playwright-report/ directory receives the HTML report after a run, and test-results/ collects traces, screenshots, and video. Add the last two to .gitignore — they are run artifacts, not source. Keeping config at the root and tests in a dedicated directory is what lets the runner discover and shard specs without per-file registration.

The Browser, BrowserContext, and Page model

Playwright exposes three nested objects, and most architecture decisions reduce to choosing the right one. A Browser is an expensive, long-lived engine process. A BrowserContext is a cheap, fully isolated profile inside that process — its own cookies, localStorage, IndexedDB, permissions, and cache. A Page is a single tab inside a context. Because contexts are cheap and pages are cheaper, you almost never relaunch the browser between tests; instead the runner hands each test a fresh context, guaranteeing a clean slate without the cost of a new process.

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

const browser = await chromium.launch();               // one engine process
const contextA = await browser.newContext();           // isolated profile A
const contextB = await browser.newContext();           // isolated profile B — no shared state
const pageA = await contextA.newPage();                // a tab inside A
await pageA.goto('https://example.com');
await contextA.close();                                // releasing A leaves B untouched
await contextB.close();
await browser.close();

This model is the reason Playwright parallelizes cleanly. Two tests in two contexts cannot see each other's storage even though they share one engine. Get the boundaries right and the rest of the architecture — fixtures, projects, sharding — composes on top. The full treatment of these boundaries, including authenticated storageState injection and concurrency limits, lives in Browser Contexts & Isolation.

Why isolation is the foundation

A test that mutates global state and a test that reads it will pass or fail depending on execution order, and parallel runners make that order nondeterministic. Context isolation removes the shared surface entirely. Provision one context per test, inject any pre-authenticated session through storageState, and let the runner tear it down after the last assertion. When a long-running scraping or end-to-end job needs many simultaneous sessions, you cap concurrency so memory stays bounded — the practical recipe is in How to Configure Multiple Browser Contexts in Playwright. Because cookies and storage never cross the context boundary, isolation also underpins safe data extraction behind login walls, a pattern that recurs throughout the Reliable Selector Strategies for Playwright guide where stable selectors matter most against authenticated, dynamic UIs.

Configuration and fixtures: the wiring layer

playwright.config.ts is the single source of truth for how tests run: where they live (testDir), how long they may take (timeout), how many times the runner retries a failure (retries), how many workers run concurrently, and which artifacts — traces, video, screenshots — to capture. Declaring this centrally means the same suite behaves identically on a laptop and on a CI runner, with environment-specific values injected through process.env rather than hardcoded.

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,                                  // per-test ceiling
  fullyParallel: true,                              // run files concurrently
  retries: process.env.CI ? 2 : 0,                  // absorb transient flakiness on CI only
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',                        // forensic data only when needed
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Fixtures are the second half of the wiring. Instead of beforeEach and afterAll hooks, Playwright uses dependency injection: you extend the base test object with named factories, and the runner builds exactly the fixtures a test asks for, in dependency order, then tears them down in reverse. A fixture that opens an authenticated page, for example, owns the entire lifecycle of that page's context.

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

// A test-scoped fixture: fresh, isolated context per test.
export const test = base.extend<{ authedPage: Page }>({
  authedPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'auth.json' });
    const page = await context.newPage();
    await use(page);            // hand the page to the test body
    await context.close();      // guaranteed teardown, even on failure
  },
});

Worker-scoped fixtures persist across the files a single worker runs, which is where you put expensive one-time setup like seeding a database or minting a token. Test-scoped fixtures rebuild per spec for clean isolation. Choosing the right scope — and chaining fixtures so a dashboardPage depends on an apiClient — is the core skill covered in Playwright Config & Fixtures, with parallel-safe global setup detailed in Setting Up Global Fixtures for Parallel Tests.

The fixture build-and-teardown contract

What makes fixtures reliable is the guarantee around the use() call. Everything before use() is setup; use() hands the value to the test and blocks until the test finishes; everything after use() is teardown that runs unconditionally, including when the test throws. That contract is why a context opened in a fixture is always closed, why a seeded record is always cleaned up, and why you never write try/finally by hand. Fixtures also compose: declare one fixture as a dependency of another by naming it in the arguments, and the runner orders construction and reverses teardown for you. This dependency graph — not a flat list of hooks — is the mental model that scales from a single authenticated page to a multi-resource setup involving a seeded database, an API client, and several page objects.

Cross-browser execution as configuration, not code

A project in the config is a named execution profile. By declaring one project per engine, you run the identical spec files three times — once each on Chromium, Firefox, and WebKit — without touching a line of test code. The runner multiplies your specs by your projects, and --project=firefox filters to a single engine when you are debugging an engine-specific failure.

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

export default defineConfig({
  fullyParallel: true,
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});

The engines are not byte-for-byte identical: WebKit applies stricter Content Security Policy defaults, Firefox times synthetic input differently during rapid DOM mutation, and Chromium handles shadow DOM traversal aggressively. The fix is almost never branching test logic; it is using auto-retrying locators and explicit waitFor() calls so timing differences resolve themselves. Where genuine engine quirks remain, the comparison and debugging workflow lives in Cross-Browser Execution and the engine-by-engine breakdown in Running Chromium vs Firefox vs WebKit in Playwright.

Structuring code with the Page Object Model

As a suite grows, inlining selectors into every spec creates a maintenance trap: a single UI rename forces edits across dozens of files. The Page Object Model fixes this by encapsulating each screen's locators and actions inside a class that receives the Page through its constructor. Tests then read as intent — loginPage.submitCredentials(...) — while selector details live in one place.

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

export class LoginPage {
  readonly page: Page;
  readonly username: Locator;
  readonly submit: Locator;

  constructor(page: Page) {
    this.page = page;
    // Prefer role- and label-based locators so the model survives UI refactors.
    this.username = page.getByRole('textbox', { name: /username/i });
    this.submit = page.getByRole('button', { name: 'Sign In' });
  }

  async submitCredentials(user: string, pass: string): Promise<void> {
    await this.username.fill(user);
    await this.page.getByLabel('Password').fill(pass);
    await this.submit.click();
    await this.page.waitForURL('**/dashboard');   // assert navigation, never sleep
  }
}

Organize page objects by feature domain rather than raw URL so components compose across workflows, and lean on Playwright's strict locator mode to surface ambiguous matches during development. Directory topology, component reuse, and scaling patterns for large repositories are documented in Page Object Model Design and its deep dive, Structuring Large Projects with the Page Object Model.

Running it all in CI/CD

The final layer is the pipeline. CI is where parallelism pays off and where flakiness hurts most, so the config you wrote earlier — retries on CI only, traces on first retry, video retained on failure — exists precisely for this stage. Sharding splits the spec set across multiple runners with --shard=1/4, cutting wall-clock time roughly linearly, while each runner uses the same npx playwright install browsers as your laptop so results are reproducible.

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

export default defineConfig({
  // Capture forensic artifacts only when a test actually fails on CI.
  use: {
    trace: 'on-first-retry',
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  // Default the reporter to HTML locally and a CI-friendly format in the pipeline.
  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'html',
});

Containerizing the runner pins the OS, fonts, and engine revisions so a green local run stays green remotely. The full pipeline workflow — caching browsers, fanning specs across runners with sharding, and Dockerizing for headless execution — is covered in CI/CD Integration. When a pipeline run does fail, the trace and video artifacts feed directly into the Debugging & Test Observability workflow, where the Trace Viewer reconstructs the exact failing timeline.

Reporters and artifacts: the observability surface

The reporter is the runner's output layer, and choosing it deliberately matters as much as choosing workers. Locally, the html reporter gives an interactive report with embedded traces; in the pipeline, a machine-readable format such as github (which annotates the PR directly), junit (which CI dashboards ingest), or json (which custom tooling parses) turns raw pass/fail data into something a team acts on. You can run several reporters at once. The artifacts the config captures — trace zips, failure screenshots, retained video — are what make a failed CI run reproducible without re-running it, and they are uploaded as build artifacts so any engineer can download and replay them. This observability surface is the bridge between the architecture you build and the day-to-day work of keeping it green.

Maintaining the suite over time

A suite is a living system, and the architecture includes the habits that keep it healthy. Pin the Playwright version and bump it deliberately, reading the release notes for engine behavior changes. Track historical pass rates so a slowly rising flake rate is visible before it becomes a crisis, and route genuinely unstable specs to a quarantine project rather than letting them erode trust in the whole suite. Audit locators periodically for the brittle patterns — hashed class names, positional indexes — that page objects are meant to keep out. Watch per-shard duration so an imbalanced split can be rebalanced before it dominates pipeline time. None of this is glamorous, but it is the difference between a suite that scales for years and one that is rewritten every six months because no one trusts it.

Auto-waiting: the property that makes all of this reliable

Underneath every layer is one behavior that makes Playwright deterministic where older tools were flaky: auto-waiting. When you call an action like click() or fill() on a locator, Playwright does not fire immediately. It first checks a set of actionability conditions — the element is attached to the DOM, visible, stable (not animating), receives events (not obscured by an overlay), and enabled — and retries until they all hold or the timeout elapses. The same retry logic backs web-first assertions: expect(locator).toBeVisible() polls until the element appears rather than asserting once against a snapshot.

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

test('auto-waiting removes manual sleeps', async ({ page }) => {
  await page.goto('/dashboard');
  // No waitForTimeout: click() retries until the button is actionable.
  await page.getByRole('button', { name: 'Load report' }).click();
  // The assertion polls until the row appears, absorbing async fetch latency.
  await expect(page.getByRole('row', { name: /Q3 revenue/ })).toBeVisible();
});

This is why the recurring rule across every guide here is "never call waitForTimeout." A fixed sleep is either too short — and flaky — or too long — and slow. Auto-waiting replaces both with a condition that resolves the instant the app is ready. The engines differ in exactly how fast they reach that ready state, which is precisely why auto-retrying locators, rather than per-engine branching, are what keep the cross-browser matrix green.

Where each layer's failures show up

The architecture also predicts where a problem will surface, which shortens debugging. An installation or version-pin problem fails at launch — browsers will not start, or behave differently than CI. A fixture-scope mistake shows up as a slow suite or as state bleeding between tests. A missing context boundary shows up as order-dependent flakiness that only appears under parallel execution. A brittle selector inside a page object shows up as a sudden wave of failures after an unrelated UI change. A pipeline problem shows up as "green locally, red on CI." Mapping a symptom back to its layer is half the work, and the artifacts captured by the config layer — traces, video, screenshots — give you the evidence to confirm the diagnosis in the Debugging & Test Observability workflow.

How the pieces fit together

Read top to bottom, the architecture is a single dependency chain. Installation provisions the engines. Config and fixtures decide how the runner builds and tears down the object tree. Contexts enforce isolation so parallelism is safe. Projects fan that tree across engines. Page objects keep the test code maintainable as it grows. Auto-waiting makes every interaction in that tree resolve on a real condition rather than a guess. CI replays everything across sharded runners and captures artifacts when something breaks. Master each layer in its own guide, and the suite scales from one spec to thousands without the flakiness that sinks brittle test code.

Frequently Asked Questions

What is the difference between a Browser, a BrowserContext, and a Page?

A Browser is a single, expensive engine process. A BrowserContext is a cheap, fully isolated profile inside it with its own cookies and storage. A Page is one tab inside a context. The runner gives each test a fresh context so tests stay isolated without relaunching the browser.

Do I need separate test files for each browser engine?

No. Declare one project per engine in playwright.config.ts and the runner replays the same spec files across Chromium, Firefox, and WebKit. Use --project=<name> to filter to a single engine when debugging.

Should I use Page Object Model from the start of a project?

For anything beyond a handful of specs, yes. Encapsulating selectors and actions in page object classes means a UI change is a one-file edit instead of a sweep across every spec, which is the difference between a maintainable suite and a brittle one.

Back to overview