A Playwright framework is the reusable architecture around Playwright Test: configuration, fixtures, domain objects, environment contracts, test data, reports, and CI rules that let a team add reliable tests without rebuilding the harness in every spec. The goal is not the largest folder tree. It is a small, typed public API with clear ownership and safe defaults.
This guide builds that architecture from scratch with TypeScript. The reference was compiled and executed with @playwright/test 1.62.1, strict TypeScript, and Chromium. It covers the decisions that matter after the first demo: what belongs in configuration, when to create a fixture, how page objects differ from components, how to isolate data in parallel runs, and which artifacts CI should retain.
If you are still choosing the tool, start with the Playwright automation guide. For TypeScript basics and the runner workflow, use the Playwright TypeScript guide. This article assumes you can already run one test and focuses on production framework design.
Version note — reviewed 31 August 2026: npm lists
@playwright/test1.62.1 as the stable release used by the validated reference. Pin the package and lockfile together, install the matching browsers in CI, and review the official release notes before upgrading.
What Makes a Playwright Framework Production-Ready?
A framework is production-ready when a new test inherits the correct isolation, environment, evidence, and cleanup policy by importing the approved test fixture. Reliability should come from architecture, not from every author remembering a checklist.
| Concern | Framework contract | Failure it prevents |
|---|---|---|
| Configuration | Typed, validated, one entry point | Tests silently targeting the wrong host |
| Isolation | Fresh context and test-scoped state by default | Order-dependent passes and failures |
| Domain API | Small page and component objects | Selectors and workflows copied across specs |
| Data | Unique ownership per test or worker | Parallel tests editing the same record |
| Evidence | Useful report plus failure-scoped artifacts | Green reruns with no explanation for the first failure |
| Safety | Secrets external; production writes blocked | Credential leaks and destructive runs |
| Governance | Named owners and review rules | A shared framework becoming an unowned dumping ground |
Playwright already supplies a capable runner, auto-waiting, isolated browser contexts, assertions, projects, retries, and reporters. Your framework should compose those primitives around product-specific needs. Replacing them with custom abstractions usually creates more maintenance than value.
Start With One-Way Dependency Flow
The most important architecture rule is dependency direction. Specs may depend on fixtures, fixtures may compose domain objects and data builders, and domain objects may depend on Playwright primitives. The lower layers never import specs.

