Why architecture matters more than the test runner
Playwright makes the mechanics of browser automation easy — auto-waiting, cross-browser support, a built-in test runner. The part teams actually struggle with is everything around the tests: how locators are organized, how test data flows in, how config differs across environments, and how CI reports back what failed and why. A good architecture makes all of that boring and predictable.
Layered structure
- Fixtures layer — custom fixtures extend Playwright's base
testobject to inject page objects, authenticated sessions, and API clients directly into every test, instead of instantiating them by hand in each spec. - Page Object layer — one class per page/component, exposing intent-revealing methods (
login(user), not a raw sequence offill()/click()calls in the test itself). Locators live here, and only here — a UI change should require editing one file, not every test that touches that page. - Data layer — factories/builders for test data rather than hardcoded literals scattered through specs, so a schema change updates one place.
- Config layer —
playwright.config.tsprojects per environment/browser, with secrets and base URLs pulled from environment variables, never committed.
A minimal fixture example
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
type Fixtures = { loginPage: LoginPage };
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
Every spec that needs the login page just declares it as a parameter — no manual setup boilerplate repeated across files.
Locator strategy
Prefer role/label/text-based locators (getByRole, getByLabel) over CSS/XPath wherever the UI exposes proper semantics — they're far more resilient to markup changes and double as a lightweight accessibility check. Fall back to data-testid only when semantics genuinely aren't enough, and treat raw CSS selectors as a last resort, not a default.
Parallelization and isolation
Playwright's BrowserContext is the real isolation boundary — cheap to create, giving each test its own cookies/storage without spinning up a whole new browser. Configure workers based on CI runner resources, and avoid any shared mutable state (a shared test account, a shared database row) between parallel tests — that's the single most common source of "flaky in CI, fine locally."
CI wiring and reporting
Run with the built-in HTML reporter for local debugging and a machine-readable reporter (JUnit/JSON) for CI dashboards. Upload traces and screenshots only on failure (trace: 'retain-on-failure') — capturing them on every run balloons artifact storage for no benefit. Shard large suites across CI jobs (--shard=1/4) rather than running everything serially in one job.
What this buys you
None of this is about a single test running faster — it's about a suite with 500+ tests staying maintainable: a UI change touches one page object, a flaky test is traceable to one isolated cause, and a new team member can read a spec and understand intent without reverse-engineering selectors.