playwright-testing
Use this skill when writing Playwright e2e tests, debugging flaky tests, setting up visual regression, testing APIs with request context, configuring CI sharding, or automating browser interactions. Triggers on Playwright, page.route, storageState, toHaveScreenshot, trace viewer, codegen, test.describe, page object model, and any task requiring Playwright test automation or flaky test diagnosis.
engineering playwrighte2etestingbrowser-automationvisual-regressionflaky-testsWhat is playwright-testing?
Use this skill when writing Playwright e2e tests, debugging flaky tests, setting up visual regression, testing APIs with request context, configuring CI sharding, or automating browser interactions. Triggers on Playwright, page.route, storageState, toHaveScreenshot, trace viewer, codegen, test.describe, page object model, and any task requiring Playwright test automation or flaky test diagnosis.
playwright-testing
playwright-testing is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex. Writing Playwright e2e tests, debugging flaky tests, setting up visual regression, testing APIs with request context, configuring CI sharding, or automating browser interactions.
Quick Facts
| Field | Value |
|---|---|
| Category | engineering |
| Version | 1.0.0 |
| Platforms | claude-code, gemini-cli, openai-codex |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill playwright-testing- The playwright-testing skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
Playwright runs real Chromium, Firefox, and WebKit browsers from a single API with auto-waiting, network interception, and built-in assertions. This skill focuses on what Claude gets wrong by default: auth state management, flaky test diagnosis, CI optimization, and the subtle gotchas that burn hours. For basic "write a test" tasks, Claude already knows the API - this skill adds the battle-tested patterns that prevent production pain.
Tags
playwright e2e testing browser-automation visual-regression flaky-tests
Platforms
- claude-code
- gemini-cli
- openai-codex
Related Skills
Pair playwright-testing with these complementary skills:
Frequently Asked Questions
What is playwright-testing?
Use this skill when writing Playwright e2e tests, debugging flaky tests, setting up visual regression, testing APIs with request context, configuring CI sharding, or automating browser interactions. Triggers on Playwright, page.route, storageState, toHaveScreenshot, trace viewer, codegen, test.describe, page object model, and any task requiring Playwright test automation or flaky test diagnosis.
How do I install playwright-testing?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill playwright-testing in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support playwright-testing?
This skill works with claude-code, gemini-cli, openai-codex. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Playwright Testing
Playwright runs real Chromium, Firefox, and WebKit browsers from a single API with auto-waiting, network interception, and built-in assertions. This skill focuses on what Claude gets wrong by default: auth state management, flaky test diagnosis, CI optimization, and the subtle gotchas that burn hours. For basic "write a test" tasks, Claude already knows the API - this skill adds the battle-tested patterns that prevent production pain.
When to use this skill
Trigger this skill when the user:
- Debugs flaky Playwright tests or intermittent CI failures
- Sets up authentication (storageState, global setup, multi-role projects)
- Configures CI pipelines with sharding, caching, or Docker containers
- Implements visual regression / screenshot diffing
- Mocks API routes or intercepts network in complex scenarios (iframes, service workers)
- Writes test infrastructure: custom fixtures, page objects, shared utilities
- Sets up Playwright Component Testing (CT) for React/Vue/Svelte
- Migrates from Cypress, Puppeteer, or Selenium to Playwright
Do NOT trigger this skill for:
- Unit testing with Jest/Vitest when Playwright isn't involved
- Generic Puppeteer scripting unrelated to test automation
First-time project setup
On first activation, check if config.json exists in this skill's directory.
If not, ask the user these questions and save the answers:
{
"baseURL": "http://localhost:3000",
"testDir": "./tests",
"authStrategy": "storageState | none | per-test-login",
"ciProvider": "github-actions | gitlab-ci | circle-ci | other",
"browsers": ["chromium", "firefox", "webkit"],
"screenshotBaseline": "linux | macos | docker"
}Use these values to generate correct config snippets without asking the user to repeat themselves every session.
Gotchas - the stuff that actually burns you
This is the highest-value section. These are patterns Claude will get wrong without this skill.
1. storageState does NOT include sessionStorage
context.storageState() saves cookies and localStorage. It silently ignores
sessionStorage. If your app stores auth tokens in sessionStorage (many SPAs
do), the saved state file looks valid but tests fail with unauthenticated
redirects. Fix: use page.evaluate() in global setup to also dump
sessionStorage, then restore it via addInitScript in a custom fixture.
2. page.route() does NOT intercept iframe requests
page.route('**/api/*', handler) only intercepts requests from the top-level
page. API calls from <iframe> elements are invisible to it. You need
context.route() to intercept at the browser context level. This bites
hard with embedded payment forms (Stripe Elements, PayPal) and third-party
widgets.
3. Screenshot baselines are OS + browser + DPI specific
Snapshots generated on macOS will fail in Linux CI due to font rendering,
sub-pixel antialiasing, and DPI differences. Always generate baselines in
the same environment CI uses. Best practice: run --update-snapshots inside
the same Docker container CI uses, commit the results.
4. fullyParallel + shared database = silent data corruption
With fullyParallel: true, tests within the same file run concurrently. If
two tests create a user with the same email, one fails with a duplicate key
error that looks like a test bug. Fix: generate unique test data per test
(crypto.randomUUID() in email prefix) or use per-test database transactions
that roll back.
5. Workers vs shards - they don't do the same thing
workers: 4 = 4 threads on one machine sharing memory/DB. --shard=1/4 =
4 separate machines. Setting workers: 1 does NOT prevent shard conflicts.
If tests share global state (a single test user, a shared API key), they'll
conflict across shards even with one worker per shard.
6. Hydration kills your click handlers
In SSR apps (Next.js, Nuxt), Playwright finds the server-rendered button
and clicks it. But React hasn't hydrated yet - the click handler isn't
attached. The click silently does nothing. Playwright's auto-wait checks
visibility and stability, NOT hydration. Fix: add a data-hydrated
attribute after hydration and wait for it, or use
page.waitForFunction(() => document.querySelector('[data-hydrated]')).
7. page.clock requires explicit install
page.clock.install() must be called BEFORE navigating to the page. If you
call it after page.goto(), timers already scheduled by the app aren't
captured. The clock also doesn't affect Web Workers - timers in workers
still use real time.
8. expect(locator).toHaveText() does substring matching by default
await expect(locator).toHaveText('Hello') passes if the element contains
"Hello World". This is different from Jest's toBe. For exact match, use
toHaveText('Hello', { exact: true }) or pass a regex: toHaveText(/^Hello$/).
Claude's default is to write the non-exact form, which leads to false passes.
9. route.fulfill() with json option silently sets content-type
When using route.fulfill({ json: data }), Playwright automatically sets
Content-Type: application/json. If you also pass contentType: manually,
the manual value wins but the json is still stringified. If you pass body:
as a string AND json:, the json option takes precedence. These
precedence rules aren't obvious from the types.
10. Test isolation breaks when using browser.newPage() directly
If you create a page via browser.newPage() instead of using the page
fixture, Playwright does NOT create an isolated BrowserContext. Your page
shares cookies, localStorage, and cache with other pages from the same
browser. Always use browser.newContext() first, then context.newPage().
Non-obvious patterns
Auth with multi-role project dependencies
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'auth-setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'admin',
dependencies: ['auth-setup'],
use: { storageState: '.auth/admin.json' },
},
{
name: 'member',
dependencies: ['auth-setup'],
use: { storageState: '.auth/member.json' },
},
],
})See references/auth-patterns.md for the full setup including token refresh
and OAuth mocking.
Waiting for network + action atomically
// WRONG: race condition - response might arrive before waitForResponse registers
await page.getByRole('button', { name: 'Save' }).click()
const response = await page.waitForResponse('**/api/save')
// RIGHT: register the wait BEFORE the action triggers the request
const [response] = await Promise.all([
page.waitForResponse('**/api/save'),
page.getByRole('button', { name: 'Save' }).click(),
])
expect(response.status()).toBe(200)Modifying a response without replacing it
await page.route('**/api/products', async (route) => {
const response = await route.fetch()
const json = await response.json()
json.items = json.items.slice(0, 2) // trim to 2 items for test
await route.fulfill({ response, json })
})Disabling animations globally for stable tests
// playwright.config.ts
export default defineConfig({
use: {
// Tells the browser to prefer reduced motion
contextOptions: { reducedMotion: 'reduce' },
},
})For apps that ignore prefers-reduced-motion, inject CSS:
// global-setup or fixture
await page.addStyleTag({
content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }',
})/careful - destructive command guard
When the user invokes /careful, wrap the following commands with a
confirmation prompt before executing:
| Command | Risk |
|---|---|
npx playwright test --update-snapshots |
Overwrites all baseline screenshots |
rm -rf test-results/ |
Deletes trace and screenshot artifacts |
rm -rf .auth/ |
Deletes saved auth state, breaks dependent projects |
npx playwright install --force |
Re-downloads all browsers (~1.5GB) |
Without /careful, execute these normally. This is an opt-in safety net.
Skill memory
This skill tracks test run patterns across sessions. After each test run,
check for and update .playwright-skill/run-log.json:
{
"lastRun": "2026-03-22T10:00:00Z",
"flakyTests": ["tests/checkout.spec.ts:45", "tests/auth.spec.ts:12"],
"avgDuration": "2m 34s",
"failurePatterns": {
"timeout": 3,
"strict-mode-violation": 1,
"navigation": 0
}
}Use this data to proactively suggest fixes. If the same test appears in
flakyTests across 3+ runs, flag it and suggest loading
references/flaky-test-playbook.md.
References
Load these on demand - only when the current task matches the topic:
references/locator-strategies.md- Locator priority guide, filtering, chaining, iframe/shadow DOMreferences/auth-patterns.md- storageState lifecycle, token refresh, multi-role testing, OAuth mockingreferences/flaky-test-playbook.md- Diagnosis flowchart, 7 root causes, trace-based debuggingreferences/ci-optimization.md- Sharding math, browser caching, Docker gotchas, artifact strategiesreferences/component-testing.md- CT mode setup, mounting, props serialization, mocking patterns
References
auth-patterns.md
Authentication Patterns in Playwright
1. storageState Flow
The standard pattern: log in once in global setup, persist browser state, reuse it across all tests.
Global Setup File
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://myapp.com/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();
// Wait for the app to fully load after auth redirect
await page.waitForURL('https://myapp.com/dashboard');
// Save the authenticated state
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;Config Wiring
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: '.auth/user.json',
},
});Gotcha: sessionStorage Is Not Included
storageState persists cookies and localStorage only. If your app stores auth tokens in sessionStorage, those are lost. Workaround - hydrate sessionStorage manually in a beforeEach hook:
import { test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.sessionStorage.setItem('access_token', 'test-token-value');
});
});addInitScript runs before any page script, so the token is available when your app boots.
2. Token Refresh Trap
storageState captures tokens at save time. If tokens have a short TTL (e.g., 15-minute JWTs), tests that run later in a long suite hit mystery 401 errors.
Symptoms
- Tests pass locally (fast machine, suite finishes in < 15 min).
- Tests fail in CI (slower, suite takes 30+ min).
- Failures are non-deterministic - early tests pass, later tests fail.
Solution A: Check Expiry in Global Setup
// global-setup.ts
import { chromium } from '@playwright/test';
async function globalSetup() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://myapp.com/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('https://myapp.com/dashboard');
// Verify the token has enough runway
const token = await page.evaluate(() => localStorage.getItem('access_token'));
if (token) {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
if (expiresIn < 30 * 60 * 1000) {
console.warn(`Token expires in ${Math.round(expiresIn / 60000)} min - may not last the suite`);
}
}
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;Solution B: Refresh Hook Per Test File
import { test } from '@playwright/test';
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/refresh', {
headers: { Authorization: `Bearer ${process.env.REFRESH_TOKEN}` },
});
if (!response.ok()) {
throw new Error('Token refresh failed - check REFRESH_TOKEN env var');
}
});Solution C: Use a Long-Lived Test Token
Configure your auth provider to issue long-lived tokens (e.g., 24h) for the test environment only. This is the simplest fix and the one most teams settle on.
3. Multi-Role Testing
Test different user roles (admin, member, guest) in a single suite using Playwright projects with dependencies.
Config
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup-admin',
testMatch: /auth\.setup\.ts/,
use: { storageState: '.auth/admin.json' },
},
{
name: 'setup-member',
testMatch: /auth\.setup\.ts/,
use: { storageState: '.auth/member.json' },
},
{
name: 'admin-tests',
dependencies: ['setup-admin'],
use: { storageState: '.auth/admin.json' },
},
{
name: 'member-tests',
dependencies: ['setup-member'],
use: { storageState: '.auth/member.json' },
},
],
});Auth Setup File
// auth.setup.ts
import { test as setup } from '@playwright/test';
const credentials: Record<string, { email: string; password: string }> = {
'setup-admin': {
email: process.env.ADMIN_EMAIL!,
password: process.env.ADMIN_PASSWORD!,
},
'setup-member': {
email: process.env.MEMBER_EMAIL!,
password: process.env.MEMBER_PASSWORD!,
},
};
setup('authenticate', async ({ page }) => {
const creds = credentials[setup.info().project.name];
if (!creds) throw new Error(`No credentials for project: ${setup.info().project.name}`);
await page.goto('/login');
await page.getByLabel('Email').fill(creds.email);
await page.getByLabel('Password').fill(creds.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// storageState path comes from the project config
await page.context().storageState({ path: setup.info().project.use.storageState as string });
});Gotcha: testMatch Is a Regex
testMatch: /auth\.setup\.ts/ matches any file path containing auth.setup.ts. If you
have multiple setup files, be specific or use testDir per project to isolate them.
Gotcha: Dependencies Run Serially by Default
Setup projects run before dependent projects but the dependent projects themselves run in parallel. If your admin and member setup both hit the same login endpoint, ensure your test environment handles concurrent logins.
4. OAuth/SSO Testing
Navigating to /auth/google in CI hits a real Google login page. This fails because:
- CAPTCHAs block automated browsers.
- MFA prompts require human interaction.
- Google actively detects and blocks automation.
Option A: Mock the OAuth Provider
Intercept the OAuth redirect and return a fake token:
import { test, expect } from '@playwright/test';
test('login via mocked OAuth', async ({ page }) => {
// Intercept the OAuth callback that your app expects
await page.route('**/api/auth/callback/google*', async (route) => {
await route.fulfill({
status: 302,
headers: {
location: '/dashboard',
'set-cookie': 'session=fake-session-token; Path=/; HttpOnly',
},
});
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await expect(page).toHaveURL('/dashboard');
});Option B: Test-Only Bypass Endpoint
Add a server-side endpoint that exists only in test/staging environments:
import { test } from '@playwright/test';
test('login via test bypass', async ({ page }) => {
// Server-side endpoint that creates a session without OAuth
await page.goto('/api/test-auth?user=test@example.com&role=admin');
await page.waitForURL('/dashboard');
});Guard this endpoint behind an environment check and a secret header in production.
Option C: Pre-Saved storageState as CI Secret
- Log in manually in a real browser.
- Export storageState from the browser context.
- Store it as a CI secret (e.g., GitHub Actions secret).
- Decode it in global setup before tests run.
// global-setup.ts
import fs from 'fs';
async function globalSetup() {
const encoded = process.env.AUTH_STORAGE_STATE;
if (encoded) {
fs.mkdirSync('.auth', { recursive: true });
fs.writeFileSync('.auth/user.json', Buffer.from(encoded, 'base64').toString('utf-8'));
}
}
export default globalSetup;Downside: the saved state expires. Set up a scheduled CI job to refresh it.
5. API Token Auth
For API-only test suites, skip browser login entirely. Use extraHTTPHeaders on the
request context.
Config-Level Pattern
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
},
});Per-Test Override
import { test, expect } from '@playwright/test';
test('fetch user profile via API', async ({ playwright }) => {
const apiToken = process.env.API_TOKEN;
if (!apiToken) throw new Error('API_TOKEN env var is required');
const context = await playwright.request.newContext({
baseURL: 'https://api.myapp.com',
extraHTTPHeaders: {
Authorization: `Bearer ${apiToken}`,
'X-Request-ID': `test-${Date.now()}`,
},
});
const response = await context.get('/v1/me');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.email).toBeDefined();
await context.dispose();
});Gotcha: extraHTTPHeaders Apply to Every Request
Including third-party scripts, images, and analytics calls. If your token is sensitive,
use page.route() to add the header only to your API domain:
import { test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.route('**/api.myapp.com/**', async (route) => {
const headers = {
...route.request().headers(),
Authorization: `Bearer ${process.env.API_TOKEN}`,
};
await route.continue({ headers });
});
});6. .gitignore Pattern
storageState files contain live session tokens. Never commit them.
Add to .gitignore:
# Playwright auth state
.auth/Common mistake: running git add . after first test run and committing .auth/user.json
with a valid session cookie inside it. Rotate any tokens that were committed.
Also add .auth/ to .dockerignore if you build Docker images from the repo - leaked
tokens in image layers are equally dangerous.
ci-optimization.md
CI/CD Optimization for Playwright
1. Browser Caching Strategy
Playwright downloads ~500 MB per browser on npx playwright install. Without
caching, every CI run pays this cost.
GitHub Actions Cache
Cache the browser binary directory, keyed on the exact Playwright version from
your lockfile. A version mismatch between the cached binaries and the installed
@playwright/test package causes cryptic launch failures like
browserType.launch: Executable doesn't exist - the binaries look present but
are the wrong version.
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Get Playwright version
id: pw-version
run: |
PW_VERSION=$(node -e "const lock = require('./package-lock.json'); console.log(lock.packages['node_modules/@playwright/test'].version)")
echo "version=$PW_VERSION" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: cache-browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ steps.pw-version.outputs.version }}
- name: Install Playwright browsers
if: steps.cache-browsers.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- name: Install system deps (cache hit)
if: steps.cache-browsers.outputs.cache-hit == 'true'
run: npx playwright install-deps
- name: Run tests
run: npx playwright testKey detail: when the cache hits, you still need npx playwright install-deps to
install OS-level shared libraries (libnss3, libatk-bridge, etc.). The cache only
covers the browser binaries themselves.
Docker Alternative
Use the official Playwright Docker image. The tag must match the exact
version of @playwright/test in your project - there is no "latest" that works
reliably.
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.48.0-noble
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright testNo browser install or caching needed - they are baked into the image.
2. Sharding Math
--shard=N/M splits the test files (not individual tests) across M buckets.
Playwright hashes each file path and assigns it to a shard deterministically.
This means a single file containing 50 tests goes entirely to one shard. If your suite has one large file and many small ones, one shard may take 10x longer than the rest, defeating the purpose.
Fix: Keep Files Small
Aim for 10-15 tests per file maximum. If a file grows beyond that, split it by feature area.
GitHub Actions Matrix for 4 Shards
name: E2E Sharded
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run tests (shard ${{ matrix.shard }}/4)
run: npx playwright test --shard=${{ matrix.shard }}/4
- name: Upload shard report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
merge-reports:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Download all shard reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-shard-*
path: all-shard-reports
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-shard-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-report-merged
path: playwright-report/
retention-days: 7fail-fast: false is important - you want all shards to complete so you see
every failure, not just the first shard that breaks.
3. Parallel Workers vs Shards
These are two different axes of parallelism:
| Concept | Scope | Controls |
|---|---|---|
| Workers | Single machine | Threads/processes on one runner |
| Shards | Multiple machines | Separate CI jobs |
Use both together for maximum throughput:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: '50%',
fullyParallel: true,
// ...rest of config
});Then run with --shard=N/4 in CI across 4 jobs.
Shared State Warning
Workers within one machine share the same process environment. If your tests rely on a shared database, all workers hit the same connection pool. Common symptoms:
- Deadlocks under high parallelism
- Unique constraint violations from concurrent inserts
- Tests that pass with
workers: 1but fail with more
Solutions:
- Use per-worker database schemas (key off
process.env.TEST_WORKER_INDEX) - Use
test.describe.serialfor tests that mutate shared state - Mock the data layer entirely with
page.route()
4. Container Gotchas
Missing System Dependencies
npx playwright install-deps installs OS packages (libgbm, libasound2, etc.)
and requires root. In Docker, run it at build time:
FROM node:20-bookworm
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npx playwright install --with-deps
COPY . .
CMD ["npx", "playwright", "test"]Do not defer install-deps to runtime - it will fail in read-only
containers or non-root contexts.
Display Server
Playwright runs headless by default in CI. However, certain operations behave differently in headless mode:
- Clipboard API (
navigator.clipboard) may be unavailable - Drag-and-drop coordinates can shift
- Some CSS hover states do not trigger
If you need headed mode in a container without a display:
- name: Run tests headed
run: xvfb-run npx playwright test --headedxvfb-run creates a virtual framebuffer. Install it with
apt-get install -y xvfb in your Dockerfile or setup step.
Timezone
Containers default to UTC. Tests that assert on formatted dates or time-dependent logic will break if they assume a local timezone.
- name: Run tests
run: npx playwright test
env:
TZ: America/New_YorkOr set it in playwright.config.ts:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
timezoneId: 'America/New_York',
},
});The timezoneId in config controls the browser's timezone. The TZ env var
controls Node's timezone (for any server-side assertions).
5. Artifact Strategies
What to Upload and When
| Artifact | When to upload | Why |
|---|---|---|
playwright-report/ |
On failure | HTML report for debugging |
| Traces | On first retry | Detailed timeline without storage bloat |
| Screenshot diffs | Always | Let PR reviewers see visual changes |
Recommended Configuration
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: 2,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
reporter: [
['html', { open: 'never' }],
['github'],
],
});GitHub Actions Upload
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload screenshot diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshot-diffs
path: test-results/**/*-diff.png
retention-days: 7
if-no-files-found: ignoreUse retention-days: 7 - test artifacts are only useful while actively
investigating a failure. Keeping them longer wastes storage.
The if-no-files-found: ignore on screenshot diffs prevents the step from
failing when all visual tests pass (no diff images generated).
6. Speeding Up Test Runs
Quick wins, ordered by impact:
| Optimization | Time saved | How |
|---|---|---|
storageState for auth |
2-5s per test | See references/auth-patterns.md |
| Mock external APIs | 1-3s per API call | page.route('**/api.external.com/**', ...) |
fullyParallel: true |
30-60% total time | Tests within a file run concurrently |
Explicit waits over networkidle |
1-5s per navigation | await expect(locator).toBeVisible() instead |
| Sharding (section 2 above) | Linear speedup | 4 shards = ~4x faster |
Profile slow tests
PWDEBUG=1 npx playwright test --headed tests/slow-test.spec.tsReplace networkidle (waits for ALL network to stop) with explicit waits:
// Slow
await page.goto('/dashboard', { waitUntil: 'networkidle' })
// Fast - waits only for what you need
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() component-testing.md
Playwright Component Testing (CT)
What CT Mode Is
Playwright Component Testing mounts real framework components in a real browser
- not jsdom, not a simulated DOM. Each test spins up an actual Chromium, Firefox, or WebKit instance and renders your component in isolation.
Key differences from e2e testing:
- No dev server required - CT bundles your component on the fly using Vite
- Isolated rendering - each test mounts a single component, not your full app
- Real browser APIs - unlike Jest/jsdom, you get real layout, real events, real rendering
- Framework support - React, Vue, Svelte, and Solid are supported via dedicated packages
The experimental packages are:
| Framework | Package |
|---|---|
| React | @playwright/experimental-ct-react |
| Vue | @playwright/experimental-ct-vue |
| Svelte | @playwright/experimental-ct-svelte |
| Solid | @playwright/experimental-ct-solid |
Setup
Installation
npm init playwright@latest -- --ctThis scaffolds two key files:
playwright-ct.config.ts- the CT-specific Playwright configplaywright/index.html- the HTML shell where components are mountedplaywright/index.ts- bootstrap file for global styles and setup
Config Structure
import { defineConfig, devices } from '@playwright/experimental-ct-react'
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.{ts,tsx}',
use: {
ctPort: 3100,
ctViteConfig: {
resolve: {
alias: {
'@': '/src',
},
},
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
})The Mount Point - playwright/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>The Bootstrap File - playwright/index.ts
Use this file to import global CSS and register any setup logic:
// playwright/index.ts
import '../src/styles/globals.css'Writing CT Tests
Basic React Example
import { test, expect } from '@playwright/experimental-ct-react'
import { Counter } from './Counter'
test('increments count on click', async ({ mount }) => {
const component = await mount(<Counter initialCount={0} />)
await component.getByRole('button', { name: 'Increment' }).click()
await expect(component.getByText('Count: 1')).toBeVisible()
})Passing Props
import { test, expect } from '@playwright/experimental-ct-react'
import { Greeting } from './Greeting'
test('renders greeting with name', async ({ mount }) => {
const component = await mount(<Greeting name="Playwright" />)
await expect(component.getByText('Hello, Playwright!')).toBeVisible()
})Testing Slots (Vue)
import { test, expect } from '@playwright/experimental-ct-vue'
import Card from './Card.vue'
test('renders slot content', async ({ mount }) => {
const component = await mount(Card, {
slots: {
default: '<p>Card body content</p>',
},
})
await expect(component.getByText('Card body content')).toBeVisible()
})Running CT Tests
npx playwright test -c playwright-ct.config.ts
npx playwright test -c playwright-ct.config.ts --project=chromium
npx playwright test -c playwright-ct.config.ts src/Counter.ct.tsxGotchas
This is the most important section. CT has sharp edges you will hit.
1. CT Is Experimental - API May Change
The packages are namespaced under @playwright/experimental-ct-*. The API can
and does change between minor Playwright versions. Pin your version and read
the changelog before upgrading.
2. mount() Returns a Locator, Not a Component Instance
You cannot call React hooks, access component state, or invoke component methods.
The return value is a standard Playwright Locator. Interact with it the same
way you would in an e2e test - through the DOM.
// WRONG - this does not work
const component = await mount(<Counter initialCount={0} />)
component.setState({ count: 5 }) // TypeError - not a React instance
// CORRECT - interact via the DOM
const component = await mount(<Counter initialCount={0} />)
await expect(component.getByText('Count: 0')).toBeVisible()3. Bundling Uses Vite - Node Builtins Will Fail
CT uses Vite under the hood to bundle your component. If your component imports
Node builtins (fs, path, crypto) or server-only modules (next/server,
database clients), the test fails at build time.
Fix: mock those imports in playwright/index.ts or in the Vite config:
// playwright-ct.config.ts
export default defineConfig({
use: {
ctViteConfig: {
resolve: {
alias: {
'server-only-module': '/tests/mocks/server-only-module.ts',
},
},
},
},
})4. Props Must Be Serializable
You cannot pass functions as props through mount(). Functions are not
serializable across the browser-Node boundary. Use the on option for
event handlers instead:
// WRONG - function props are silently dropped
const component = await mount(<Button onClick={() => console.log('clicked')} />)
// CORRECT - use the `on` option
let clicked = false
const component = await mount(<Button />, {
on: { click: () => { clicked = true } }
})
await component.click()
expect(clicked).toBe(true)5. Global Styles Do Not Load Automatically
Your app's CSS reset, design tokens, or global styles won't be present unless
you explicitly import them in playwright/index.ts:
// playwright/index.ts
import '../src/styles/globals.css'
import '../src/styles/design-tokens.css'6. No Hot Module Replacement
Unlike vite dev, CT does not support HMR. When you change component code or
test code, you must re-run the test from scratch. There is no watch mode that
hot-reloads components in the browser.
7. Re-mounting Replaces the Component
Calling mount() a second time in the same test replaces the previously mounted
component. If you need to test unmount/remount behavior, use component.unmount():
test('cleanup on unmount', async ({ mount }) => {
const component = await mount(<Timer />)
await expect(component.getByText('Running')).toBeVisible()
await component.unmount()
// Component is removed from the DOM
})When to Use CT vs E2E vs Unit Tests
| Scenario | CT | E2E | Jest/Vitest |
|---|---|---|---|
| Component interaction (clicks, input, focus) | Yes | ||
| Visual regression on isolated components | Yes | ||
| Components hard to reach in the full app | Yes | ||
| User flows across multiple pages | Yes | ||
| Testing with a real backend/database | Yes | ||
| Pure logic, utilities, data transformations | Yes | ||
| React hooks in isolation | Yes | ||
| Fast feedback loop (sub-second) | Yes | ||
| Testing real browser layout/rendering | Yes | Yes | |
| Testing third-party script loading | Yes |
Rule of thumb: if the test is about how a component looks or responds to user interaction in a real browser, use CT. If it is about application flow, use e2e. If it is about logic, use a unit test runner.
Mocking in CT
Mocking API Calls
Use page.route() to intercept network requests, just like in e2e tests:
import { test, expect } from '@playwright/experimental-ct-react'
import { UserProfile } from './UserProfile'
test('displays user data from API', async ({ mount, page }) => {
await page.route('**/api/user/1', async (route) => {
await route.fulfill({
json: { id: 1, name: 'Test User', email: 'test@example.com' },
})
})
const component = await mount(<UserProfile userId={1} />)
await expect(component.getByText('Test User')).toBeVisible()
await expect(component.getByText('test@example.com')).toBeVisible()
})Wrapping With Context Providers
Use the hooksConfig pattern to wrap components in providers. First, register
the hook in playwright/index.ts:
// playwright/index.ts
import '../src/styles/globals.css'
import { beforeMount } from '@playwright/experimental-ct-react/hooks'
import { ThemeProvider } from '../src/providers/ThemeProvider'
import { BrowserRouter } from 'react-router-dom'
type HooksConfig = {
theme?: 'light' | 'dark'
routing?: boolean
}
beforeMount<HooksConfig>(async ({ App, hooksConfig }) => {
let app = <App />
if (hooksConfig?.routing) {
app = <BrowserRouter>{app}</BrowserRouter>
}
if (hooksConfig?.theme) {
app = <ThemeProvider theme={hooksConfig.theme}>{app}</ThemeProvider>
}
return app
})Then use hooksConfig in your tests:
import { test, expect } from '@playwright/experimental-ct-react'
import { ThemedButton } from './ThemedButton'
test('renders in dark mode', async ({ mount }) => {
const component = await mount(<ThemedButton label="Save" />, {
hooksConfig: { theme: 'dark' },
})
await expect(component.getByRole('button')).toHaveCSS(
'background-color',
'rgb(30, 30, 30)'
)
})
test('renders with router context', async ({ mount }) => {
const component = await mount(<NavLink to="/about">About</NavLink>, {
hooksConfig: { routing: true },
})
await expect(component.getByRole('link', { name: 'About' })).toHaveAttribute(
'href',
'/about'
)
})Mocking Child Components
Vite aliases can replace heavy child components with lightweight stubs:
// playwright-ct.config.ts
export default defineConfig({
use: {
ctViteConfig: {
resolve: {
alias: {
'./HeavyChart': './tests/stubs/HeavyChart.tsx',
},
},
},
},
})// tests/stubs/HeavyChart.tsx
export function HeavyChart() {
return <div data-testid="chart-stub">Chart placeholder</div>
} flaky-test-playbook.md
Flaky Test Playbook
Diagnosis flowchart
Start here when a test fails intermittently. Follow the first branch that matches.
Test is flaky
|
+-- Fails only in CI, passes locally?
| -> Check: viewport size, system fonts, timezone, locale,
| network latency to external services, Docker resource limits.
| See: Root Cause E (Viewport/font rendering)
|
+-- Fails only on specific browser(s)?
| -> WebKit: date/time pickers, file upload dialogs, scrollIntoView quirks
| Firefox: focus/blur ordering, clipboard API differences
| Chromium: usually the baseline; if only Chromium fails, check
| Chrome-specific DevTools Protocol assumptions.
| See: Root Cause G (Focus/hover races)
|
+-- Fails intermittently on the same browser?
| -> Race condition. Narrow down:
| - Appears after a navigation or route change? -> Hydration timing (B)
| - Appears after a button click that triggers an API call? -> Network race (C)
| - Appears during or right after an animation? -> Animation race (A)
|
+-- Fails only when run with other tests, passes in isolation?
| -> Shared state pollution. Check: database records, cookies,
| localStorage, service workers, global JS variables.
| See: Root Cause D (Shared mutable state)
|
+-- Fails around midnight, month boundaries, or DST transitions?
-> Time-dependent logic.
See: Root Cause F (Time-dependent tests)The 7 root causes
A. Animation/transition races
The element exists in the DOM and passes actionability checks, but it is mid-animation. Playwright clicks the element's current bounding box, which shifts by the next frame, causing a misclick or a stale position.
Fix 1 - Disable animations globally in test config:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
contextOptions: {
reducedMotion: 'reduce',
},
},
});Fix 2 - Inject a style that kills all animations:
// In a global setup or beforeEach
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});Fix 3 - Wait for a specific animation to finish before acting:
// Wait until the sidebar's CSS transition ends
await page.locator('.sidebar').evaluate((el) => {
return new Promise<void>((resolve) => {
el.addEventListener('transitionend', () => resolve(), { once: true });
});
});
await page.locator('.sidebar').getByRole('link', { name: 'Settings' }).click();B. Hydration timing
In SPA/SSR frameworks (Next.js, Nuxt, Remix, Astro), the server sends pre-rendered HTML. Playwright finds the element immediately, but the framework then hydrates the page - replacing DOM nodes. Your click fires on a dead node that is about to be swapped out.
Symptoms: click succeeds (no error) but nothing happens. The action
worked on the server-rendered shell, not the hydrated interactive version.
Fix 1 - Wait for a hydration signal:
// Next.js - wait for client-side data to be available
await page.waitForFunction(() => {
return typeof window.__NEXT_DATA__?.props !== 'undefined';
});
// Generic - add a data attribute in your app's root component after mount
// In your app: document.documentElement.setAttribute('data-hydrated', 'true')
await page.locator('[data-hydrated="true"]').waitFor();Fix 2 - Gate on an interactive assertion (proves hydration complete):
await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
await page.getByRole('button', { name: 'Add to cart' }).click();C. Network race
The test clicks a button that fires an API call, then immediately asserts on the result. The assertion runs before the response arrives and the UI updates.
Fix 1 - Wait for the response, then assert:
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/orders') && resp.status() === 200
);
await page.getByRole('button', { name: 'Place order' }).click();
await responsePromise;
await expect(page.getByRole('alert')).toHaveText('Order confirmed');Fix 2 - Mock the route for deterministic control:
await page.route('**/api/orders', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'ord_123', status: 'confirmed' }),
});
});
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('alert')).toHaveText('Order confirmed');Web-first assertions (
toHaveText,toBeVisible) auto-retry and handle most network timing without explicit waits. AddwaitForResponseonly when you need to assert on the response itself.
D. Shared mutable state
Tests share a database, a user account, a file on disk, or browser storage. Test A modifies the shared resource; test B depends on its original state.
Fix 1 - Isolate data per test with a factory:
import { test as base } from '@playwright/test';
type TestFixtures = {
testUser: { email: string; password: string };
};
export const test = base.extend<TestFixtures>({
testUser: async ({ request }, use) => {
const response = await request.post('/api/test/create-user', {
data: { prefix: 'flaky-test' },
});
const user = await response.json();
await use(user);
// Teardown: delete the user after the test
await request.delete(`/api/test/users/${user.id}`);
},
});Fix 2 - Use test.describe.configure({ mode: 'serial' }) only for wizard flows:
test.describe('checkout wizard', () => {
test.describe.configure({ mode: 'serial' });
test('step 1: add items', async ({ page }) => { /* ... */ });
test('step 2: enter address', async ({ page }) => { /* ... */ });
});E. Viewport/font rendering
Screenshot and visual regression tests fail because CI renders fonts differently, uses a different DPI, or has a different default viewport.
Fix 1 - Set explicit viewport in config:
export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1,
},
});Fix 2 - Use maxDiffPixelRatio for slight rendering differences:
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01, // allow 1% pixel difference
});F. Time-dependent tests
Tests that assert on "today", "2 hours ago", or relative dates break when they run near midnight, across DST boundaries, or in a different timezone.
Fix - Use page.clock to freeze or control time:
// Freeze time to a known date
await page.clock.install({ time: new Date('2025-06-15T10:00:00Z') });
await page.goto('/dashboard');
await expect(page.getByText('June 15, 2025')).toBeVisible();
// Fast-forward time to test expiration logic
await page.clock.install({ time: new Date('2025-06-15T09:00:00Z') });
await page.goto('/session');
await page.clock.fastForward('02:00:00'); // advance 2 hours
await expect(page.getByText('Session expired')).toBeVisible();Also set the timezone in config to avoid CI surprises:
export default defineConfig({
use: {
timezoneId: 'America/New_York',
locale: 'en-US',
},
});G. Focus/hover races
Elements that appear only on hover (tooltips, dropdown menus, action buttons on table rows) disappear before Playwright can interact with them. This happens because the mouse moves to the target element's expected position, but a layout shift or re-render moves the element between the hover and click.
Fix 1 - Hover then act immediately on the revealed element:
await page.getByRole('row', { name: 'invoice-42' }).hover();
await page.getByRole('row', { name: 'invoice-42' }).getByRole('button', { name: 'Delete' }).click();Fix 2 - Find a non-hover code path (right-click, keyboard shortcut):
await page.getByRole('row', { name: 'invoice-42' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Delete' }).click();Use force: true only as a last resort for elements behind transparent overlays.
Playwright's built-in flaky detection
Reproduce with --repeat-each
Run a suspect test many times to confirm it is actually flaky:
npx playwright test tests/checkout.spec.ts --repeat-each=20If it fails 2 out of 20 times, you have a confirmed flaky test. The report will show exactly which runs failed.
Retries and the "flaky" label
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});When a test fails on the first attempt but passes on a retry, Playwright marks it as flaky (yellow) in the HTML report - distinct from passed (green) or failed (red).
Retries mask the problem. They do not fix it. A test that needs retries to pass is a test that will eventually block a deploy at the worst time. Use retries as a safety net while you investigate, not as a permanent solution. Track the flaky label count over time and drive it to zero.
Shard large suites to find interaction effects
npx playwright test --shard=1/4
npx playwright test --shard=2/4If a test is flaky only in a specific shard, it likely has a shared state dependency with another test in that shard.
The nuclear option: test.fixme() vs test.skip()
When you cannot fix a flaky test immediately, quarantine it - but use the right annotation.
| Annotation | Meaning | When to use |
|---|---|---|
test.fixme() |
Known broken, needs investigation | Flaky tests you plan to fix |
test.skip() |
Intentionally not running | Platform exclusions, feature flags |
test('complex drag-and-drop reorder', async ({ page, browserName }) => {
test.fixme(browserName === 'webkit', 'Flaky on WebKit - see issue #1234');
// test body...
});
test('windows-only file path handling', async ({ page }) => {
test.skip(process.platform !== 'win32', 'Only relevant on Windows');
// test body...
});test.fixme() shows up in the report as a reminder. test.skip() is silent.
Never use test.skip() to hide flaky tests - it removes accountability.
Trace-based debugging
Traces are the single most effective tool for diagnosing flaky tests. They capture a complete recording of what happened during a test run.
Configure traces for flaky failures only
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry', // capture trace only when a test is retried
},
retries: 2,
});Options: 'on' (always), 'off', 'retain-on-failure', 'on-first-retry' (recommended).
Read a trace
npx playwright show-trace trace.zipKey tabs: Actions (timeline), Before/After (DOM snapshots - fastest way to spot race conditions), Network (request waterfall), Console (client errors).
For race conditions: compare the After snapshot of the last passing action with the Before snapshot of the failing action. The diff reveals what changed.
Quick reference: flaky test first-aid
| Symptom | Likely cause | First thing to try |
|---|---|---|
| Click does nothing | Hydration race (B) | await expect(button).toBeEnabled() before click |
| Element not found intermittently | Animation race (A) | Disable animations via reducedMotion |
| Wrong text in assertion | Network race (C) | Use toHaveText (auto-retries) or waitForResponse |
| Test fails after another test | Shared state (D) | Run in isolation to confirm, then isolate data |
| Screenshot diff in CI | Font/viewport (E) | Pin Docker image, set explicit viewport |
| Fails near midnight | Time-dependent (F) | Freeze clock with page.clock.install() |
| Hover menu disappears | Focus race (G) | Chain .hover() then immediate .click() |
locator-strategies.md
Locator Strategies
Locators are Playwright's mechanism for finding elements. They are lazy
(re-queried on each use), auto-waiting, and strict by default (throw if more
than one element matches). Prefer them over ElementHandle and $().
Priority order
Always use the highest-priority locator that is stable and meaningful:
| Priority | Method | When to use |
|---|---|---|
| 1 | getByRole |
Any interactive or landmark element with an ARIA role |
| 2 | getByLabel |
Form inputs associated with a <label> |
| 3 | getByPlaceholder |
Inputs without a label but with placeholder text |
| 4 | getByText |
Non-interactive elements identified by visible text |
| 5 | getByAltText |
Images with meaningful alt text |
| 6 | getByTitle |
Elements with a title attribute |
| 7 | getByTestId |
Elements with data-testid (or custom attribute) |
| 8 | locator('css=...') |
Only when none of the above are feasible |
| 9 | locator('xpath=...') |
Last resort; brittle and hard to read |
If you find yourself reaching for CSS or XPath, first ask whether the application is missing an ARIA role, label, or
data-testid. Often the right fix is improving the app's accessibility, not writing a brittle selector.
getByRole - the primary locator
getByRole matches elements by their implicit or explicit ARIA role. It
mirrors how screen readers traverse the page.
// Buttons
page.getByRole('button', { name: 'Submit' })
page.getByRole('button', { name: /cancel/i }) // regex, case-insensitive
// Links
page.getByRole('link', { name: 'Learn more' })
// Headings
page.getByRole('heading', { name: 'Dashboard', level: 1 })
// Form fields (matches by associated label text)
page.getByRole('textbox', { name: 'Email address' })
page.getByRole('combobox', { name: 'Country' })
page.getByRole('checkbox', { name: 'I agree to the terms' })
// Tables and navigation
page.getByRole('table', { name: 'Order history' })
page.getByRole('navigation', { name: 'Main menu' })
// Dialogs and regions
page.getByRole('dialog', { name: 'Confirm deletion' })
page.getByRole('alert')Common ARIA roles to know: button, link, textbox, checkbox, radio,
combobox, listbox, option, menuitem, tab, tabpanel, dialog,
alertdialog, alert, status, heading, img, list, listitem,
table, row, cell, columnheader, navigation, main, banner,
contentinfo, region, search.
getByLabel - form inputs
Matches <input>, <select>, and <textarea> by their associated <label>.
Works with for/id pairing, aria-label, and aria-labelledby.
page.getByLabel('Email')
page.getByLabel('Password', { exact: true })
page.getByLabel(/date of birth/i)getByTestId - stable escape hatch
When no semantic locator fits, add data-testid to the element and query it.
This is the right trade-off: it doesn't couple to styles, text, or structure.
<div data-testid="product-card-123">...</div>page.getByTestId('product-card-123')Configure a custom attribute name in playwright.config.ts:
export default defineConfig({
use: {
testIdAttribute: 'data-pw', // use data-pw="..." in HTML
},
})Filtering and chaining locators
Filter by text
// Find a list item containing specific text
page.getByRole('listitem').filter({ hasText: 'Alice' })
// Combine: find a row that contains a specific cell value
page.getByRole('row').filter({ hasText: 'Order #1042' })Filter by child element
// Find a card that contains a "Featured" badge
page.getByTestId('product-card').filter({
has: page.getByRole('status', { name: 'Featured' }),
})Chain locators to scope searches
// Scope to a specific section before finding elements inside it
const sidebar = page.getByRole('navigation', { name: 'Sidebar' })
await sidebar.getByRole('link', { name: 'Settings' }).click()
// Scope to a form
const loginForm = page.getByRole('form', { name: 'Login' })
await loginForm.getByLabel('Email').fill('user@example.com')
await loginForm.getByLabel('Password').fill('secret')
await loginForm.getByRole('button', { name: 'Sign in' }).click()Picking from a list
// First match
page.getByRole('listitem').first()
// Last match
page.getByRole('listitem').last()
// By index (0-based)
page.getByRole('row').nth(2)Strict mode and multiple matches
By default, if a locator matches more than one element, any action on it throws a strict mode violation. Resolve this by:
- Making the locator more specific (add
{ name: '...' }, scope to a parent) - Using
.first()/.nth(n)if order is meaningful - Using
.filter()to narrow down by text or child element - Using
.all()if you intentionally want all matches
// Wrong: throws if multiple buttons exist
await page.getByRole('button').click()
// Right: be explicit
await page.getByRole('button', { name: 'Add to cart' }).click()
// Intentionally iterate over all matches
const items = await page.getByRole('listitem').all()
for (const item of items) {
console.log(await item.textContent())
}Dynamic and asynchronous content
Wait for element to appear
// Web-first assertion retries until visible or timeout
await expect(page.getByRole('status')).toBeVisible()
await expect(page.getByRole('status')).toHaveText('Saved!')Wait for element to disappear (loading states)
// Wait for spinner to disappear before asserting on results
await expect(page.getByRole('progressbar')).not.toBeVisible()
await expect(page.getByRole('list')).toContainText('Result A')Wait for URL after navigation
await page.getByRole('button', { name: 'Go to checkout' }).click()
await page.waitForURL('**/checkout')
// OR use assertion:
await expect(page).toHaveURL(/\/checkout/)Wait for network response tied to an action
const [response] = await Promise.all([
page.waitForResponse('**/api/search'),
page.getByRole('button', { name: 'Search' }).click(),
])
expect(response.status()).toBe(200)Locating inside iframes
const frame = page.frameLocator('iframe[title="Payment form"]')
await frame.getByLabel('Card number').fill('4242 4242 4242 4242')
await frame.getByLabel('Expiry date').fill('12/26')
await frame.getByLabel('CVC').fill('123')Shadow DOM
Playwright pierces open shadow roots automatically. Standard locators work without any special configuration:
// Finds element inside shadow DOM transparently
await page.getByLabel('Username').fill('alice')Common mistakes
| Mistake | Why it fails | Fix |
|---|---|---|
page.locator('button:has-text("Submit")') |
Pseudo-selector syntax; readable but CSS-coupled | page.getByRole('button', { name: 'Submit' }) |
page.locator('[class*="btn-primary"])` |
Breaks on CSS refactors | page.getByRole('button', { name: '...' }) or getByTestId |
page.locator('text=Submit') |
Legacy shorthand; less explicit | page.getByText('Submit') or getByRole |
(await page.$('input')).type('text') |
Stale ElementHandle; legacy API |
page.getByRole('textbox').fill('text') |
page.getByText('3') on a table with many "3" values |
Matches multiple elements | Scope: row.getByRole('cell', { name: '3' }) |
Frequently Asked Questions
What is playwright-testing?
Use this skill when writing Playwright e2e tests, debugging flaky tests, setting up visual regression, testing APIs with request context, configuring CI sharding, or automating browser interactions. Triggers on Playwright, page.route, storageState, toHaveScreenshot, trace viewer, codegen, test.describe, page object model, and any task requiring Playwright test automation or flaky test diagnosis.
How do I install playwright-testing?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill playwright-testing in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support playwright-testing?
playwright-testing works with claude-code, gemini-cli, openai-codex. Install it once and use it across any supported AI coding agent.