---
name: playwright-automation
description: >-
Production-grade Playwright in TypeScript: Page Object Model, fixtures, auto-waiting,
user-facing locators, parallel execution, CI integration, visual testing, accessibility.
Includes explicit "do not" list for AI agents and 2025-2026 feature awareness.
Use when: "Playwright," "browser testing," "E2E test," "end-to-end," "page object."
Related: visual-testing, ci-cd-integration, api-testing, test-reliability, accessibility-testing.
license: MIT
metadata:
author: kindlmann
version: "1.0"
category: automation
---
How an expert agent writes stable, maintainable, production-grade Playwright tests in TypeScript.
## Discovery Questions
Before generating any code, ask:
1. **TypeScript or JavaScript?** TypeScript is strongly recommended. It catches locator and assertion mistakes at compile time, and every example in this skill assumes TypeScript.
2. **Which browsers?** Chromium for local dev. Add Firefox and WebKit in CI. Mobile viewports are separate Playwright projects, not separate test files.
3. **Existing suite or fresh start?** If migrating from Cypress or Selenium, start by rewriting the flakiest tests first. Do not attempt a big-bang rewrite.
4. **Single site or multi-site?** Multi-site architectures need shared fixtures and per-site config objects. See `references/multi-site-architecture.md`.
---
## Core Principles
1. **User-facing locators first.** `getByRole` > `getByLabel` > `getByTestId` > CSS (last resort). Locators must reflect what the user sees, not how the DOM is structured. See `references/selector-strategies.md`.
2. **Auto-waiting -- NEVER use `waitForTimeout`.** Every Playwright action and web-first assertion auto-waits. If you think you need a timeout, you need a better locator or assertion.
3. **Test isolation.** Each test gets a fresh `BrowserContext`. Tests must never depend on other tests' state or execution order.
4. **Parallel by default, serial only when necessary.** Use `fullyParallel: true` in config. Reserve `test.describe.serial` for flows that genuinely cannot be isolated (rare).
5. **Fixtures for setup, not hooks.** Fixtures compose, provide type safety, and automatically tear down. Prefer them over `beforeEach`/`afterEach` for anything non-trivial. See `references/fixtures-and-projects.md`.
> **Calibrate to your team maturity** (set `team_maturity` in `.agents/qa-project-context.md`):
> - **startup** — Chromium only, 5–10 critical path tests, basic CI run on PR. Skip sharding and visual testing until the suite is stable.
> - **growing** — Multi-browser (Chromium + Firefox), POM structure, parallel execution, CI with sharding, HTML report artifacts.
> - **established** — Full browser matrix, auth fixtures, API mocking layer, visual regression baseline, trace-on-failure, flakiness tracking.
---
## Common AI Agent Mistakes
**Do not generate code that matches any of these patterns.**
### 1. Never use `waitForTimeout()` as synchronization
**Why it is wrong:** Arbitrary waits are slow on fast machines and flaky on slow ones. They hide the real condition you are waiting for.
```typescript
// BAD
await page.waitForTimeout(2000);
await page.click('#submit');
// GOOD
await page.getByRole('button', { name: 'Submit' }).click(); // auto-waits
```
### 2. Never default to CSS/XPath when `getByRole`/`getByLabel`/`getByTestId` work
**Why it is wrong:** CSS selectors encode DOM structure, break on refactors, and do not communicate test intent.
```typescript
// BAD
await page.locator('.btn-primary.submit-form').click();
// GOOD
await page.getByRole('button', { name: 'Submit' }).click();
```
See the full decision tree in `references/selector-strategies.md`.
### 3. Never use discouraged `page.*` APIs when locator APIs exist
**Why it is wrong:** `page.click()`, `page.fill()`, `page.type()` are legacy convenience methods that bypass the locator auto-waiting pipeline and cannot be chained or filtered.
```typescript
// BAD
await page.click('#email');
await page.fill('#email', 'user@example.com');
// GOOD
await page.getByLabel('Email').fill('user@example.com');
```
### 4. Never use `force: true` without documented justification
**Why it is wrong:** `force: true` skips actionability checks (visible, enabled, stable, receives events). Either the wrong element is targeted, or there is an accessibility bug.
```typescript
// BAD
await page.getByRole('button', { name: 'Save' }).click({ force: true });
// GOOD -- dismiss any overlay first
await page.getByRole('button', { name: 'Dismiss' }).click();
await page.getByRole('button', { name: 'Save' }).click();
```
### 5. Never share mutable state between tests
**Why it is wrong:** Tests run in parallel. Shared module-level variables create race conditions and order-dependent failures. Use fixtures instead. See `references/anti-patterns.md`.
### 6. Never put login boilerplate in every test -- use `storageState`
**Why it is wrong:** UI login for every test is slow and fragile. `storageState` logs in once and replays cookies/localStorage for all tests. See `references/auth-patterns.md`.
### 7. Never use `locator.all()` on dynamic collections without a stability check
**Why it is wrong:** `locator.all()` returns a snapshot. If the DOM is still updating, you get a partial or empty array. It does not auto-retry.
```typescript
// BAD
const items = await page.getByRole('listitem').all();
expect(items.length).toBe(5); // may be 0 if DOM is still rendering
// GOOD
await expect(page.getByRole('listitem')).toHaveCount(5);
const items = await page.getByRole('listitem').all(); // then iterate if needed
```
### 8. Never assert with `allTextContents()` when `toHaveText()` gives retryability
**Why it is wrong:** `allTextContents()` is a snapshot that does not retry. `toHaveText()` retries until the condition is met or timeout expires.
```typescript
// BAD
const texts = await page.getByRole('listitem').allTextContents();
expect(texts).toEqual(['Apple', 'Banana', 'Cherry']);
// GOOD
await expect(page.getByRole('listitem')).toHaveText(['Apple', 'Banana', 'Cherry']);
```
### 9. Never test external dependencies you do not control
**Why it is wrong:** Third-party services have their own uptime, rate limits, and UI changes. Tests that hit real external services are flaky by definition.
```typescript
// BAD -- hitting real Stripe checkout
await page.goto('https://checkout.stripe.com/...');
// GOOD -- mock the external integration
await page.route('**/api/create-checkout-session', async (route) => {
await route.fulfill({ json: { sessionId: 'mock_session', url: '/success' } });
});
```
### 10. Never leave `test.only` in committed code
**Why it is wrong:** A single `test.only` silently skips every other test in the suite. In CI, you run one test and think everything passes.
```typescript
export default defineConfig({ forbidOnly: !!process.env.CI });
```
---
## Project Structure
```
project-root/
├── playwright.config.ts
├── e2e/
│ ├── fixtures/ # base.fixture.ts, auth.fixture.ts, data.fixture.ts
│ ├── pages/ # Page objects organized by feature
│ │ ├── base.page.ts
│ │ ├── dashboard.page.ts
│ │ └── components/ # Reusable component objects
│ │ ├── data-table.component.ts
│ │ └── modal.component.ts
│ ├── tests/ # Test files organized by feature
│ │ ├── auth/
│ │ ├── dashboard/
│ │ └── settings/
│ ├── helpers/ # test-data.ts, api-client.ts
│ └── global-setup.ts
├── .auth/ # Git-ignored storageState files
└── test-results/ # Git-ignored artifacts
```
### playwright.config.ts
```typescript
import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? '50%' : undefined,
reporter: isCI
? [['html', { open: 'never' }], ['github'], ['json', { outputFile: 'test-results/results.json' }]]
: [['html', { open: 'on-failure' }]],
use: {
baseURL,
trace: isCI ? 'on-first-retry' : 'retain-on-failure',
screenshot: 'only-on-failure',
video: isCI ? 'on-first-retry' : 'off',
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'setup', testMatch: /global-setup\.ts/, teardown: 'teardown' },
{ name: 'teardown', testMatch: /global-teardown\.ts/ },
{ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' }, dependencies: ['setup'] },
{ name: 'firefox', use: { ...devices['Desktop Firefox'], storageState: '.auth/user.json' }, dependencies: ['setup'] },
{ name: 'webkit', use: { ...devices['Desktop Safari'], storageState: '.auth/user.json' }, dependencies: ['setup'] },
],
webServer: isCI ? undefined : {
command: 'npm run dev', url: baseURL, reuseExistingServer: true, timeout: 120_000,
},
});
```
### Global Setup
```typescript
import { test as setup, expect } from '@playwright/test';
setup('authenticate as default user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/.*dashboard/);
await page.context().storageState({ path: '.auth/user.json' });
});
```
---
## Page Object Model
### Base Page
```typescript
import { type Page, type Locator, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
abstract readonly path: string;
async goto(): Promise {
await this.page.goto(this.path);
await this.waitForReady();
}
async waitForReady(): Promise {
await this.page.waitForLoadState('domcontentloaded');
}
}
```
### Component Objects
Component objects represent reusable UI fragments (modals, data tables, nav bars). They take a root `Locator`, not a `Page`.
```typescript
export class DataTable {
readonly rows: Locator;
constructor(private readonly root: Locator) {
this.rows = root.getByRole('row');
}
getRowByText(text: string | RegExp): Locator {
return this.rows.filter({ hasText: text });
}
}
```
### Fixture-Based Injection
Inject page objects via fixtures, not constructors in test files.
```typescript
export const test = base.extend<{ dashboardPage: DashboardPage }>({
dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); },
});
export { expect } from '@playwright/test';
```
### Composition Over Inheritance
Compose component objects rather than inherit from deep class hierarchies.
```typescript
export class UsersPage extends BasePage {
readonly path = '/admin/users';
readonly table: DataTable;
constructor(page: Page) {
super(page);
this.table = new DataTable(page.getByRole('table', { name: 'Users' }));
}
}
```
---
## Test Patterns
### Authentication (storageState reuse)
Global setup logs in once and saves `storageState`. All test projects load it via config. For multi-role auth (admin/user/guest), see `references/auth-patterns.md`.
### Form Interactions with test.step
Wrap logical action groups in `test.step()` for better trace viewer output.
```typescript
test('submits a multi-step form', async ({ page }) => {
await page.goto('/onboarding');
await test.step('fill personal info', async () => {
await page.getByLabel('First name').fill('Jane');
await page.getByRole('button', { name: 'Next' }).click();
});
await test.step('submit', async () => {
await page.getByRole('button', { name: 'Complete setup' }).click();
});
await expect(page).toHaveURL('/dashboard');
});
```
### API Mocking
```typescript
// Mock a response
await page.route('**/api/products*', async (route) => {
await route.fulfill({ json: { items: [{ id: '1', name: 'Widget', price: 29.99 }] } });
});
// Modify a real response
await page.route('**/api/feature-flags', async (route) => {
const response = await route.fetch();
const body = await response.json();
body.flags['new-checkout'] = true;
await route.fulfill({ response, json: body });
});
// Simulate errors
await page.route('**/api/products*', (route) => route.fulfill({ status: 500 }));
// WebSocket (v1.49+)
await page.routeWebSocket('**/ws/notifications', (ws) => {
ws.onMessage((msg) => { ws.send(JSON.stringify({ type: 'alert', title: 'Deployed' })); });
});
```
See `references/network-and-mocking.md` for HAR replay, conditional routing, and full patterns.
### Tags and Annotations
```typescript
test('checkout @smoke', async ({ page }) => { /* npx playwright test --grep @smoke */ });
test.slow(); // Triples timeout
test.skip(({ browserName }) => browserName === 'webkit', 'WebKit bug');
test.fixme('known issue tracked in JIRA-1234', async ({ page }) => { /* ... */ });
```
---
## Assertions
### Web-First Assertions (auto-retry — always prefer these)
```typescript
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText(['Apple', 'Banana', 'Cherry']);
```
### Soft Assertions
Collect all failures instead of stopping at the first; all are reported at the end.
```typescript
await expect.soft(page.getByLabel('Name')).toHaveValue('Jane Doe');
await expect.soft(page.getByLabel('Email')).toHaveValue('jane@example.com');
```
### ARIA Snapshots
Verify accessibility tree structure; catches semantic regressions.
```typescript
await expect(page.getByRole('navigation', { name: 'Main' })).toMatchAriaSnapshot(`
- navigation "Main":
- link "Home"
- link "Products"
`);
```
---
## New Features (2025-2026)
Agents should be aware of these recent Playwright additions:
| Version | Feature | What it does |
|---------|---------|-------------|
| v1.45 | Clock API | `page.clock.install()` / `page.clock.fastForward()` -- control time without monkey-patching `Date` |
| v1.45 | `--fail-on-flaky-tests` | CI flag that fails the run if any test required a retry to pass |
| v1.46 | `--only-changed` | Run only tests affected by changed files (git-diff-aware) |
| v1.46 | Component testing `router` fixture | Mock Next.js/SvelteKit/etc. router in component tests |
| v1.46 | ARIA snapshots | `toMatchAriaSnapshot()` for accessibility tree assertions |
| v1.49 | `routeWebSocket` | First-class WebSocket interception (replaces CDP hacks) |
| v1.51 | `expect.configure` | Per-block timeout/soft configuration |
| v1.57 | Speedboard in HTML reporter | Performance timeline visualization in the built-in report |
| v1.57 | `webServer.wait` regex | Wait for a specific stdout pattern instead of just a URL |
---
## Parallel Execution & CI
### Worker Configuration
```typescript
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? '50%' : undefined,
});
```
### Sharding Across CI Nodes
```yaml
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4
```
### Multiple Reporters
```typescript
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'results.json' }],
['github'],
['junit', { outputFile: 'junit.xml' }],
],
```
See `references/ci-recipes.md` for complete GitHub Actions workflows, artifact upload patterns, and sharding with merge.
---
## Debugging
- **Trace viewer:** `npx playwright show-trace test-results/my-test/trace.zip` -- timeline of actions, network, DOM snapshots, console logs.
- **UI mode:** `npx playwright test --ui` -- live browser, step-by-step, time-travel debugging.
- **Debug flag:** `npx playwright test my-test.spec.ts --debug` -- headed browser, pauses at each action.
- **VS Code extension:** `ms-playwright.playwright` -- run/debug from gutter icons, pick locators, watch mode.
- **page.pause():** Opens the Playwright Inspector mid-test. For local debugging only. Never commit to CI code paths.
See `references/debugging-and-triage.md` for flaky test triage workflows and artifact analysis.
---
## Done When
- `playwright.config.ts` exists with `projects` defined for at least one target browser (Chromium minimum; Firefox and WebKit added for CI)
- Page Object Model files live in the designated directory (`e2e/pages/` or equivalent) with component objects composed via root `Locator`
- All locators in test code use `getByRole`, `getByLabel`, or `getByTestId` — no raw CSS selectors or XPath
- CI workflow runs Playwright with `--shard` across matrix jobs and uploads the HTML report as an artifact on failure
- No `waitForTimeout` calls exist anywhere in test code (`grep` or `forbidOnly`-style lint catches any regressions)
## Related Skills and References
### Reference Files (in `references/`)
| File | Purpose |
|------|---------|
| `anti-patterns.md` | BAD vs GOOD code pairs for every common mistake |
| `fixtures-and-projects.md` | Auth fixtures, data fixtures, multi-env projects, composition |
| `selector-strategies.md` | Locator decision tree, getByRole examples, stability scoring |
| `auth-patterns.md` | storageState, multi-role, token seeding, session expiry |
| `multi-site-architecture.md` | Shared fixtures, per-site config, monorepo patterns |
| `network-and-mocking.md` | page.route, route.fetch, HAR, WebSocket, conditional routing |
| `debugging-and-triage.md` | Trace viewer, flaky test triage, retries, artifacts |
| `ci-recipes.md` | Reporters, sharding, --only-changed, browser caching, artifacts |
### Related Skills
- **visual-testing** -- Screenshot comparison, threshold management, baseline workflows.
- **ci-cd-integration** -- Pipeline configuration, parallelization, reporting beyond Playwright.
- **api-testing** -- Backend API validation, contract testing, request/response schemas.
- **test-reliability** -- Flaky test patterns, retry strategies, test stability metrics.
- **accessibility-testing** -- WCAG compliance, axe-core integration, ARIA assertions.