This direction makes change predictable. A redesigned cart can change one component object. A new staging URL can change one environment map. A reporter migration can change configuration without touching business specs.
Use a Project Structure That Communicates Ownership
There is no mandatory Playwright project structure. This production-oriented layout is deliberately small:
playwright.config.ts
package.json
tsconfig.json
playwright/
.auth/ # ignored; generated authentication state
src/
config/
environment.ts
fixtures/
app.fixture.ts # approved import for specs
pages/
checkout.page.ts
components/
cart-summary.component.ts
data/
checkout-scenario.ts
api/
orders.client.ts
utils/
dates.ts
tests/
checkout/
submit-order.spec.ts
| Folder | Put here | Keep out |
|---|---|---|
config |
Environment parsing, target URLs, safety gates | Selectors and business workflows |
fixtures |
Lifecycle, composition, setup, teardown | Large collections of unrelated helper functions |
pages |
Page-level navigation and user actions | Cross-product test orchestration |
components |
Reusable regions such as headers, drawers, grids, and dialogs | Entire application journeys |
data |
Typed builders and static domain examples | Passwords or shared mutable accounts |
api |
Typed setup and cleanup clients | Browser assertions |
utils |
Small pure utilities with a clear domain name | A generic helper grab bag |
tests |
Business scenarios and outcome assertions | Low-level framework mechanics |
Organize specs by product capability, not by the page object they happen to call. Checkout, billing, access control, and search are meaningful ownership boundaries. “Pages” is an implementation detail, not a product taxonomy.
Step 1: Pin a Minimal Toolchain
Start with Playwright Test and TypeScript. Add libraries only when a real requirement appears. A framework with six assertion packages, three environment loaders, and overlapping report adapters is harder to upgrade than the product tests it supports.
{
"name": "company-e2e",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "playwright test",
"test:smoke": "playwright test --grep @smoke"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@types/node": "24.3.0",
"typescript": "5.9.2"
}
}
Commit the lockfile. In CI, use the package manager’s frozen or immutable install mode and install the Playwright browser revision after dependencies. For platform-specific installation and proxy failures, use the dedicated Playwright installation guide instead of expanding the framework layer.
Step 2: Build a Typed Environment Contract
A base URL is not an environment strategy. An environment contract should name approved targets, validate overrides, expose safety capabilities, and fail before test discovery when configuration is invalid.
const targets = ['local', 'staging', 'production'] as const;
type Target = (typeof targets)[number];
interface TestEnvironment {
target: Target;
baseURL: string;
allowWrites: boolean;
}
const urls: Record<Target, string> = {
local: 'http://127.0.0.1:3000/',
staging: 'https://staging.example.test/',
production: 'https://example.test/'
};
export function loadEnvironment(
source: NodeJS.ProcessEnv = process.env
): TestEnvironment {
const candidate = source.E2E_ENV ?? 'local';
if (!targets.includes(candidate as Target)) {
throw new Error('E2E_ENV must be local, staging, or production.');
}
const target = candidate as Target;
const parsed = new URL(source.E2E_BASE_URL ?? urls[target]);
const allowWrites = source.E2E_ALLOW_WRITES === 'true';
if (target !== 'local' && parsed.protocol !== 'https:') {
throw new Error('Remote test environments must use HTTPS.');
}
if (target === 'production' && allowWrites) {
throw new Error('Write scenarios are blocked in production.');
}
return { target, baseURL: parsed.toString(), allowWrites };
}
Keep credentials outside this object. CI should inject secrets from its protected secret store. A local .env may improve developer ergonomics, but the real contract is the validated environment variable set—not a committed file.
Step 3: Make Configuration an Execution Policy
Playwright runner options such as retries, workers, and reporters belong at the top level. Browser-context options such as baseURL, tracing, and screenshots belong under use. Keeping that distinction visible makes reviews much easier.
import { defineConfig, devices } from '@playwright/test';
import { loadEnvironment } from './src/config/environment.js';
const environment = loadEnvironment();
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
timeout: 30_000,
expect: { timeout: 5_000 },
outputDir: 'test-results/artifacts',
reporter: [
['list'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
['junit', { outputFile: 'test-results/junit.xml' }]
],
use: {
baseURL: environment.baseURL,
testIdAttribute: 'data-testid',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
});
This is a baseline, not universal truth. One CI worker is the conservative default recommended by the official CI guide; a measured self-hosted runner can use more. Likewise, two retries can contain transient CI noise while preserving a trace, but a retry must not become the accepted fix for a flaky test.
Do Not Multiply Every Dimension
A common mistake is creating every browser × environment × role combination as a project. Three browsers, three environments, and four personas become 36 executions before mobile devices enter the matrix.
Use separate jobs for deployment targets, then keep Playwright projects for configurations that benefit from runner-level isolation and reporting. A practical matrix often looks like this:
- pull request: Chromium, staging, critical suite;
- nightly: Chromium, Firefox, and WebKit, staging, broader suite;
- release: risk-selected smoke checks against a production-like target;
- production: read-only synthetic checks unless an explicit safe-write contract exists.
Step 4: Expose One Typed Fixture API
Fixtures are the composition root of a Playwright framework. They should give each test exactly what it needs, perform symmetric teardown, and remain small enough that authors can predict the lifecycle.
import { test as base } from '@playwright/test';
import {
buildCheckoutScenario,
type CheckoutScenario
} from '../data/checkout-scenario.js';
import { CheckoutPage } from '../pages/checkout.page.js';
interface AppFixtures {
scenario: CheckoutScenario;
checkout: CheckoutPage;
}
export const test = base.extend<AppFixtures>({
scenario: async ({}, use, testInfo) => {
await use(buildCheckoutScenario(testInfo.testId));
},
checkout: async ({ page, scenario }, use) => {
await page.goto('/checkout?case=' + encodeURIComponent(scenario.email));
await use(new CheckoutPage(page));
}
});
export { expect } from '@playwright/test';
Specs import test and expect from this module, not directly from @playwright/test. That single choice gives the framework an enforceable public boundary.
import { expect, test } from '../../src/fixtures/app.fixture.js';
test('submits an isolated checkout scenario', async ({
checkout,
scenario
}) => {
await expect(checkout.cart.itemName).toHaveText(scenario.itemName);
await checkout.submitOrder(scenario);
await expect(checkout.status).toHaveText('Order submitted');
});
The test reads as business intent. It does not know how the environment was selected, how its data became unique, or where the cart selector lives.
Choose Fixture Scope Deliberately
| Scope | Good fit | Main risk |
|---|---|---|
| Test | Pages, contexts, scenarios, temporary records | Repeated setup cost |
| Worker | Expensive account allocation or seeded tenant that one worker can own | State leakage across tests in that worker |
| Automatic | Small universal diagnostics or invariant gates | Invisible work on every test |
Default to test scope. Move a resource to worker scope only when you can state its concurrency, reset, and cleanup contract. The official fixtures guide documents composable, on-demand, test-scoped, and worker-scoped fixtures.
Use hooks for local behavior within one spec group. Use fixtures when a capability must be reusable, typed, and lifecycle-aware across files. Avoid a 300-line beforeEach that signs in, seeds data, changes feature flags, opens three pages, and hides all of those dependencies from the test signature.
Step 5: Separate Pages From Reusable Components
A page object represents page-level behavior. A component object represents a reusable region that can appear on one or many pages. This split prevents every page class from implementing another copy of the header, modal, table, or cart drawer.
import type { Locator, Page } from '@playwright/test';
import { CartSummary } from '../components/cart-summary.component.js';
import type { CheckoutScenario } from '../data/checkout-scenario.js';
export class CheckoutPage {
readonly email: Locator;
readonly submit: Locator;
readonly status: Locator;
readonly cart: CartSummary;
constructor(page: Page) {
this.email = page.getByLabel('Email');
this.submit = page.getByRole('button', { name: 'Place order' });
this.status = page.getByRole('status');
this.cart = new CartSummary(page.getByTestId('cart-summary'));
}
async submitOrder(scenario: CheckoutScenario): Promise<void> {
await this.email.fill(scenario.email);
await this.submit.click();
}
}
import type { Locator } from '@playwright/test';
export class CartSummary {
readonly itemName: Locator;
readonly total: Locator;
constructor(root: Locator) {
this.itemName = root.getByTestId('cart-item-name');
this.total = root.getByTestId('cart-total');
}
}
Keep assertions in specs when they express the scenario outcome. A page method can assert an invariant required to continue—for example, that a modal opened after clicking—but hiding every expectation inside objects makes failures harder to interpret and specs less expressive.
Prefer role, label, text, and deliberate test-id contracts over CSS tied to layout. The complete Playwright locators guide owns chaining, filtering, strictness, and selector anti-patterns in depth.
Step 6: Treat Test Data as an Owned Resource
Browser-context isolation protects cookies and storage. It does not isolate the database, message queue, email inbox, payment sandbox, or third-party account. Parallel tests still collide if they edit the same server-side record.
Generate a unique scenario from a stable test identity:
export interface CheckoutScenario {
email: string;
itemName: string;
quantity: number;
}
export function buildCheckoutScenario(
testId: string
): CheckoutScenario {
const suffix = testId
.replace(/[^a-z0-9]+/gi, '-')
.slice(-24)
.toLowerCase();
return {
email: 'qa+' + suffix + '@example.test',
itemName: 'Quality Engineering Handbook',
quantity: 1
};
}
Use an API client or direct test-support service to create the record, pass the owned identifiers to the browser flow, and delete only records created by that test. For a deeper organization-wide model—synthetic data, masking, refreshes, environments, and governance—follow the test data management guide.
Static, Dynamic, or External?
| Data type | Use it for | Control |
|---|---|---|
| Static typed object | Read-only examples, countries, product tiers | Version with code |
| Builder or factory | Unique users, orders, workspaces | Derive IDs and cleanup ownership |
| Worker allocation | Expensive accounts or tenants | One owner per parallel index |
| External secret | Credentials, tokens, signing keys | Inject from a protected store |
Do not put mutable test objects in a module-level variable. Playwright workers are separate processes, workers restart after failures, and tests must not depend on execution order. Use testInfo.testId, testInfo.outputPath(), or parallelIndex for collision-resistant ownership.
Step 7: Handle Authentication Without Leaking Credentials
Authentication state can make suites faster, but it is sensitive. Playwright warns that a storage-state file may contain cookies and headers that can impersonate the test account. Put generated state under playwright/.auth, add the directory to .gitignore, and never attach it to a public report.
Use a setup project when many independent tests can safely share one account:
projects: [
{
name: 'auth-setup',
testMatch: /.*\.setup\.ts/
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json'
},
dependencies: ['auth-setup']
}
]
Do not share one account when tests modify its server-side settings, basket, permissions, or other mutable state. Allocate an account per worker or create one per test instead. The official authentication guide explains both strategies and the security warning.
Step 8: Keep Utilities Small and Boring
A utils folder is useful only when each module has one recognizable responsibility. Good utilities are pure date formatting, deterministic identifiers, typed polling around an external system, or safe redaction. Bad utilities click UI elements, own global state, swallow errors, or accept any so they can be reused everywhere.
Use this extraction order:
- If the code expresses a product action, place it in a page or component object.
- If it creates or deletes product state, place it in a typed API or data client.
- If it manages test lifecycle, make it a fixture.
- If it is pure and domain-neutral, make it a narrowly named utility.
- If none fits, leave it close to the spec until the right abstraction becomes clear.
Early duplication is cheaper than the wrong shared abstraction. Extract after two or three examples reveal the stable concept, not after the first repeated line.
Step 9: Design Reports for Two Audiences
Humans need a navigable report with steps, attachments, and traces. CI needs machine-readable results that can annotate the build and track failures. Configure both.
| Reporter or artifact | Best use | Retention rule |
|---|---|---|
| List | Fast local terminal feedback | Console log only |
| HTML | Human investigation and attachments | Upload for failed or diagnostically important jobs |
| JUnit | CI test summary and historical tooling | Publish every CI run |
| Blob | Merging reports from shards | Keep until merged report is produced |
| Trace | Timeline, DOM snapshots, network, console, actions | First retry or failed tests |
| Screenshot/video | Quick visual context | Failure-scoped; avoid permanent bulk storage |
The official reporters guide documents list, HTML, JSON, JUnit, and blob outputs. Blob is especially useful when shards run on separate jobs and a final job merges their results into one HTML report.
Use traces as the primary Playwright failure artifact. They capture the action timeline, DOM snapshots, network activity, and console messages. Playwright recommends trace: 'on-first-retry' for CI rather than recording every test, which is expensive.
Step 10: Make CI Reproducible
A Playwright job should perform the same phases in the same order: restore a trusted dependency cache, install the locked dependencies, install matching browser binaries and OS dependencies, type-check, run tests, publish machine results, and retain bounded failure evidence.
name: e2e
on:
pull_request:
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm run typecheck
- run: pnpm exec playwright test --project=chromium
env:
CI: true
E2E_ENV: staging
E2E_BASE_URL: <injected by CI>
E2E_USER: <injected by CI>
E2E_PASSWORD: <injected by CI>
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
Use versions approved by your organization and keep action references under dependency review. The example installs only Chromium for the pull-request job. Add Firefox, WebKit, or shards in separate jobs when risk and duration data justify them.
Retries Are Evidence, Not a Repair
Playwright classifies a test that fails first and passes on retry as flaky. Track that state. A team that looks only at the final green build silently converts instability into longer feedback.
- Use zero retries locally so failures remain immediate.
- Use a small bounded retry count in CI to capture a trace and distinguish transient behavior.
- Quarantine only with an owner, issue, expiry date, and visible coverage gap.
- Measure flaky rate, p95 duration, retry cost, and failure category by project.
Do not add arbitrary waitForTimeout calls. Playwright locators and assertions auto-wait for actionable or expected states. When the application has no observable UI signal, wait for a named response, event, or product state—not elapsed time.
Projects, Tags, and Shards Solve Different Problems
| Mechanism | Use it to | Avoid using it to |
|---|---|---|
| Project | Change browser, device, auth state, timeout, or configuration | Represent every business feature |
| Tag or grep | Select smoke, regression, accessibility, or ownership slices | Create order dependencies |
| Shard | Distribute independent tests across machines | Hide one slow or stateful test group |
| Serial group | Handle a rare, genuinely indivisible sequence | Patch shared data and poor isolation |
The projects guide describes browsers, environments, setup dependencies, and custom options. The parallelism guide explicitly discourages serial suites in favor of independent tests.
Maintain the Framework as a Product
A framework is shared production code. Give it owners, a change policy, compatibility expectations, and health metrics. Otherwise every team adds a local exception until no one knows which behavior is intentional.
Define Review Ownership
- Configuration owners review changes to projects, retries, timeouts, environments, and reporters.
- Domain owners review page/component APIs and business assertions.
- Platform or CI owners review browser images, caches, secrets, sharding, and artifact retention.
- Security owners review storage state, production access, redaction, and third-party integrations.
Add a concise architecture decision record when changing a public fixture, test-data contract, or execution matrix. A useful decision record states context, choice, alternatives rejected, migration, and rollback.
Set Measurable Health Budgets
Track signals that expose engineering cost:
- flake rate before retries and after retries;
- median and p95 duration by project and tag;
- setup versus test-body time;
- artifact volume and storage retention;
- quarantined tests by owner and age;
- failures caused by product, test, data, environment, or infrastructure;
- time from Playwright release to approved upgrade.
A five-minute smoke suite that occasionally takes 18 minutes needs investigation even when it remains green. A nightly suite with a 4% first-attempt failure rate is not healthy just because retries produce a pass.
Playwright Framework Anti-Patterns
| Anti-pattern | Why it fails | Better rule |
|---|---|---|
| One base page with every helper | Unrelated dependencies and a constantly changing public API | Small page and component objects composed by fixtures |
| Selectors in specs and utilities | UI change requires repository-wide edits | Keep durable selector ownership in domain objects |
| One shared account | Parallel tests overwrite server-side state | Read-only shared state or per-worker/test ownership |
| Blanket retries | Hides instability and lengthens feedback | Bounded retry plus trace and flake ownership |
| Arbitrary sleeps | Tune for yesterday’s timing and waste time when fast | Wait for observable UI, response, or domain state |
| Every browser on every commit | High cost without risk prioritization | Fast PR matrix plus broader scheduled coverage |
| Committed storage state | Leaks impersonation-capable credentials | Generate ignored state from protected secrets |
| Tests importing internals | Every refactor becomes a suite-wide migration | One approved typed fixture entry point |
| Global mutable variables | Worker restarts and concurrency make state unpredictable | Fixture-scoped resources and explicit ownership |
| Catch and continue | Turns broken preconditions into misleading later failures | Fail at the first meaningful invariant |
How to Refactor an Existing Playwright Suite
Do not pause feature work for a framework rewrite. Migrate through vertical slices:
- Choose one valuable, flaky, or frequently edited flow.
- Create the environment contract and one approved fixture import.
- Move selectors into one page object and shared regions into components.
- Replace shared data with an owned builder and cleanup path.
- Turn on strict type checking, failure-scoped traces, HTML, and JUnit output.
- Measure the slice for several CI runs.
- Migrate the next capability only after the public API remains stable.
Delete obsolete helpers as each slice moves. Keeping old and new APIs indefinitely creates two frameworks, two review standards, and no real migration.
Production Readiness Checklist
- Package and browser versions are pinned and upgraded together.
- Strict TypeScript and
noUncheckedIndexedAccesspass in CI. - Configuration validates the named environment before discovery.
- Production writes fail closed.
- Specs import one extended
testobject. - Test scope is the fixture default; worker scope has an ownership contract.
- Page objects and components use resilient user-facing locators.
- Dynamic records are unique per test or worker and cleaned safely.
- Authentication state and secrets are ignored and externally injected.
- Local and CI reports serve human and machine consumers.
- Traces and media are failure-scoped with bounded retention.
test.onlyfails CI.- Retries, quarantine, duration, and flakes have visible owners.
- Pull-request and scheduled browser matrices reflect product risk.
If these fundamentals are new, work through the Playwright tutorial first, then return to this architecture. For the broader sequence of QA engineering skills, use the QA roadmap.
Use AI as a Reviewer, Not the Framework Owner
An AI coding assistant can spot duplicated selectors, unsafe fixture scope, missing cleanup, and dependency inversions. Give it a constrained review task and verify every recommendation against your application and the current Playwright documentation.
Reusable review prompt: Review this Playwright TypeScript change for dependency direction, fixture scope, browser-context and server-side data isolation, locator resilience, secret handling, production-write safety, assertion clarity, retry misuse, teardown symmetry, and CI artifact cost. Classify findings as blocker, major, or minor. Cite the exact file and line, explain the failure mode under parallel CI, and propose the smallest fix. Do not invent selectors, credentials, APIs, or Playwright options. Mark claims that require current documentation or application verification.
Never paste production cookies, storage-state files, user data, access tokens, or private traces into an external model. Redact evidence and follow the organization’s approved AI and data-handling policy.
Frequently Asked Questions
What is the best Playwright framework structure?
The best structure has one typed configuration, one approved fixture entry point, domain-oriented specs, small page/component objects, typed data builders, and explicit API clients for setup and cleanup. Folder names matter less than one-way dependencies and clear ownership.
Should every Playwright test use a page object?
No. Use a page or component object when it creates a stable product-shaped API or centralizes selectors used across scenarios. A short, unique interaction can remain in the spec. Do not wrap every Playwright call merely to hide Playwright.
Should fixtures replace beforeEach?
Use fixtures for reusable, typed, lifecycle-aware capabilities across files. Use beforeEach for local setup shared by tests in one describe block. If a hook hides major dependencies or teardown, a fixture usually communicates the contract better.
How should environments be configured?
Select a named target with validated environment variables, keep approved URLs in one map, inject secrets externally, and fail closed for production writes. Use Playwright projects only when runner-level separation improves reporting or behavior; do not multiply every browser, environment, and persona automatically.
How many retries should a Playwright framework use?
Use zero locally and a small, measured CI value—often one or two—to capture diagnostic evidence. Track tests that pass on retry as flaky. More retries are not a stability strategy.
Is Page Object Model mandatory in Playwright?
No. Playwright supports page objects, but it does not require them. Use POM where it improves authoring and selector maintenance. Component objects and direct locators are often better for smaller, reusable, or one-off interactions.
Where should Playwright assertions live?
Keep scenario outcomes in specs so business intent remains visible. A page or fixture may assert a required invariant during setup or an action, but avoid hiding all expectations inside framework methods.
How do I make Playwright tests safe in parallel?
Keep tests independent, use isolated contexts, allocate unique server-side records or accounts per test or worker, write artifacts through testInfo.outputPath(), and remove order dependencies. Serial mode should be rare and justified.
Build the Smallest Framework That Enforces the Right Defaults
A maintainable Playwright automation framework is not a library of clever wrappers. It is a set of enforceable contracts: configuration fails early, fixtures expose a small typed API, pages and components model the product, data has one owner, secrets stay external, and CI retains evidence proportional to failure risk.
Start with one reliable vertical slice. Compile it under strict TypeScript, run it in Chromium, inspect its HTML and JUnit reports, force a safe failure to verify the trace, then let a second product team add a test without learning hidden setup. If that experience is predictable, the framework is doing its job.
