
Playwright Automation
- 328 installs
- 55 repo stars
- Updated June 10, 2026
- petrkindlmann/qa-skills
Helps with automation & workflows tasks.
About
playwright-automation is a Claude Code skill for automation & workflows. It helps solo builders move faster with AI-assisted development.
- playwright-automation
- Automation & Workflows
- AI-coding skill
Playwright Automation by the numbers
- 328 all-time installs (skills.sh)
- +71 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #480 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/petrkindlmann/qa-skills --skill playwright-automationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 328 |
|---|---|
| repo stars | ★ 55 |
| Last updated | June 10, 2026 |
| Repository | petrkindlmann/qa-skills ↗ |
What it does
Helps with automation & workflows tasks.
Files
<objective> How an expert agent writes stable, maintainable, production-grade Playwright tests in TypeScript. The failure this prevents: AI agents reflexively reach for the three patterns that produce suites which pass once and flake forever — never use waitForTimeout, never default to CSS selectors, and avoid the legacy page.click() family. This skill encodes the auto-waiting, user-facing-locator, fixture-based discipline that makes a suite survive a refactor. </objective>
Discovery Questions
Check .agents/qa-project-context.md first — if it exists, use it and skip any question answered there. Then ask only what's missing:
1. TypeScript or JavaScript? TypeScript is strongly recommended — it catches locator and assertion mistakes at compile time, and every example here assumes it. 2. Which browsers? Chromium for local dev; add Firefox and WebKit in CI. Mobile viewports are separate Playwright projects, not separate test files — they change the device descriptor. 3. Existing suite or fresh start? Migrating from Cypress/Selenium, rewrite the flakiest tests first; never big-bang. Changes the sequencing entirely. 4. Single site or multi-site? Multi-site needs 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. Reserve test.describe.serial for flows that genuinely cannot be isolated (rare). 5. Fixtures for setup, not hooks. Fixtures compose, provide type safety, and tear down automatically. Prefer them over beforeEach/afterEach for anything non-trivial. See references/fixtures-and-projects.md.
Calibrate to your team maturity (setteam_maturityin.agents/qa-project-context.md):
- startup — Chromium only, 5–10 critical-path tests, basic CI run on PR. Skip sharding and visual baselines until the suite is stable.
- growing — Chromium + Firefox, POM structure, parallel execution, sharding in CI, HTML report artifacts.
- established — Full browser matrix, auth fixtures, API mocking layer, visual regression baseline, trace-on-failure, flakiness tracking.
---
Project Structure
project-root/
├── playwright.config.ts
├── e2e/
│ ├── fixtures/ # base.fixture.ts, auth.fixture.ts, data.fixture.ts
│ ├── pages/ # Page objects by feature
│ │ ├── base.page.ts
│ │ ├── dashboard.page.ts
│ │ └── components/ # Reusable component objects (data-table, modal)
│ ├── tests/ # Test files by feature (auth/, dashboard/, settings/)
│ ├── helpers/ # test-data.ts, api-client.ts
│ └── global-setup.ts
├── .auth/ # Git-ignored storageState files
└── test-results/ # Git-ignored artifactsplaywright.config.ts
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
? [['blob'], ['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',
navigationTimeout: 30_000,
// Avoid a global actionTimeout — it can mask a genuinely slow auto-waited
// action. Set per-action only where a known-slow widget needs it.
},
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: !isCI, timeout: 120_000,
},
});The blob reporter in CI is what makes sharded runs mergeable — see the sharding section. The setup project writes storageState once before the browser projects depend on it.
Global setup (storageState)
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' });
});This is the setup project pattern: the setup project (or a globalSetup file) runs UI login once, and every browser project replays the saved cookies/localStorage via storageState in config. For multi-role auth (admin/user/guest) and token seeding, see references/auth-patterns.md.
---
Page Object Model
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<void> {
await this.page.goto(this.path);
await this.page.waitForLoadState('domcontentloaded');
}
}Component objects represent reusable UI fragments (modals, tables, nav). They take a root Locator, not a Page:
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 });
}
}Compose, don't inherit deep. A page holds its components; it does not extend a five-level hierarchy:
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' }));
}
}Inject page objects via fixtures, not constructors in test files:
export const test = base.extend<{ usersPage: UsersPage }>({
usersPage: async ({ page }, use) => { await use(new UsersPage(page)); },
});
export { expect } from '@playwright/test';POM methods return state (locators, values); they do not assert. Assertions live in the test so failures point at the test, not the page object.
---
Test Patterns
Form interactions with test.step
Wrap logical action groups in test.step() for readable trace-viewer output:
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
// 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 an error
await page.route('**/api/products*', (route) => route.fulfill({ status: 500 }));
// WebSocket (v1.48+)
await page.routeWebSocket('**/ws/notifications', (ws) => {
ws.onMessage(() => ws.send(JSON.stringify({ type: 'alert', title: 'Deployed' })));
});See references/network-and-mocking.md for HAR replay and conditional routing.
Authenticated APIRequestContext fixture
For seeding data or asserting backend state without driving the UI, inject a pre-authenticated APIRequestContext. Acquire the token in the fixture; never hardcode it:
import { test, request, type APIRequestContext } from '@playwright/test';
// test.extend adds an `api` fixture to the base test object.
export const apiTest = test.extend<{ api: APIRequestContext }>({
api: async ({ baseURL }, use) => {
const ctx = await request.newContext({
baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN!}` },
});
await use(ctx);
await ctx.dispose();
},
});Tags and annotations
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
Always prefer web-first assertions — they auto-retry until the condition holds or the timeout expires:
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:
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 and catch semantic regressions:
await expect(page.getByRole('navigation', { name: 'Main' })).toMatchAriaSnapshot(`
- navigation "Main":
- link "Home"
- link "Products"
`);Visual regression (one-liner; defer the workflow)
Playwright's built-in toHaveScreenshot auto-retries and writes a baseline on first run. Mask dynamic regions; do not precede it with waitForTimeout:
await expect(page.getByTestId('product-card')).toHaveScreenshot('product-card.png', {
mask: [page.getByTestId('price')],
});For baseline management, thresholds (maxDiffPixelRatio, maskColor, stylePath), and review workflows, use visual-testing — that is where visual baselines belong.
Accessibility scan (axe; deep audits live elsewhere)
ARIA snapshots above check structure, not WCAG rules. For rule-based scanning, add @axe-core/playwright:
import AxeBuilder from '@axe-core/playwright';
test('dashboard has no a11y violations', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});For WCAG levels, rule tuning, and remediation guidance, use accessibility-testing.
---
Parallel Execution & CI
Sharding across CI nodes
Split the suite across matrix jobs, then merge the shard reports into one HTML report. Sharding earns its place at growing+ maturity; a startup suite of 5–10 tests should not shard.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4Each shard uploads its blob-report/; a final job runs npx playwright merge-reports --reporter=html ./all-blob-reports. The blob reporter (set in the config above) is what makes merge work — --shard alone produces fragmented HTML reports. See references/ci-recipes.md for the full GitHub Actions workflow, blob upload/download, and artifact patterns.
Debugging
- Trace viewer:
npx playwright show-trace test-results/.../trace.zip— timeline of actions, network, DOM snapshots, console. - UI mode:
npx playwright test --ui— live, step-by-step, time-travel. - Debug flag:
npx playwright test my-test.spec.ts --debug— headed, pauses each action. - VS Code extension
ms-playwright.playwright— run/debug from gutter, pick locators, watch mode. - `page.pause()` opens the Inspector mid-test. Local only — never commit it.
See references/debugging-and-triage.md for flaky-test triage and artifact analysis.
---
New Features (2025-2026)
Current latest is Playwright 1.60.0 (May 2026). Pin the same version in package.json and your CI Docker image. Recent additions worth knowing:
| Version | Feature | What it does |
|---|---|---|
| v1.45 | Clock API | page.clock.install() / fastForward() — control time without monkey-patching Date |
| v1.45 | --fail-on-flaky-tests | Fail the CI run if any test needed a retry to pass |
| v1.46 | --only-changed | Run only tests affected by changed files (git-diff aware) |
| v1.46 | ARIA snapshots | toMatchAriaSnapshot() for accessibility-tree assertions |
| v1.48 | routeWebSocket | First-class WebSocket interception (replaces CDP hacks) |
| v1.55 | Test Migrator | Automated Cypress→/Selenium→Playwright via npx playwright migrate |
| v1.56 | Test Agents | `npx playwright init-agents --loop=claude\ |
| v1.57 | Chrome for Testing default | Headed uses chrome, headless uses chrome-headless-shell instead of bundled Chromium. Caveat: a high-memory regression was reported (microsoft/playwright #38489) — pin a known-good image tag for CI. |
| v1.57 | toHaveScreenshot options | maskColor, stylePath, pathTemplate for masking color, custom stylesheet, and output path control |
| v1.59 | Screencast API | page.screencast.start() / .stop() for mid-test video with start/stop control — an alternative to recordVideo, not a replacement. Adds action annotations, chapter markers, custom HTML overlays, and screencast.showOverlays() / hideOverlays(). Useful for agent self-verification: a coding agent can hand off a reviewable video receipt. |
| v1.59 | --debug=cli | Pause-and-attach so an agent can step through a test |
| v1.60 | locator.drop() | Simulate an external file/clipboard drag-and-drop onto an element |
| v1.60 | tracing.startHar() | HAR recording as a first-class tracing API |
AI-augmented authoring (Test Agents vs MCP)
Two integration paths — pick based on whether the agent runs inside your editor loop or drives a real browser remotely.
Path A — Test Agents (`npx playwright init-agents --loop=claude`): scaffolds planner/generator/healer agents the coding agent loads during its loop. Token-efficient — no MCP server, no inter-process traffic. Best for "Claude/VS Code/opencode writes Playwright tests for me."
Path B — `@playwright/mcp`: an MCP server exposing browser actions to any MCP-aware agent. Higher overhead (process boundary, JSON marshalling) but the right choice when the agent must drive a live browser interactively rather than author tests offline. Config: { "mcpServers": { "playwright": { "command": "npx", "args": ["@playwright/mcp@latest"] } } } in .mcp.json.
For test-failure repair, see test-reliability. For first-time generation from PRDs/specs, see ai-test-generation.
---
Anti-Patterns
Design-time mistakes that quietly rot a suite. The code-level "never do X" list lives in references/anti-patterns.md with BAD/GOOD pairs — load it when writing test bodies.
1. The God Page Object
One class for the whole app turns into a 2000-line file every test imports and nothing can refactor safely. Split by page/feature and compose component objects.
2. POM methods that assert
A page object whose methods call expect hides the assertion from the test. When it fails, the stack points at the page object, not the failing scenario. Return locators/state; assert in the test.
3. Asserting on implementation detail
Tests keyed to CSS classes, DOM nesting, or internal IDs break on every refactor without a real behavior change. Assert what the user perceives — visible text, roles, URLs.
4. Fixtures that depend on test order
A fixture that mutates shared module state, or assumes another test ran first, fails the moment tests parallelize or run in isolation. Each fixture must stand alone.
5. data-testid where getByRole would work
Sprinkling test ids onto buttons and headings that already have an accessible name skips the cheapest accessibility signal you get for free. Reserve getByTestId for elements with no stable role/label.
The most damaging runtime mistake — synchronizing with waitForTimeout instead of an auto-waiting locator:
// BAD — slow on fast machines, flaky on slow ones, hides the real condition
await page.waitForTimeout(2000);
await page.click('#submit');
// GOOD — the action auto-waits for actionability
await page.getByRole('button', { name: 'Submit' }).click();The other nine code-level offenders (CSS over roles, page.* over locators, force: true, shared state, per-test login, locator.all() without a stability check, allTextContents() over toHaveText(), hitting real third-party services, committed test.only) are in references/anti-patterns.md.
---
Verification
Run these against the generated artifact, smallest first:
npx playwright test --list # tests are discovered and parse
grep -rn 'waitForTimeout\|page.pause' e2e/ # must print nothing
npx tsc --noEmit # locator/assertion types compileEnforce the "never do X" rules in CI with eslint-plugin-playwright — rules no-wait-for-timeout, no-force-option, no-element-handle, no-page-pause turn this skill's prose bans into a failing lint.
---
Done When
playwright.config.tsexists withprojectsfor at least Chromium (Firefox + WebKit added when targeting CI), andforbidOnly: !!process.env.CI.- Page Object Model files live in
e2e/pages/(or equivalent), with component objects composed via a rootLocatorand noexpectinside POM methods. grep -rn 'waitForTimeout' e2e/returns nothing, andeslint-plugin-playwright'sno-wait-for-timeoutis enabled.- Every locator uses
getByRole/getByLabel/getByTestId—grep -rn 'page.locator(\|xpath=\|css=' e2e/returns nothing (or only justified, commented exceptions). - CI runs the suite on PR; at
growing+ maturity it shards across matrix jobs with theblobreporter and amerge-reportsstep, uploading the HTML report as an artifact on failure.
Related Skills
- visual-testing — screenshot baseline creation, threshold tuning, and review/approval workflows. Go here for anything beyond a single inline
toHaveScreenshotcheck. - accessibility-testing — WCAG levels, axe rule tuning, and remediation. This skill only shows a minimal axe scan.
- api-testing — backend API validation, schema/contract testing, and the full
APIRequestContextpatterns. - ci-cd-integration — pipeline config, parallelization, and reporting beyond Playwright's own.
- test-reliability — runtime healing of a single flaky test (quarantine, retry strategy).
- selector-drift-recovery — offline bulk regeneration of selectors after a UI refactor breaks many tests.
Reference files (in references/)
| File | Purpose |
|---|---|
anti-patterns.md | BAD vs GOOD code pairs for every code-level 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 + merge, --only-changed, browser caching, Docker |
Anti-Patterns: BAD vs GOOD Playwright Code
Every pattern below shows code that AI agents commonly generate, why it is wrong, and the correct alternative. This is the highest-value reference in the skill -- read it before generating any Playwright code.
---
1. waitForTimeout vs Proper Waiting
BAD
await page.goto('/dashboard');
await page.waitForTimeout(3000); // "wait for data to load"
await expect(page.getByText('Revenue')).toBeVisible();Why it is wrong: 3 seconds is too long on fast machines (wastes CI time) and too short on slow ones (still flaky). The test does not express what it is actually waiting for.
GOOD
await page.goto('/dashboard');
await expect(page.getByText('Revenue')).toBeVisible(); // auto-retries until visible or timeoutGOOD (waiting for network)
await page.goto('/dashboard');
const responsePromise = page.waitForResponse('**/api/dashboard/stats');
await page.getByRole('button', { name: 'Refresh' }).click();
await responsePromise;
await expect(page.getByText('Revenue')).toBeVisible();---
2. CSS Selectors vs getByRole
BAD
await page.locator('#login-btn').click();
await page.locator('.submit-button').click();
await page.locator('div.modal-overlay > div.modal-content button.primary').click();Why it is wrong: CSS selectors encode DOM structure and styling concerns. They break when class names change, when components are refactored, when CSS modules add hashes, or when a component library updates.
GOOD
await page.getByRole('button', { name: 'Log in' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('dialog', { name: 'Confirm' }).getByRole('button', { name: 'OK' }).click();---
3. page.click() vs locator.click()
BAD
await page.click('#email');
await page.fill('#email', 'user@example.com');
await page.type('#search', 'query', { delay: 100 });
await page.check('#agree-terms');Why it is wrong: page.click(), page.fill(), page.type(), page.check() are legacy convenience methods. They accept only string selectors (not locators), cannot be chained, and do not benefit from the locator auto-waiting pipeline. Playwright's documentation marks them as discouraged.
GOOD
await page.getByLabel('Email').fill('user@example.com');
await page.getByPlaceholder('Search...').fill('query');
await page.getByRole('checkbox', { name: 'I agree to the terms' }).check();---
4. force:true Abuse
BAD
// "The button was covered by a cookie banner, so I added force:true"
await page.getByRole('button', { name: 'Submit' }).click({ force: true });Why it is wrong: force: true skips all actionability checks -- visibility, enabled state, stable position, receiving events. If the element is obscured, the test is hiding a real bug or testing the wrong state.
GOOD
// Dismiss the overlay first
await page.getByRole('button', { name: 'Accept cookies' }).click();
await page.getByRole('button', { name: 'Submit' }).click();GOOD (if the overlay is not always present)
const cookieBanner = page.getByRole('dialog', { name: 'Cookie consent' });
if (await cookieBanner.isVisible()) {
await cookieBanner.getByRole('button', { name: 'Accept' }).click();
}
await page.getByRole('button', { name: 'Submit' }).click();Only acceptable use of force:true: Testing that a visually hidden element exists in the DOM (e.g., screen-reader-only content). Document the reason in a comment.
---
5. Shared Mutable State Between Tests
BAD
let projectId: string;
test('create project', async ({ request }) => {
const resp = await request.post('/api/projects', {
data: { name: 'Test Project' },
});
projectId = (await resp.json()).id; // module-level variable
});
test('rename project', async ({ request }) => {
// Depends on the previous test having run AND succeeded
await request.patch(`/api/projects/${projectId}`, {
data: { name: 'Renamed' },
});
});
test('delete project', async ({ request }) => {
await request.delete(`/api/projects/${projectId}`);
});Why it is wrong: Tests run in parallel across workers. Even within a single file, fullyParallel: true means order is not guaranteed. If "create project" fails, both subsequent tests fail with a confusing error about undefined.
GOOD
// Use a fixture that creates and tears down per-test
const test = base.extend<{ project: { id: string; name: string } }>({
project: async ({ request }, use) => {
const resp = await request.post('/api/projects', {
data: { name: `test-${Date.now()}` },
});
const project = await resp.json();
await use(project);
await request.delete(`/api/projects/${project.id}`);
},
});
test('rename project', async ({ request, project }) => {
const resp = await request.patch(`/api/projects/${project.id}`, {
data: { name: 'Renamed' },
});
expect(resp.status()).toBe(200);
});GOOD (if tests genuinely depend on each other)
// Use test.describe.serial and keep state scoped to the describe block
test.describe.serial('project lifecycle', () => {
let projectId: string;
test('create project', async ({ request }) => {
const resp = await request.post('/api/projects', {
data: { name: 'Lifecycle Test' },
});
projectId = (await resp.json()).id;
});
test('rename project', async ({ request }) => {
await request.patch(`/api/projects/${projectId}`, {
data: { name: 'Renamed' },
});
});
test.afterAll(async ({ request }) => {
if (projectId) await request.delete(`/api/projects/${projectId}`);
});
});---
6. Login in Every Test vs storageState
BAD
test('view dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@test.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
// Actual test starts here -- 4 lines of boilerplate above
await expect(page.getByRole('heading')).toHaveText('Dashboard');
});
test('view settings', async ({ page }) => {
// Same login boilerplate repeated
await page.goto('/login');
await page.getByLabel('Email').fill('admin@test.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.goto('/settings');
// ...
});Why it is wrong: Each UI login takes 2-5 seconds of real network time. Multiply by 100 tests = 200-500 seconds of pure login overhead. If the login page changes, every test breaks.
GOOD
// e2e/global-setup.ts -- runs once, saves session
import { test as setup, expect } from '@playwright/test';
setup('authenticate', 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' });
});// playwright.config.ts -- all test projects reuse the session
projects: [
{ name: 'setup', testMatch: /global-setup\.ts/ },
{
name: 'chromium',
use: { storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],// Tests start already authenticated -- zero login overhead
test('view dashboard', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading')).toHaveText('Dashboard');
});---
7. allTextContents() vs toHaveText()
BAD
const texts = await page.getByRole('listitem').allTextContents();
expect(texts).toEqual(['Home', 'Products', 'About']);Why it is wrong: allTextContents() takes a DOM snapshot at one instant. If the list is still rendering, you get [] or a partial result, and the test fails intermittently.
GOOD
await expect(page.getByRole('listitem')).toHaveText(['Home', 'Products', 'About']);toHaveText() is a web-first assertion. It retries until the locator matches the expected array of strings or the timeout expires.
---
8. isVisible() Checks Instead of expect().toBeVisible()
BAD
const visible = await page.getByRole('alert').isVisible();
expect(visible).toBe(true);Why it is wrong: isVisible() returns the visibility at one instant, with no retry. If the alert has not appeared yet, you get false and the test fails.
GOOD
await expect(page.getByRole('alert')).toBeVisible();The web-first assertion retries until the element is visible or the timeout expires.
---
9. Testing Implementation Details
BAD
// Checking CSS classes for state
await expect(page.locator('.btn-primary')).toHaveClass(/active/);
await expect(page.locator('.nav-item')).toHaveClass(/selected/);
// Checking internal data attributes for state
const state = await page.locator('[data-state]').getAttribute('data-state');
expect(state).toBe('open');Why it is wrong: CSS classes and internal data attributes are implementation details. They can change without any user-visible difference. Tests should assert what the user sees.
GOOD
// Check the user-visible state
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('dialog', { name: 'Confirm' })).toBeVisible();---
10. Hardcoded Waits for Animations
BAD
await page.getByRole('button', { name: 'Open menu' }).click();
await page.waitForTimeout(500); // "wait for slide-in animation"
await page.getByRole('menuitem', { name: 'Settings' }).click();Why it is wrong: Animation duration varies by browser, device, and whether prefers-reduced-motion is set. Playwright already waits for elements to be stable before acting.
GOOD
await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click(); // auto-waits for actionabilityGOOD (if CSS animations genuinely interfere)
// Disable animations globally in playwright.config.ts
export default defineConfig({
use: {
// Playwright injects a stylesheet that sets animation-duration: 0s
// for all elements
launchOptions: {
args: ['--force-prefers-reduced-motion'],
},
},
});---
11. Nested Locator Chains That Mirror DOM Structure
BAD
await page.locator('div.page-wrapper > main > section.content > div.card-grid > div.card:first-child > div.card-footer > button').click();Why it is wrong: This locator encodes 7 levels of DOM nesting. Any wrapper div added, removed, or renamed breaks it.
GOOD
await page
.getByTestId('card-grid')
.getByTestId('project-card')
.first()
.getByRole('button', { name: 'View details' })
.click();BETTER (if the card has unique text)
await page
.getByTestId('project-card')
.filter({ hasText: 'Acme Project' })
.getByRole('button', { name: 'View details' })
.click();---
12. Using evaluate() for Assertions
BAD
const text = await page.evaluate(() => document.querySelector('h1')?.textContent);
expect(text).toBe('Dashboard');Why it is wrong: page.evaluate() runs raw JavaScript in the browser context. It bypasses Playwright's auto-waiting, auto-retry, and locator pipeline. If the h1 has not rendered yet, you get null.
GOOD
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');---
13. Catching Errors Instead of Asserting Absence
BAD
try {
await page.getByRole('alert').click({ timeout: 1000 });
// Alert existed, handle it
} catch {
// Alert did not exist, continue
}Why it is wrong: Using try/catch for flow control is slow (waits the full timeout), fragile, and hides real errors.
GOOD
// Assert absence
await expect(page.getByRole('alert')).toBeHidden();
// Or conditionally handle
const alert = page.getByRole('alert');
if (await alert.isVisible()) {
await alert.getByRole('button', { name: 'Dismiss' }).click();
}---
14. Using page.waitForSelector Instead of Locator Assertions
BAD
await page.waitForSelector('.loading-spinner', { state: 'hidden' });
await page.waitForSelector('.data-table', { state: 'visible' });Why it is wrong: waitForSelector uses CSS selectors and returns an ElementHandle (a DOM reference that can become stale). Locator-based assertions are more readable and more robust.
GOOD
await expect(page.getByTestId('loading-spinner')).toBeHidden();
await expect(page.getByRole('table', { name: 'Data' })).toBeVisible();---
15. Incorrect Use of locator.all() for Iteration
BAD
// Iterating immediately without waiting for the list to stabilize
const items = await page.getByRole('listitem').all();
for (const item of items) {
await expect(item).toBeVisible(); // items.length might be 0
}Why it is wrong: locator.all() returns a point-in-time snapshot. If the DOM is updating, you may get an empty array or a partial list.
GOOD
// First, assert the expected count (this auto-retries)
await expect(page.getByRole('listitem')).toHaveCount(5);
// Then iterate safely
const items = await page.getByRole('listitem').all();
for (const item of items) {
await expect(item).toContainText(/\w+/);
}GOOD (when the exact count is unknown)
// Wait for at least one item to appear
await expect(page.getByRole('listitem').first()).toBeVisible();
// Then snapshot
const items = await page.getByRole('listitem').all();Authentication Patterns
Every approach to authentication in Playwright E2E tests, from simple single-user to multi-role, token-seeded, and session-expiry-aware patterns.
---
Storage State Concept
Playwright's storageState saves cookies and localStorage from a browser context to a JSON file. Later tests load that file to start already authenticated -- no login UI required.
1. Global setup: Login through UI → save storageState to .auth/user.json
2. Test projects: Load .auth/user.json → tests start authenticated
3. Each test: Gets a fresh BrowserContext with the saved cookies/localStorageThe .auth/ directory must be in .gitignore.
---
Global Setup Login
The simplest pattern: one user, one login, all tests share it.
// e2e/global-setup.ts
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/);
// Save the authenticated state
await page.context().storageState({ path: '.auth/user.json' });
});// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global-setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});---
Multi-Role Authenticated Contexts
When your app has admin, regular user, and guest roles that see different UI.
Separate Setup Files
// e2e/auth/admin.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate as admin', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/admin/);
await page.context().storageState({ path: '.auth/admin.json' });
});
// e2e/auth/user.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate as user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/dashboard/);
await page.context().storageState({ path: '.auth/user.json' });
});Config With Role-Based Projects
// playwright.config.ts
export default defineConfig({
projects: [
// Setup projects -- run first, no dependencies
{
name: 'admin-setup',
testMatch: /admin\.setup\.ts/,
},
{
name: 'user-setup',
testMatch: /user\.setup\.ts/,
},
// Admin tests
{
name: 'admin-chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/admin.json',
},
dependencies: ['admin-setup'],
testMatch: /.*\.admin\.spec\.ts/,
},
// Regular user tests
{
name: 'user-chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['user-setup'],
testMatch: /.*\.user\.spec\.ts/,
},
// Guest/anonymous tests (no storageState)
{
name: 'guest-chromium',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.guest\.spec\.ts/,
},
],
});File Naming Convention
e2e/tests/
├── auth/
│ ├── login.guest.spec.ts # Runs without auth
│ └── password-reset.guest.spec.ts
├── dashboard/
│ ├── overview.user.spec.ts # Runs as regular user
│ └── admin-panel.admin.spec.ts # Runs as admin
├── settings/
│ ├── profile.user.spec.ts
│ └── team-management.admin.spec.ts---
Role Switching Within a Test
Sometimes a single test needs to verify behavior across roles (e.g., admin creates a resource, user sees it).
import { test, expect } from '@playwright/test';
test('admin-created announcement visible to users', async ({ browser }) => {
// Admin context
const adminCtx = await browser.newContext({ storageState: '.auth/admin.json' });
const adminPage = await adminCtx.newPage();
await adminPage.goto('/admin/announcements');
await adminPage.getByRole('button', { name: 'New announcement' }).click();
await adminPage.getByLabel('Title').fill('Scheduled maintenance');
await adminPage.getByRole('button', { name: 'Publish' }).click();
await expect(adminPage.getByRole('alert')).toContainText('Published');
await adminCtx.close();
// User context
const userCtx = await browser.newContext({ storageState: '.auth/user.json' });
const userPage = await userCtx.newPage();
await userPage.goto('/dashboard');
await expect(userPage.getByText('Scheduled maintenance')).toBeVisible();
await userCtx.close();
});---
Token Seeding via API
When your app uses JWT or API tokens, you can skip the login UI entirely by setting tokens directly.
Direct Token Injection
// e2e/fixtures/auth.fixture.ts
import { test as base, type BrowserContext } from '@playwright/test';
export const test = base.extend<{ authenticatedContext: BrowserContext }>({
authenticatedContext: async ({ browser, request }, use) => {
// Get a token from the auth API
const resp = await request.post('/api/auth/token', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { accessToken, refreshToken } = await resp.json();
// Create context with the token set in localStorage
const ctx = await browser.newContext({
storageState: {
cookies: [],
origins: [
{
origin: process.env.BASE_URL ?? 'http://localhost:3000',
localStorage: [
{ name: 'accessToken', value: accessToken },
{ name: 'refreshToken', value: refreshToken },
],
},
],
},
});
await use(ctx);
await ctx.close();
},
});Cookie-Based Token Seeding
export const test = base.extend<{}, { authCookies: string }>({
authCookies: [async ({ browser }, use) => {
const ctx = await browser.newContext();
// Get auth cookie from API
const resp = await ctx.request.post('/api/auth/login', {
data: { email: 'test@example.com', password: 'password' },
});
const setCookie = resp.headers()['set-cookie'];
// Save the state
const path = `.auth/cookies-${test.info().parallelIndex}.json`;
await ctx.storageState({ path });
await ctx.close();
await use(path);
}, { scope: 'worker' }],
});---
Auth State Per Worker
In parallel test runs, each worker needs its own auth state file to avoid file conflicts.
export const test = base.extend<{}, { workerAuth: string }>({
workerAuth: [async ({ browser }, use, workerInfo) => {
// Unique file per worker
const authFile = `.auth/worker-${workerInfo.workerIndex}.json`;
const ctx = await browser.newContext();
const page = await ctx.newPage();
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 page.waitForURL('**/dashboard');
await ctx.storageState({ path: authFile });
await ctx.close();
await use(authFile);
}, { scope: 'worker' }],
});---
Session Expiry Handling
For long-running test suites, sessions may expire mid-run.
Re-authentication Fixture
export const test = base.extend<{ authedPage: Page }>({
authedPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: '.auth/user.json' });
const page = await ctx.newPage();
// Check if session is still valid before proceeding
const resp = await page.request.get('/api/auth/me');
if (resp.status() === 401) {
// Re-authenticate
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 page.waitForURL('**/dashboard');
// Save refreshed state for subsequent tests
await ctx.storageState({ path: '.auth/user.json' });
}
await use(page);
await ctx.close();
},
});Automatic Token Refresh via Route Interception
test('handles token refresh transparently', async ({ page }) => {
let refreshCount = 0;
// Intercept 401s and refresh the token
await page.route('**/api/**', async (route) => {
const response = await route.fetch();
if (response.status() === 401 && refreshCount === 0) {
refreshCount++;
// Refresh the token
const refreshResp = await page.request.post('/api/auth/refresh');
if (refreshResp.ok()) {
// Retry the original request
const retryResponse = await route.fetch();
await route.fulfill({ response: retryResponse });
return;
}
}
await route.fulfill({ response });
});
await page.goto('/dashboard');
await expect(page.getByRole('heading')).toHaveText('Dashboard');
});---
OAuth / SSO Testing Strategies
Strategy 1: Bypass OAuth (Recommended)
Most OAuth flows involve third-party UI you cannot control. Use an API endpoint that issues a session token directly.
setup('authenticate via API', async ({ request }) => {
// Your test environment exposes a backdoor login endpoint
const resp = await request.post('/api/test/auth', {
data: { userId: 'test-user-id', role: 'user' },
});
// The response sets a session cookie
const storageState = {
cookies: resp.headers()['set-cookie']
? [/* parse set-cookie header */]
: [],
origins: [],
};
await fs.writeFile('.auth/user.json', JSON.stringify(storageState));
});Strategy 2: Mock the OAuth Callback
test('OAuth login flow', async ({ page }) => {
// Intercept the OAuth redirect and fake a successful callback
await page.route('**/oauth/authorize*', async (route) => {
const url = new URL(route.request().url());
const redirectUri = url.searchParams.get('redirect_uri')!;
const state = url.searchParams.get('state')!;
// Redirect back to the app with a mock code
await route.fulfill({
status: 302,
headers: {
Location: `${redirectUri}?code=mock_auth_code&state=${state}`,
},
});
});
// Mock the token exchange
await page.route('**/oauth/token', async (route) => {
await route.fulfill({
json: {
access_token: 'mock_access_token',
token_type: 'bearer',
expires_in: 3600,
},
});
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await expect(page).toHaveURL(/dashboard/);
});---
Summary of Auth Patterns
| Scenario | Pattern | Where to look |
|---|---|---|
| Single user, all tests | Global setup + storageState in config | Top of this file |
| Multiple roles | Separate setup files + role-based projects | Multi-Role section |
| Cross-role verification | Multiple browser contexts in one test | Role Switching section |
| API-based apps (JWT) | Token seeding via API, skip login UI | Token Seeding section |
| Parallel workers | Per-worker auth file paths | Auth State Per Worker |
| Long suites | Session validity check + re-auth fixture | Session Expiry section |
| OAuth/SSO | Mock the OAuth flow or use API backdoor | OAuth section |
CI Recipes
Production-ready CI/CD configurations for Playwright test suites: reporters, sharding, caching, artifacts, and advanced flags.
---
Reporter Configuration
Multiple Reporters
Configure reporters for both human and machine consumption.
// playwright.config.ts
const isCI = !!process.env.CI;
export default defineConfig({
reporter: isCI
? [
// GitHub Actions inline annotations
['github'],
// HTML report for human review
['html', { open: 'never', outputFolder: 'playwright-report' }],
// JSON for downstream processing (dashboards, Slack bots)
['json', { outputFile: 'test-results/results.json' }],
// JUnit XML for CI systems (Jenkins, CircleCI, etc.)
['junit', { outputFile: 'test-results/junit.xml' }],
]
: [
// Local: HTML report, auto-open on failure
['html', { open: 'on-failure' }],
],
});Custom Reporter
// e2e/reporters/slack-reporter.ts
import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
class SlackReporter implements Reporter {
private failures: string[] = [];
onTestEnd(test: TestCase, result: TestResult) {
if (result.status === 'failed') {
this.failures.push(`${test.title} (${test.location.file}:${test.location.line})`);
}
}
async onEnd(result: FullResult) {
if (this.failures.length > 0 && process.env.SLACK_WEBHOOK_URL) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `E2E failures (${this.failures.length}):\n${this.failures.map(f => `- ${f}`).join('\n')}`,
}),
});
}
}
}
export default SlackReporter;Add to config:
reporter: [
['html', { open: 'never' }],
['./e2e/reporters/slack-reporter.ts'],
],---
Sharding Setup with Merge
Split tests across multiple CI machines, then merge results into a single report.
GitHub Actions with Sharding
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4
env:
CI: true
BASE_URL: ${{ vars.STAGING_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 1
merge-reports:
needs: e2e
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: all-blob-reports
merge-multiple: true
- name: Merge reports
run: npx playwright merge-reports --reporter=html ./all-blob-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14Key detail: For shard merge to work, use the blob reporter (default when sharding) which creates blob-report/ with binary blobs that merge-reports can combine.
// playwright.config.ts -- ensure blob reporter is active for sharding
reporter: process.env.CI
? [['blob'], ['github']]
: [['html', { open: 'on-failure' }]],---
Retry Configuration
export default defineConfig({
retries: process.env.CI ? 2 : 0,
// Combine with --fail-on-flaky-tests in CI
// A test that passes on retry is still flagged as a problem
});# In CI workflow
- run: npx playwright test --fail-on-flaky-tests---
forbidOnly and fail-on-flaky-tests Flags
forbidOnly
Prevents test.only from being committed. The CI run fails immediately if any test uses .only.
// playwright.config.ts
export default defineConfig({
forbidOnly: !!process.env.CI,
});fail-on-flaky-tests
A test that fails on the first attempt but passes on retry is "flaky." This flag treats flaky tests as failures in CI, preventing silent degradation.
npx playwright test --fail-on-flaky-tests---
--only-changed for PR Builds
Run only tests affected by files changed in the current PR. Dramatically speeds up PR feedback.
# Run tests affected by changes since the base branch
npx playwright test --only-changed=origin/main# In PR workflow
- run: npx playwright test --only-changed=origin/${{ github.base_ref }}Note: This is git-diff-aware. It analyzes import chains to determine which test files are affected by changed source files.
---
Browser Caching in CI
Downloading browsers on every CI run wastes time and bandwidth. Cache them.
GitHub Actions
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install browsers (cache miss only)
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromiumWhy `install-deps` separately? Browser binaries are cached, but OS-level dependencies (shared libraries) are not. install-deps installs only the system packages.
---
Artifact Upload Patterns
Upload on Failure Only
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-artifacts-${{ matrix.shard }}
path: |
test-results/
playwright-report/
retention-days: 7Upload Always (for dashboards, trends)
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.shard }}
path: test-results/results.json
retention-days: 30---
Concurrency and Timeout Configuration
Worker Concurrency
export default defineConfig({
// Percentage of CPU cores
workers: process.env.CI ? '50%' : undefined,
// Or absolute number
// workers: process.env.CI ? 2 : undefined,
fullyParallel: true,
});Timeouts
export default defineConfig({
// Global test timeout (includes all retries)
timeout: 60_000, // 60 seconds per test
// Expect timeout (how long web-first assertions retry)
expect: {
timeout: 10_000, // 10 seconds
},
use: {
// Action timeout (click, fill, etc.) — set this only if a known-slow widget
// needs it; a global actionTimeout can mask a genuinely slow auto-waited action.
actionTimeout: 15_000,
// Navigation timeout (goto, waitForURL)
navigationTimeout: 30_000,
},
});CI Job Timeout
Always set a job-level timeout to prevent runaway jobs:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30 # Kill the job after 30 minutes---
Complete GitHub Actions Workflow (Production Template)
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
env:
CI: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Cache Playwright browsers
id: pw-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: steps.pw-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium firefox webkit
- name: Install system deps only
if: steps.pw-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium firefox webkit
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}/4 --fail-on-flaky-tests
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 1
merge-reports:
needs: e2e
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: all-blob-reports
merge-multiple: true
- run: npx playwright merge-reports --reporter=html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
pr-tests:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for --only-changed
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Run changed tests only
run: npx playwright test --only-changed=origin/${{ github.base_ref }} --project=chromium
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}---
Docker Setup
For reproducible CI environments, use the official Playwright Docker image.
jobs:
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.60.0-noble # pin to the version in package.json
options: --user 1001 # Non-root user
steps:
- uses: actions/checkout@v4
- run: npm ci
# No need to install browsers -- they are pre-installed in the image
- run: npx playwright test---
Summary of CI Flags
| Flag | Purpose | When to use |
|---|---|---|
--shard=N/M | Split tests across M machines | Parallel CI jobs |
--fail-on-flaky-tests | Treat retried passes as failures | Always in CI |
--only-changed=ref | Run only affected tests | PR builds |
--project=name | Run specific project | Targeted runs |
--grep=pattern | Filter tests by title pattern | Smoke suites (@smoke) |
--repeat-each=N | Run each test N times | Flaky test investigation |
--forbid-only | Fail if test.only is used | Always in CI (or config) |
--workers=N | Set parallelism | Tune for CI machine size |
Debugging and Triage
How to debug failing tests, triage flaky tests, and configure artifacts for fast diagnosis.
---
Trace Viewer Workflow
The trace viewer is Playwright's most powerful debugging tool. It records a timeline of actions, network requests, DOM snapshots, and console logs.
Record Traces
// playwright.config.ts
export default defineConfig({
use: {
// CI: record on first retry (saves storage, captures failures)
trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure',
},
});Trace options:
'off'-- No traces (fastest)'on'-- Always record (generates large files)'on-first-retry'-- Record only when a test retries (best for CI)'retain-on-failure'-- Record always, keep only if test fails (best for local dev)'on-all-retries'-- Record every retry attempt
Open a Trace
# From a local trace file
npx playwright show-trace test-results/my-test-chromium/trace.zip
# From a URL (e.g., CI artifact link)
npx playwright show-trace https://ci.example.com/artifacts/trace.zipWhat to Look For in a Trace
1. Action timeline: Each action shows the element state before and after. Look for actions that took unexpectedly long or targeted the wrong element. 2. Network tab: Check if API responses returned expected data. Look for failed requests, unexpected redirects, or slow responses. 3. Console tab: Look for JavaScript errors, warnings, or unhandled promise rejections. 4. Before/After DOM snapshots: Compare the DOM state before and after an action. Look for elements that were not yet rendered or were obscured. 5. Source tab: Shows which line of test code triggered each action. Jump directly to the problem.
---
HTML Report Navigation
# Generate and open the HTML report
npx playwright show-report
# Open a specific report directory
npx playwright show-report playwright-report/The HTML report shows:
- Pass/fail summary per project and file
- Duration per test (identify slow tests)
- Retry history (see which attempts failed and why)
- Attached traces, screenshots, and videos
- Error messages and stack traces
- Speedboard (v1.57+): Performance timeline showing test execution distribution
Filtering the Report
- Click a project name to filter by browser/device
- Click "Flaky" tab to see tests that passed after retry
- Click "Failed" tab to focus on current failures
- Search by test name or file path
---
Screenshot, Video, and Trace Artifacts
Configuration
// playwright.config.ts
export default defineConfig({
use: {
screenshot: 'only-on-failure', // Save screenshots on failure
video: process.env.CI ? 'on-first-retry' : 'off', // Video on retry
trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure',
},
});Manual Screenshots in Tests
test('visual checkpoint', async ({ page }) => {
await page.goto('/dashboard');
// Attach a screenshot to the test report
await test.info().attach('dashboard-loaded', {
body: await page.screenshot(),
contentType: 'image/png',
});
});Artifact Directory Structure
test-results/
├── my-test-chromium/
│ ├── trace.zip # Trace viewer archive
│ ├── test-failed-1.png # Failure screenshot
│ └── video.webm # Test video (if enabled)
├── another-test-firefox/
│ └── ...---
Flaky Test Triage Process
A test is "flaky" when it sometimes passes and sometimes fails with no code change. Flaky tests erode trust in the test suite.
Step 1: Identify
# Run with --fail-on-flaky-tests to catch flakes in CI
npx playwright test --fail-on-flaky-tests
# Run a specific test multiple times to reproduce
npx playwright test my-test.spec.ts --repeat-each=10
# Run only tests affected by recent changes
npx playwright test --only-changedStep 2: Classify the Root Cause
| Symptom | Likely cause | Fix |
|---|---|---|
| Element not found | Race condition; element not rendered yet | Use web-first assertion (expect().toBeVisible()) before interacting |
| Element obscured | Overlay, toast, or animation covering the target | Dismiss the overlay first, or wait for animation to complete |
| Stale data | Previous test's data leaked | Use fixtures with setup/teardown for test data |
| Different on CI vs local | Timing difference due to slower CI machines | Remove waitForTimeout(), use proper waits |
| Fails only in Firefox/WebKit | Browser-specific rendering or event timing | Check for browser-specific workarounds or bugs |
| Network-related | Real API response varies or is slow | Mock the API response with page.route() |
| Order-dependent | Test relies on another test's state | Use fixtures for data setup; ensure test isolation |
Step 3: Fix or Quarantine
// If you understand the flake and have a fix: fix it immediately
// If the fix requires app changes: quarantine with a link to the issue
test.fixme('flaky: card drag-and-drop sometimes fails', async ({ page }) => {
// TODO: Investigate DOM mutation timing. Tracked in JIRA-1234.
});
// If it is a known platform bug: skip on the affected browser
test.skip(({ browserName }) => browserName === 'webkit', 'WebKit bug #12345');Step 4: Never Ignore
Do not let flaky tests accumulate. A suite with 5% flake rate means every CI run has a ~40% chance of a false failure (with 10 tests).
---
Retries Configuration
// playwright.config.ts
export default defineConfig({
// Global retries
retries: process.env.CI ? 2 : 0,
// Per-project retries (override global)
projects: [
{
name: 'chromium',
retries: process.env.CI ? 2 : 0,
},
{
name: 'webkit',
retries: process.env.CI ? 3 : 0, // WebKit can be flakier
},
],
});Per-Test Retries
// Override retries for a specific test
test('known-flaky integration', async ({ page }) => {
test.info().config.retries; // Read current retry count
});
// Or in describe block
test.describe('external service integration', () => {
test.describe.configure({ retries: 3 });
test('calls external API', async ({ page }) => {
// ...
});
});CI: Fail on Flaky
# In CI, use --fail-on-flaky-tests to treat retried-then-passed as failure
npx playwright test --fail-on-flaky-testsThis prevents the suite from silently passing with flaky tests.
---
VS Code Extension Debugging
Install ms-playwright.playwright from the VS Code marketplace.
Features
- Gutter run/debug icons: Click the green triangle next to any test to run it. Click the debug icon to debug it.
- Pick locator: Click "Pick Locator" in the testing sidebar, then click any element in the browser. The extension generates the best locator.
- Watch mode: Enable "Show Browser" in settings, then edit tests -- they re-run automatically.
- Trace viewer integration: Failed tests show a "Show Trace" button that opens the trace viewer inline.
Debug Configuration
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Playwright Test",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/playwright",
"args": ["test", "--debug", "${file}"],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}"
}
]
}---
page.pause() Usage
page.pause() opens the Playwright Inspector mid-test, for local debugging only — never commit it. Use it for interactive debugging during development.
test('debug this flow', async ({ page }) => {
await page.goto('/checkout');
await page.getByLabel('Card number').fill('4242424242424242');
// Local debugging only, never commit: opens the Inspector to step through actions
await page.pause();
await page.getByRole('button', { name: 'Pay' }).click();
});Rules:
- NEVER commit
page.pause()to the repository - NEVER use
page.pause()in CI code paths - Use
forbidOnly: isCIto catch accidentaltest.only, and consider a linting rule forpage.pause()
---
Common Debugging Commands
# Run a specific test in debug mode (headed browser, pauses at each action)
npx playwright test my-test.spec.ts --debug
# Run in UI mode (interactive, time-travel debugging)
npx playwright test --ui
# Run headed (see the browser, no pausing)
npx playwright test --headed
# Run a specific test by title
npx playwright test -g "submits the form"
# Show the last test report
npx playwright show-report
# Open a trace file
npx playwright show-trace test-results/path/trace.zip
# Generate code by recording browser actions
npx playwright codegen http://localhost:3000
# List available tests without running them
npx playwright test --list---
Debugging Checklist
When a test fails, work through this checklist:
1. Read the error message. What assertion failed? What element was not found? 2. Open the trace. Look at the DOM snapshot at the moment of failure. 3. Check the network tab. Did the API return unexpected data or a non-200 status? 4. Check the console tab. Are there JavaScript errors? 5. Run locally with --debug. Does it reproduce? If not, it may be a timing issue. 6. Run with --repeat-each=10. Is it flaky? 7. Check if the test depends on another test's state. Run it in isolation. 8. Check if it is browser-specific. Run with --project=firefox or --project=webkit. 9. Check recent code changes. Did a UI change break a locator? 10. If all else fails, add await page.pause() locally — never commit it — before the failing line and inspect interactively.
Fixtures and Projects
Fixtures are Playwright's mechanism for test setup, teardown, and dependency injection. They replace beforeEach/afterEach hooks with composable, typed, automatically-cleaned-up building blocks.
---
Test-Scoped vs Worker-Scoped Fixtures
| Scope | Created | Destroyed | Use case |
|---|---|---|---|
test (default) | Before each test | After each test | Page objects, test-specific data |
worker | Once per worker process | When worker exits | Auth sessions, DB connections, shared server |
import { test as base } from '@playwright/test';
type TestFixtures = {
dashboardPage: DashboardPage; // fresh per test
};
type WorkerFixtures = {
authState: string; // shared across tests in a worker
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
// Test-scoped (default)
dashboardPage: async ({ page }, use) => {
const dashboard = new DashboardPage(page);
await dashboard.goto();
await use(dashboard);
// Automatic teardown: page is closed by Playwright after test
},
// Worker-scoped (explicit)
authState: [async ({ browser }, use) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
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 page.waitForURL('**/dashboard');
const path = `.auth/user-${test.info().parallelIndex}.json`;
await ctx.storageState({ path });
await ctx.close();
await use(path);
}, { scope: 'worker' }],
});---
Auth Fixtures (storageState Per Role)
// e2e/fixtures/auth.fixture.ts
import { test as base, type BrowserContext } from '@playwright/test';
type AuthFixtures = {
adminContext: BrowserContext;
adminPage: Page;
userContext: BrowserContext;
userPage: Page;
};
type AuthWorkerFixtures = {
adminStorageState: string;
userStorageState: string;
};
export const test = base.extend<AuthFixtures, AuthWorkerFixtures>({
// Worker-scoped: login once per worker
adminStorageState: [async ({ browser }, use) => {
const path = `.auth/admin-${test.info().parallelIndex}.json`;
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/admin/**');
await ctx.storageState({ path });
await ctx.close();
await use(path);
}, { scope: 'worker' }],
userStorageState: [async ({ browser }, use) => {
const path = `.auth/user-${test.info().parallelIndex}.json`;
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await ctx.storageState({ path });
await ctx.close();
await use(path);
}, { scope: 'worker' }],
// Test-scoped: create fresh context with saved auth
adminContext: async ({ browser, adminStorageState }, use) => {
const ctx = await browser.newContext({ storageState: adminStorageState });
await use(ctx);
await ctx.close();
},
adminPage: async ({ adminContext }, use) => {
const page = await adminContext.newPage();
await use(page);
},
userContext: async ({ browser, userStorageState }, use) => {
const ctx = await browser.newContext({ storageState: userStorageState });
await use(ctx);
await ctx.close();
},
userPage: async ({ userContext }, use) => {
const page = await userContext.newPage();
await use(page);
},
});
export { expect } from '@playwright/test';Usage in tests:
import { test, expect } from '../fixtures/auth.fixture';
test('admin can see user management', async ({ adminPage }) => {
await adminPage.goto('/admin/users');
await expect(adminPage.getByRole('heading')).toHaveText('User Management');
});
test('regular user cannot access admin panel', async ({ userPage }) => {
await userPage.goto('/admin/users');
await expect(userPage).toHaveURL('/unauthorized');
});---
Seeded Data Fixtures (API-Created Test Data)
Create test data via API before the test, clean it up after. This is faster and more reliable than creating data through the UI.
// e2e/fixtures/data.fixture.ts
import { test as base, type APIRequestContext } from '@playwright/test';
type DataFixtures = {
testProject: { id: string; name: string; slug: string };
testUsers: Array<{ id: string; email: string }>;
};
export const test = base.extend<DataFixtures>({
testProject: async ({ request }, use) => {
// Setup: create via API
const resp = await request.post('/api/projects', {
data: {
name: `e2e-project-${Date.now()}`,
description: 'Created by E2E test fixture',
},
});
const project = await resp.json();
await use(project);
// Teardown: clean up via API
await request.delete(`/api/projects/${project.id}`);
},
testUsers: async ({ request }, use) => {
const users: Array<{ id: string; email: string }> = [];
// Create 3 test users
for (let i = 0; i < 3; i++) {
const resp = await request.post('/api/users', {
data: {
email: `e2e-user-${Date.now()}-${i}@test.local`,
name: `Test User ${i}`,
role: 'viewer',
},
});
users.push(await resp.json());
}
await use(users);
// Teardown
await Promise.all(
users.map((u) => request.delete(`/api/users/${u.id}`))
);
},
});---
API Client Fixture
Wrap a typed API client for cleaner test data management:
// e2e/helpers/api-client.ts
import { type APIRequestContext } from '@playwright/test';
export class TestApiClient {
constructor(private readonly request: APIRequestContext) {}
async createUser(data: { email: string; name: string; role: string }) {
const resp = await this.request.post('/api/users', { data });
if (!resp.ok()) throw new Error(`Failed to create user: ${resp.status()}`);
return resp.json() as Promise<{ id: string; email: string; name: string }>;
}
async deleteUser(id: string) {
await this.request.delete(`/api/users/${id}`);
}
async createProject(data: { name: string; ownerId: string }) {
const resp = await this.request.post('/api/projects', { data });
if (!resp.ok()) throw new Error(`Failed to create project: ${resp.status()}`);
return resp.json() as Promise<{ id: string; name: string; slug: string }>;
}
async deleteProject(id: string) {
await this.request.delete(`/api/projects/${id}`);
}
}
// e2e/fixtures/api.fixture.ts
import { test as base } from '@playwright/test';
import { TestApiClient } from '../helpers/api-client';
export const test = base.extend<{ api: TestApiClient }>({
api: async ({ request }, use) => {
await use(new TestApiClient(request));
},
});---
Multi-Environment Projects (dev/staging/prod)
// playwright.config.ts
const envConfigs = {
dev: {
baseURL: 'http://localhost:3000',
testUserEmail: 'dev-user@test.local',
},
staging: {
baseURL: 'https://staging.example.com',
testUserEmail: 'staging-user@test.local',
},
production: {
baseURL: 'https://app.example.com',
testUserEmail: 'smoke-user@test.local',
},
} as const;
const env = (process.env.TEST_ENV ?? 'dev') as keyof typeof envConfigs;
const config = envConfigs[env];
export default defineConfig({
use: {
baseURL: config.baseURL,
},
projects: [
{
name: 'setup',
testMatch: /global-setup\.ts/,
},
{
name: `chromium-${env}`,
use: {
...devices['Desktop Chrome'],
storageState: `.auth/${env}-user.json`,
},
dependencies: ['setup'],
},
],
});Run with environment selection:
TEST_ENV=staging npx playwright test
TEST_ENV=production npx playwright test --grep @smoke # only smoke tests in prod---
Browser and Device Matrices
// playwright.config.ts
export default defineConfig({
projects: [
// Desktop browsers
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
// Mobile viewports (still uses desktop browser engines)
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] } },
// Tablet
{ name: 'tablet', use: { ...devices['iPad Pro 11'] } },
// High-DPI
{
name: 'retina',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
},
},
],
});Run a specific project:
npx playwright test --project=chromium
npx playwright test --project=mobile-safari---
Custom Fixture Composition
Compose multiple fixture files into a single test export. Each fixture file adds its own concerns.
// e2e/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { authState: string }>({
authState: [async ({ browser }, use) => {
// ... login logic
await use(path);
}, { scope: 'worker' }],
});
// e2e/fixtures/data.fixture.ts
import { test as authTest } from './auth.fixture';
import { TestApiClient } from '../helpers/api-client';
export const test = authTest.extend<{
api: TestApiClient;
testProject: { id: string; name: string };
}>({
api: async ({ request }, use) => {
await use(new TestApiClient(request));
},
testProject: async ({ api }, use) => {
const project = await api.createProject({ name: `e2e-${Date.now()}`, ownerId: 'system' });
await use(project);
await api.deleteProject(project.id);
},
});
// e2e/fixtures/pages.fixture.ts
import { test as dataTest } from './data.fixture';
import { DashboardPage } from '../pages/dashboard.page';
import { SettingsPage } from '../pages/settings.page';
export const test = dataTest.extend<{
dashboardPage: DashboardPage;
settingsPage: SettingsPage;
}>({
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
// Final export used by all tests
export { expect } from '@playwright/test';// e2e/tests/dashboard/overview.spec.ts
import { test, expect } from '../../fixtures/pages.fixture';
// This test has access to: page, dashboardPage, settingsPage, api, testProject, authState
test('shows project on dashboard', async ({ dashboardPage, testProject }) => {
await dashboardPage.goto();
await expect(
dashboardPage.projectCards.filter({ hasText: testProject.name })
).toBeVisible();
});---
Fixture Options Pattern
Allow tests to customize fixture behavior through options:
type TableOptions = {
initialRows: number;
};
export const test = base.extend<TableOptions & { tableData: any[] }>({
// Default option value
initialRows: [10, { option: true }],
// Fixture that reads the option
tableData: async ({ request, initialRows }, use) => {
const rows = [];
for (let i = 0; i < initialRows; i++) {
const resp = await request.post('/api/rows', {
data: { value: `row-${i}` },
});
rows.push(await resp.json());
}
await use(rows);
await Promise.all(rows.map((r) => request.delete(`/api/rows/${r.id}`)));
},
});Override the option in specific tests:
test.describe('empty table', () => {
test.use({ initialRows: 0 });
test('shows empty state', async ({ page }) => {
await page.goto('/data-table');
await expect(page.getByText('No data available')).toBeVisible();
});
});
test.describe('paginated table', () => {
test.use({ initialRows: 50 });
test('shows pagination controls', async ({ page }) => {
await page.goto('/data-table');
await expect(page.getByRole('button', { name: 'Next page' })).toBeVisible();
});
});---
Automatic Fixture (No Explicit Usage Required)
For fixtures that should always run (like seeding analytics, resetting feature flags):
export const test = base.extend<{ resetFeatureFlags: void }>({
resetFeatureFlags: [async ({ request }, use) => {
// Reset flags before test
await request.post('/api/test/reset-feature-flags');
await use();
// Optionally reset after test too
}, { auto: true }], // Runs for every test, no need to reference in test params
});Multi-Site Architecture
Patterns for testing multiple websites or applications from a single Playwright test suite. Common in monorepos, multi-brand apps, and platform teams.
---
When You Need Multi-Site
- Monorepo: Multiple apps (marketing site, dashboard, admin panel) in one repo
- Multi-brand: Same product with different branding (whitelabel)
- Platform: A platform with customer-facing and internal-facing apps
- Microservices: Testing across service boundaries (e.g., auth service + main app)
---
Project Layout for Monorepo
monorepo/
├── apps/
│ ├── marketing/ # Public website
│ ├── dashboard/ # User-facing app
│ └── admin/ # Admin panel
├── packages/
│ └── e2e/ # Shared test infrastructure
│ ├── playwright.config.ts
│ ├── fixtures/
│ │ ├── base.fixture.ts
│ │ └── sites.fixture.ts
│ ├── pages/
│ │ ├── shared/ # Shared page objects
│ │ │ ├── login.page.ts
│ │ │ └── navigation.page.ts
│ │ ├── marketing/ # Site-specific page objects
│ │ │ └── landing.page.ts
│ │ ├── dashboard/
│ │ │ └── overview.page.ts
│ │ └── admin/
│ │ └── users.page.ts
│ └── tests/
│ ├── marketing/
│ ├── dashboard/
│ └── admin/---
Per-Site Config Objects
Define site-specific configuration in a central place.
// packages/e2e/config/sites.ts
export interface SiteConfig {
name: string;
baseURL: string;
loginPath: string;
dashboardPath: string;
credentials: {
email: string;
password: string;
};
}
const envConfigs = {
dev: {
marketing: {
name: 'marketing',
baseURL: 'http://localhost:3000',
loginPath: '/login',
dashboardPath: '/',
credentials: {
email: process.env.MARKETING_TEST_EMAIL ?? 'marketing@test.local',
password: process.env.MARKETING_TEST_PASSWORD ?? 'test-password',
},
},
dashboard: {
name: 'dashboard',
baseURL: 'http://localhost:3001',
loginPath: '/auth/login',
dashboardPath: '/overview',
credentials: {
email: process.env.DASHBOARD_TEST_EMAIL ?? 'user@test.local',
password: process.env.DASHBOARD_TEST_PASSWORD ?? 'test-password',
},
},
admin: {
name: 'admin',
baseURL: 'http://localhost:3002',
loginPath: '/admin/login',
dashboardPath: '/admin/dashboard',
credentials: {
email: process.env.ADMIN_TEST_EMAIL ?? 'admin@test.local',
password: process.env.ADMIN_TEST_PASSWORD ?? 'admin-password',
},
},
},
staging: {
marketing: {
name: 'marketing',
baseURL: 'https://staging.example.com',
loginPath: '/login',
dashboardPath: '/',
credentials: {
email: process.env.MARKETING_TEST_EMAIL!,
password: process.env.MARKETING_TEST_PASSWORD!,
},
},
dashboard: {
name: 'dashboard',
baseURL: 'https://app.staging.example.com',
loginPath: '/auth/login',
dashboardPath: '/overview',
credentials: {
email: process.env.DASHBOARD_TEST_EMAIL!,
password: process.env.DASHBOARD_TEST_PASSWORD!,
},
},
admin: {
name: 'admin',
baseURL: 'https://admin.staging.example.com',
loginPath: '/admin/login',
dashboardPath: '/admin/dashboard',
credentials: {
email: process.env.ADMIN_TEST_EMAIL!,
password: process.env.ADMIN_TEST_PASSWORD!,
},
},
},
} as const;
type Environment = keyof typeof envConfigs;
type SiteName = keyof typeof envConfigs.dev;
const env = (process.env.TEST_ENV ?? 'dev') as Environment;
export function getSiteConfig(site: SiteName): SiteConfig {
return envConfigs[env][site];
}
export function getAllSiteConfigs(): Record<SiteName, SiteConfig> {
return envConfigs[env];
}---
Multi-Site Playwright Config
// packages/e2e/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { getAllSiteConfigs } from './config/sites';
const sites = getAllSiteConfigs();
const isCI = !!process.env.CI;
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
reporter: isCI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
projects: [
// Setup projects (one per site)
{
name: 'marketing-setup',
testMatch: /marketing\.setup\.ts/,
use: { baseURL: sites.marketing.baseURL },
},
{
name: 'dashboard-setup',
testMatch: /dashboard\.setup\.ts/,
use: { baseURL: sites.dashboard.baseURL },
},
{
name: 'admin-setup',
testMatch: /admin\.setup\.ts/,
use: { baseURL: sites.admin.baseURL },
},
// Test projects (one per site per browser)
{
name: 'marketing-chromium',
testDir: './tests/marketing',
use: {
...devices['Desktop Chrome'],
baseURL: sites.marketing.baseURL,
storageState: '.auth/marketing.json',
},
dependencies: ['marketing-setup'],
},
{
name: 'dashboard-chromium',
testDir: './tests/dashboard',
use: {
...devices['Desktop Chrome'],
baseURL: sites.dashboard.baseURL,
storageState: '.auth/dashboard.json',
},
dependencies: ['dashboard-setup'],
},
{
name: 'admin-chromium',
testDir: './tests/admin',
use: {
...devices['Desktop Chrome'],
baseURL: sites.admin.baseURL,
storageState: '.auth/admin.json',
},
dependencies: ['admin-setup'],
},
],
webServer: isCI
? undefined
: [
{
command: 'npm run dev --workspace=apps/marketing',
url: sites.marketing.baseURL,
reuseExistingServer: true,
},
{
command: 'npm run dev --workspace=apps/dashboard',
url: sites.dashboard.baseURL,
reuseExistingServer: true,
},
{
command: 'npm run dev --workspace=apps/admin',
url: sites.admin.baseURL,
reuseExistingServer: true,
},
],
});---
Shared Fixtures Across Sites
Create site-aware fixtures that adapt behavior based on which site is under test.
// packages/e2e/fixtures/sites.fixture.ts
import { test as base, type Page } from '@playwright/test';
import { type SiteConfig, getSiteConfig } from '../config/sites';
type SiteFixtures = {
siteConfig: SiteConfig;
authenticatedPage: Page;
};
export const test = base.extend<SiteFixtures>({
// Determine the current site from the project name
siteConfig: async ({}, use, testInfo) => {
const projectName = testInfo.project.name;
// Extract site name: "dashboard-chromium" → "dashboard"
const siteName = projectName.split('-')[0] as 'marketing' | 'dashboard' | 'admin';
await use(getSiteConfig(siteName));
},
authenticatedPage: async ({ browser, siteConfig }, use) => {
const ctx = await browser.newContext({
storageState: `.auth/${siteConfig.name}.json`,
});
const page = await ctx.newPage();
await use(page);
await ctx.close();
},
});
export { expect } from '@playwright/test';---
Adapter Pattern for Site-Specific Behavior
When the same user action works differently across sites (e.g., different login forms), use an adapter interface.
// packages/e2e/pages/adapters/login.adapter.ts
import { type Page, expect } from '@playwright/test';
export interface LoginAdapter {
login(email: string, password: string): Promise<void>;
logout(): Promise<void>;
expectLoggedIn(): Promise<void>;
}
// Dashboard uses a standard email/password form
export class DashboardLoginAdapter implements LoginAdapter {
constructor(private readonly page: Page) {}
async login(email: string, password: string) {
await this.page.goto('/auth/login');
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
await expect(this.page).toHaveURL(/overview/);
}
async logout() {
await this.page.getByRole('button', { name: 'User menu' }).click();
await this.page.getByRole('menuitem', { name: 'Sign out' }).click();
}
async expectLoggedIn() {
await expect(this.page.getByRole('button', { name: 'User menu' })).toBeVisible();
}
}
// Admin uses a different login flow (e.g., SSO or two-factor)
export class AdminLoginAdapter implements LoginAdapter {
constructor(private readonly page: Page) {}
async login(email: string, password: string) {
await this.page.goto('/admin/login');
await this.page.getByLabel('Admin email').fill(email);
await this.page.getByLabel('Admin password').fill(password);
await this.page.getByRole('button', { name: 'Access admin panel' }).click();
await expect(this.page).toHaveURL(/admin\/dashboard/);
}
async logout() {
await this.page.getByRole('link', { name: 'Logout' }).click();
}
async expectLoggedIn() {
await expect(this.page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
}
}Fixture That Injects the Right Adapter
// packages/e2e/fixtures/login.fixture.ts
import { test as siteTest } from './sites.fixture';
import {
type LoginAdapter,
DashboardLoginAdapter,
AdminLoginAdapter,
} from '../pages/adapters/login.adapter';
export const test = siteTest.extend<{ loginAdapter: LoginAdapter }>({
loginAdapter: async ({ page, siteConfig }, use) => {
const adapters: Record<string, () => LoginAdapter> = {
dashboard: () => new DashboardLoginAdapter(page),
admin: () => new AdminLoginAdapter(page),
};
const adapter = adapters[siteConfig.name]?.() ?? new DashboardLoginAdapter(page);
await use(adapter);
},
});---
Shared Page Objects with Site Overrides
Base page objects define the common interface. Site-specific subclasses override only what differs.
// packages/e2e/pages/shared/navigation.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class NavigationPage {
readonly mainNav: Locator;
readonly userMenu: Locator;
constructor(protected readonly page: Page) {
this.mainNav = page.getByRole('navigation', { name: 'Main' });
this.userMenu = page.getByRole('button', { name: /user|account|profile/i });
}
async navigateTo(linkName: string): Promise<void> {
await this.mainNav.getByRole('link', { name: linkName }).click();
}
async expectActiveLink(linkName: string): Promise<void> {
await expect(
this.mainNav.getByRole('link', { name: linkName })
).toHaveAttribute('aria-current', 'page');
}
}
// packages/e2e/pages/admin/navigation.page.ts
import { type Page, type Locator } from '@playwright/test';
import { NavigationPage } from '../shared/navigation.page';
export class AdminNavigationPage extends NavigationPage {
readonly sidebarNav: Locator;
constructor(page: Page) {
super(page);
// Admin has a sidebar instead of top nav
this.sidebarNav = page.getByRole('navigation', { name: 'Admin sidebar' });
}
override async navigateTo(linkName: string): Promise<void> {
await this.sidebarNav.getByRole('link', { name: linkName }).click();
}
}---
Cross-Site Tests
Tests that verify behavior across multiple sites (e.g., content created in admin appears on the public site).
import { test, expect } from '@playwright/test';
import { getSiteConfig } from '../config/sites';
test('blog post published in admin appears on marketing site', async ({ browser }) => {
const adminConfig = getSiteConfig('admin');
const marketingConfig = getSiteConfig('marketing');
// Create the post in admin
const adminCtx = await browser.newContext({
baseURL: adminConfig.baseURL,
storageState: '.auth/admin.json',
});
const adminPage = await adminCtx.newPage();
await adminPage.goto('/admin/blog/new');
await adminPage.getByLabel('Title').fill('E2E Test Post');
await adminPage.getByLabel('Content').fill('This is automated test content.');
await adminPage.getByRole('button', { name: 'Publish' }).click();
await expect(adminPage.getByRole('alert')).toContainText('Published');
await adminCtx.close();
// Verify it appears on the marketing site
const marketingCtx = await browser.newContext({
baseURL: marketingConfig.baseURL,
});
const marketingPage = await marketingCtx.newPage();
await marketingPage.goto('/blog');
await expect(marketingPage.getByRole('link', { name: 'E2E Test Post' })).toBeVisible();
await marketingCtx.close();
});---
Environment-Aware Base URLs
For flexible environment targeting:
# Run against local
npx playwright test
# Run against staging
TEST_ENV=staging npx playwright test
# Run a specific site
npx playwright test --project=dashboard-chromium
# Run smoke tests on all sites
npx playwright test --grep @smoke---
CI Considerations for Multi-Site
# GitHub Actions -- run sites in parallel
jobs:
e2e:
strategy:
fail-fast: false
matrix:
site: [marketing, dashboard, admin]
steps:
- run: npx playwright test --project=${{ matrix.site }}-chromium
env:
TEST_ENV: stagingNetwork Interception and Mocking
Every page.route() pattern for API mocking, response modification, HAR replay, WebSocket interception, and request assertion.
---
page.route() Basics
page.route() intercepts network requests matching a URL pattern or predicate. Every matched request must be fulfilled, continued, or aborted.
// Mock: return a fake response
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
json: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
});
});
// Continue: let the request go through (optionally modify it)
await page.route('**/api/**', async (route) => {
await route.continue();
});
// Abort: block the request
await page.route('**/analytics/**', async (route) => {
await route.abort('blockedbyclient');
});Important: Always set up routes BEFORE the action that triggers the request.
---
Mock a REST API Response
test('displays product list', async ({ page }) => {
await page.route('**/api/products*', async (route) => {
await route.fulfill({
json: {
items: [
{ id: '1', name: 'Widget', price: 29.99, inStock: true },
{ id: '2', name: 'Gadget', price: 49.99, inStock: false },
],
total: 2,
},
});
});
await page.goto('/products');
await expect(page.getByRole('listitem')).toHaveCount(2);
await expect(page.getByText('Widget')).toBeVisible();
await expect(page.getByText('Out of stock')).toBeVisible();
});---
Modify a Real Response (route.fetch)
Let the real request go through, then alter the response before the browser receives it.
test('overrides a feature flag', async ({ page }) => {
await page.route('**/api/feature-flags', async (route) => {
const response = await route.fetch();
const body = await response.json();
// Override specific flags
body.flags['new-checkout-flow'] = true;
body.flags['maintenance-mode'] = false;
await route.fulfill({ response, json: body });
});
await page.goto('/');
// The app now sees new-checkout-flow = true
await expect(page.getByTestId('new-checkout')).toBeVisible();
});---
Modify Request Headers
await page.route('**/api/**', async (route) => {
const headers = {
...route.request().headers(),
'x-test-mode': 'true',
'x-test-run-id': testInfo.testId,
};
await route.continue({ headers });
});---
Conditional Routing
Handle different endpoints with different strategies in a single handler.
await page.route('**/api/**', async (route) => {
const url = route.request().url();
const method = route.request().method();
if (url.includes('/api/products') && method === 'GET') {
// Mock reads
await route.fulfill({ json: { items: mockProducts } });
} else if (url.includes('/api/products') && method === 'POST') {
// Let writes go through to the real server
await route.continue();
} else if (url.includes('/api/analytics')) {
// Block analytics in tests
await route.abort('blockedbyclient');
} else {
// Everything else passes through
await route.continue();
}
});---
Simulate Errors
test('handles 500 error gracefully', async ({ page }) => {
await page.route('**/api/dashboard/stats', async (route) => {
await route.fulfill({ status: 500, json: { error: 'Internal server error' } });
});
await page.goto('/dashboard');
await expect(page.getByRole('alert')).toContainText('Failed to load');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('handles network timeout', async ({ page }) => {
await page.route('**/api/dashboard/stats', async (route) => {
await route.abort('timedout');
});
await page.goto('/dashboard');
await expect(page.getByText('Connection timed out')).toBeVisible();
});---
Simulate Slow Responses
Test loading states by delaying the response.
test('shows loading skeleton while data is fetching', async ({ page }) => {
await page.route('**/api/dashboard/stats', async (route) => {
// Delay the response
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.fulfill({
json: { revenue: 50000, users: 1200 },
});
});
await page.goto('/dashboard');
// Verify loading state
await expect(page.getByTestId('stats-skeleton')).toBeVisible();
// Verify data replaces the skeleton
await expect(page.getByText('$50,000')).toBeVisible();
await expect(page.getByTestId('stats-skeleton')).toBeHidden();
});---
Intercept Requests for Assertions
Record which requests were made and assert on them.
test('sends correct analytics events', async ({ page }) => {
const analyticsRequests: Array<{ url: string; body: unknown }> = [];
await page.route('**/api/analytics/**', async (route) => {
analyticsRequests.push({
url: route.request().url(),
body: route.request().postDataJSON(),
});
await route.fulfill({ status: 204 });
});
await page.goto('/products');
await page.getByRole('link', { name: 'Widget Pro' }).click();
await page.getByRole('button', { name: 'Add to cart' }).click();
const events = analyticsRequests.map((r) => (r.body as any).event);
expect(events).toContain('page_view');
expect(events).toContain('product_view');
expect(events).toContain('add_to_cart');
});---
Wait for a Specific Network Response
test('submits order and waits for confirmation', async ({ page }) => {
await page.goto('/checkout');
// Set up the listener BEFORE the action
const orderResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/orders') && resp.status() === 201
);
await page.getByRole('button', { name: 'Place order' }).click();
const orderResponse = await orderResponsePromise;
const order = await orderResponse.json();
expect(order.id).toBeTruthy();
await expect(page).toHaveURL(new RegExp(`/orders/${order.id}`));
});---
HAR Replay
Record real network traffic to a HAR file, then replay it in tests for deterministic, offline-capable testing.
Recording a HAR File
// Record HAR during a test run
test('record HAR', async ({ page }) => {
await page.routeFromHAR('e2e/fixtures/har/products.har', {
update: true, // Record mode
url: '**/api/products/**', // Only record matching requests
updateMode: 'minimal', // Only headers needed for matching
updateContent: 'embed', // Embed response bodies in the HAR file
});
await page.goto('/products');
// Interact with the page -- all matching requests are recorded
await page.getByRole('link', { name: 'Widget' }).click();
});Replaying a HAR File
test('displays products from HAR', async ({ page }) => {
await page.routeFromHAR('e2e/fixtures/har/products.har', {
url: '**/api/products/**',
update: false, // Replay mode (default)
});
await page.goto('/products');
// Requests matching the URL pattern are served from the HAR file
await expect(page.getByText('Widget')).toBeVisible();
});HAR at Config Level
// playwright.config.ts
export default defineConfig({
use: {
// Apply HAR replay to all tests
// Useful for full offline testing
},
});HAR Best Practices
- Store HAR files in
e2e/fixtures/har/and commit them to git - Re-record when APIs change:
npx playwright test --grep "record HAR" - Use
updateMode: 'minimal'to reduce HAR file size - Scope HAR to specific URL patterns -- do not record everything
---
WebSocket Interception (routeWebSocket)
Available since Playwright v1.49. Intercepts WebSocket connections.
Mock WebSocket Messages
test('receives real-time notifications', async ({ page }) => {
await page.routeWebSocket('**/ws/notifications', (ws) => {
ws.onMessage((message) => {
if (message === 'subscribe:alerts') {
ws.send(
JSON.stringify({
type: 'alert',
title: 'New deployment',
message: 'v2.1.0 deployed to production',
})
);
}
});
});
await page.goto('/dashboard');
await expect(page.getByRole('alert')).toContainText('New deployment');
});WebSocket with Delayed Messages
test('shows typing indicator then message', async ({ page }) => {
await page.routeWebSocket('**/ws/chat', (ws) => {
ws.onMessage((message) => {
const data = JSON.parse(message as string);
if (data.type === 'send_message') {
// Simulate the other user typing
ws.send(JSON.stringify({ type: 'typing', user: 'Alice' }));
// Then send a response after a delay
setTimeout(() => {
ws.send(
JSON.stringify({
type: 'message',
user: 'Alice',
text: 'Got it, thanks!',
})
);
}, 500);
}
});
});
await page.goto('/chat');
await page.getByPlaceholder('Type a message').fill('Hello');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Alice is typing')).toBeVisible();
await expect(page.getByText('Got it, thanks!')).toBeVisible();
});Pass-Through with Modification
test('modifies real WebSocket messages', async ({ page }) => {
await page.routeWebSocket('**/ws/feed', (ws) => {
const server = ws.connectToServer();
// Pass messages through but modify them
server.onMessage((message) => {
const data = JSON.parse(message as string);
// Add test metadata
data.testMode = true;
ws.send(JSON.stringify(data));
});
// Pass client messages to server unchanged
ws.onMessage((message) => {
server.send(message);
});
});
await page.goto('/live-feed');
});---
API Mocking for Test Isolation
Fixture-Based Route Setup
// e2e/fixtures/mock.fixture.ts
import { test as base } from '@playwright/test';
type MockFixtures = {
mockApi: {
products: (items: any[]) => Promise<void>;
error: (path: string, status: number) => Promise<void>;
};
};
export const test = base.extend<MockFixtures>({
mockApi: async ({ page }, use) => {
const mockApi = {
products: async (items: any[]) => {
await page.route('**/api/products*', async (route) => {
await route.fulfill({ json: { items, total: items.length } });
});
},
error: async (path: string, status: number) => {
await page.route(`**${path}`, async (route) => {
await route.fulfill({ status, json: { error: 'Mocked error' } });
});
},
};
await use(mockApi);
},
});Usage:
import { test, expect } from '../fixtures/mock.fixture';
test('empty product list shows empty state', async ({ page, mockApi }) => {
await mockApi.products([]);
await page.goto('/products');
await expect(page.getByText('No products found')).toBeVisible();
});
test('API error shows error state', async ({ page, mockApi }) => {
await mockApi.error('/api/products', 500);
await page.goto('/products');
await expect(page.getByRole('alert')).toContainText('Failed to load');
});---
Unroute and Route Cleanup
Remove routes when you need to change mocking behavior mid-test.
test('transitions from loading to loaded state', async ({ page }) => {
// Start with a slow response
const slowHandler = async (route: Route) => {
await new Promise((r) => setTimeout(r, 5000));
await route.fulfill({ json: { data: 'loaded' } });
};
await page.route('**/api/data', slowHandler);
await page.goto('/dashboard');
await expect(page.getByTestId('loading')).toBeVisible();
// Remove the slow handler and add an instant one
await page.unroute('**/api/data', slowHandler);
await page.route('**/api/data', async (route) => {
await route.fulfill({ json: { data: 'loaded' } });
});
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('loaded')).toBeVisible();
});---
Offline Simulation
test('shows offline banner when connection drops', async ({ page, context }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading')).toHaveText('Dashboard');
// Go offline
await context.setOffline(true);
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByRole('alert')).toContainText('You are offline');
// Come back online
await context.setOffline(false);
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByRole('alert')).toBeHidden();
});---
Summary
| I want to... | Use |
|---|---|
| Return a fake response | route.fulfill({ json: ... }) |
| Modify a real response | const resp = await route.fetch(); route.fulfill({ response: resp, json: modified }) |
| Block a request | route.abort('blockedbyclient') |
| Add headers to a request | route.continue({ headers: { ...existing, 'x-new': 'val' } }) |
| Delay a response | await new Promise(r => setTimeout(r, ms)); route.fulfill(...) |
| Record/replay traffic | page.routeFromHAR('file.har', { update: true/false }) |
| Mock WebSocket | page.routeWebSocket('**/ws/**', handler) |
| Assert on requests | Capture in array, assert after actions |
| Go offline | context.setOffline(true) |
Selector Strategies
How to choose, write, and maintain locators that are stable, readable, and survive UI refactors.
---
Decision Tree
Is the element interactive (button, link, input, checkbox, select)?
├── YES: Does it have visible text or an accessible name (aria-label)?
│ ├── YES → getByRole('role', { name: 'text' })
│ └── NO: Does it have a <label>?
│ ├── YES → getByLabel('label text')
│ └── NO: Does it have placeholder text?
│ ├── YES → getByPlaceholder('placeholder')
│ └── NO → Add data-testid, use getByTestId('id')
│
└── NO (non-interactive: heading, image, region, generic container):
├── Heading? → getByRole('heading', { name: 'text', level: n })
├── Image? → getByRole('img', { name: 'alt text' })
├── Navigation/region? → getByRole('navigation', { name: 'label' })
├── Has unique visible text? → getByText('text', { exact: true })
└── None of the above → Add data-testid, use getByTestId('id')Priority order:
1. getByRole -- Best. Queries the accessibility tree. Survives CSS refactors, component library swaps. 2. getByLabel -- For form inputs with <label> associations. 3. getByPlaceholder -- When labels are absent (common in search fields). 4. getByText -- For non-interactive elements with unique, stable text. 5. getByAltText -- For images. 6. getByTitle -- Rare. Title attributes are uncommon. 7. getByTestId -- Escape hatch. Stable but conveys no user-visible meaning. 8. CSS selector (page.locator()) -- Last resort. Only when nothing above works.
---
getByRole Examples for Every Common Element
Buttons
// Button with visible text
await page.getByRole('button', { name: 'Submit' }).click();
// Icon-only button (uses aria-label)
// <button aria-label="Close"><svg>...</svg></button>
await page.getByRole('button', { name: 'Close' }).click();
// Disambiguate: exact match prevents "Delete all" from matching "Delete"
await page.getByRole('button', { name: 'Delete', exact: true }).click();
// Disabled state
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();Links
await page.getByRole('link', { name: 'Documentation' }).click();
// Link within a specific navigation region
await page.getByRole('navigation', { name: 'Main' })
.getByRole('link', { name: 'Settings' })
.click();
// Verify href without clicking
await expect(page.getByRole('link', { name: 'GitHub' }))
.toHaveAttribute('href', /github\.com/);Text Inputs
// <label for="email">Email address</label><input id="email" type="email" />
await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');
// Number input
await page.getByRole('spinbutton', { name: 'Quantity' }).fill('5');
// Search input (requires <input type="search"> or role="searchbox")
await page.getByRole('searchbox', { name: 'Search' }).fill('query');Checkboxes and Radio Buttons
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await expect(page.getByRole('checkbox', { name: 'Remember me' })).toBeChecked();
await page.getByRole('radio', { name: 'Express shipping' }).check();
// Radio group
const shippingOptions = page.getByRole('radiogroup', { name: 'Shipping method' });
await expect(shippingOptions.getByRole('radio')).toHaveCount(3);Select / Combobox
// Native <select>
await page.getByRole('combobox', { name: 'Country' }).selectOption('United States');
// By value
await page.getByRole('combobox', { name: 'Country' }).selectOption({ value: 'US' });
// Custom combobox (Radix, Headless UI, etc.)
await page.getByRole('combobox', { name: 'Assignee' }).click();
await page.getByRole('option', { name: 'Jane Doe' }).click();Headings
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome');
await expect(page.getByRole('heading', { level: 2, name: 'Recent activity' })).toBeVisible();Dialogs
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Delete' }).click();
await expect(dialog).toBeHidden();
// Alert dialog (used for destructive confirmations)
const alertDialog = page.getByRole('alertdialog', { name: 'Unsaved changes' });
await alertDialog.getByRole('button', { name: 'Discard' }).click();Tables
const table = page.getByRole('table', { name: 'User list' });
await expect(table.getByRole('columnheader')).toHaveCount(5);
// Find a row by content
const row = table.getByRole('row', { name: /jane@example\.com/ });
await row.getByRole('button', { name: 'Edit' }).click();Tabs
const tabList = page.getByRole('tablist', { name: 'Account settings' });
await tabList.getByRole('tab', { name: 'Security' }).click();
await expect(tabList.getByRole('tab', { name: 'Security' }))
.toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: 'Security' })).toBeVisible();Navigation and Regions
// <nav aria-label="Main navigation">
const mainNav = page.getByRole('navigation', { name: 'Main navigation' });
await mainNav.getByRole('link', { name: 'Products' }).click();
// <section aria-label="User profile">
const profile = page.getByRole('region', { name: 'User profile' });Alerts and Status
await expect(page.getByRole('alert')).toContainText('Saved successfully');
await expect(page.getByRole('status')).toHaveText('3 items selected');---
getByTestId Naming Conventions
Use data-testid when no user-facing attribute works. This typically means:
- Container/wrapper elements with no semantic role
- Canvas elements, charts, complex visualizations
- Third-party components you cannot modify
Naming pattern: {component}-{element}-{qualifier}
data-testid="metric-card-revenue"
data-testid="sidebar-nav-link-settings"
data-testid="checkout-step-payment"
data-testid="user-table-row-123"Avoid generic names like data-testid="container" or data-testid="wrapper" -- they will collide.
Custom test ID attribute
If your app uses data-cy or data-qa, configure globally:
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-qa',
},
});---
Chaining and Filtering
filter() with hasText
// Find a specific card among many
const card = page.getByTestId('project-card').filter({ hasText: 'Acme Corp' });
await expect(card).toBeVisible();filter() with has (nested locator)
// Find the row containing a specific cell value
const row = page
.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'jane@example.com' }) });
await row.getByRole('button', { name: 'Edit' }).click();Combining has and hasText
const urgentTask = page
.getByTestId('task-card')
.filter({ has: page.getByRole('heading', { name: 'Fix login bug' }) })
.filter({ hasText: 'Urgent' });
await urgentTask.getByRole('button', { name: 'Assign to me' }).click();hasNot and hasNotText
// Cards that are NOT archived
const activeCards = page
.getByTestId('project-card')
.filter({ hasNotText: 'Archived' });
await expect(activeCards).toHaveCount(3);Chaining locators (scoping)
// Scope to a specific region
const sidebar = page.getByRole('complementary');
await expect(sidebar.getByRole('link')).toHaveCount(5);
// Click a link inside a specific list item
await page
.getByRole('listitem')
.filter({ hasText: 'Premium Plan' })
.getByRole('link', { name: 'Details' })
.click();---
Locator Stability Scoring
Rate locators 1-5. Aim for 4+ across your test suite.
Score 5: Semantic role + accessible name
page.getByRole('button', { name: 'Add to cart' })
page.getByRole('heading', { level: 1 })
page.getByRole('navigation', { name: 'Main' }).getByRole('link', { name: 'Products' })Survives: CSS refactors, component library swaps, layout changes. Breaks only when user-visible text or element semantics change -- which is a real product change you want to know about.
Score 4: Label-based or test ID
page.getByLabel('Email address')
page.getByPlaceholder('Search...')
page.getByTestId('checkout-summary')Survives: most refactors. Test IDs change only intentionally.
Score 3: Text content
page.getByText('No results found')
page.getByText('Welcome back, Jane')Risk: fragile if text is localized, A/B tested, or from a CMS.
Score 2: CSS selectors on stable attributes
page.locator('[data-state="open"]')
page.locator('input[type="file"]')Functional but opaque. Does not communicate intent.
Score 1: CSS class selectors, nth(), XPath
page.locator('.MuiButton-containedPrimary') // breaks on library upgrade
page.locator('div > div > span:nth-child(3)') // breaks on any DOM change
page.locator('//div[@class="header"]/ul/li[2]/a') // unmaintainable
page.locator('.btn').nth(0) // which button? nobody knows---
Migration: CSS to User-Facing Locators
| Old (CSS) | New (User-facing) |
|---|---|
page.locator('#login-btn') | page.getByRole('button', { name: 'Log in' }) |
page.locator('.submit-button') | page.getByRole('button', { name: 'Submit' }) |
page.locator('input[name="email"]') | page.getByLabel('Email') |
page.locator('input[placeholder="Search"]') | page.getByPlaceholder('Search') |
page.locator('h1') | page.getByRole('heading', { level: 1 }) |
page.locator('a[href="/about"]') | page.getByRole('link', { name: 'About' }) |
page.locator('.nav-menu') | page.getByRole('navigation', { name: 'Main' }) |
page.locator('.modal') | page.getByRole('dialog', { name: 'title' }) |
page.locator('.error-message') | page.getByRole('alert') |
page.locator('select[name="country"]') | page.getByRole('combobox', { name: 'Country' }) |
If getByRole does not work, the element probably has poor accessibility. Fix the source code, not the test:
// Before: no accessible name
<button className="icon-btn"><TrashIcon /></button>
// After: accessible name via aria-label
<button className="icon-btn" aria-label="Delete item"><TrashIcon /></button>---
Anti-Patterns
nth() without context
// BAD
await page.getByRole('button').nth(2).click();
// GOOD
await page.getByRole('button', { name: 'Delete' }).click();
// ACCEPTABLE: nth() scoped to a small, stable container
await page.getByRole('listitem').nth(2).getByRole('link').click();Auto-generated IDs
// BAD -- framework-generated IDs change between builds
await page.locator('#ember234').click();
await page.locator('#radix-\\:r1\\:').click();
// GOOD
await page.getByRole('button', { name: 'Toggle menu' }).click();Overly broad getByText
// BAD -- matches "Delete", "Delete all", "Undelete"
await page.getByText('Delete').click();
// GOOD
await page.getByRole('button', { name: 'Delete', exact: true }).click();---
Quick Reference
| I want to find... | Use this |
|---|---|
| Button | getByRole('button', { name: 'text' }) |
| Link | getByRole('link', { name: 'text' }) |
| Text input | getByLabel('label') or getByRole('textbox', { name: '' }) |
| Checkbox | getByRole('checkbox', { name: 'label' }) |
| Radio | getByRole('radio', { name: 'label' }) |
| Dropdown | getByRole('combobox', { name: 'label' }) |
| Heading | getByRole('heading', { name: 'text', level: n }) |
| Dialog | getByRole('dialog', { name: 'title' }) |
| Table row | getByRole('row').filter({ hasText: 'content' }) |
| Navigation | getByRole('navigation', { name: 'label' }) |
| Alert/toast | getByRole('alert') |
| Tab | getByRole('tab', { name: 'label' }) |
| Image | getByRole('img', { name: 'alt text' }) |
| List item | getByRole('listitem').filter({ hasText: 'text' }) |
| Static text | getByText('text', { exact: true }) |
| No semantics | getByTestId('test-id') |