
E2e Playwright Testing
- 243 installs
- 60 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
Author and run Playwright end-to-end tests for web apps to verify critical user flows, catch regressions, and stabilize releases before production deployment.
About
Provides Playwright end-to-end testing guidance for web applications, covering test structure, browser automation, assertion patterns, and regression coverage so teams can validate user flows before release.
- Playwright browser automation
- Critical path E2E coverage
- Regression suite structure
- CI-friendly test execution
- User-flow assertions
E2e Playwright Testing by the numbers
- 243 all-time installs (skills.sh)
- Ranked #766 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/asyrafhussin/agent-skills --skill e2e-playwright-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 243 |
|---|---|
| repo stars | ★ 60 |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
What it does
Author and run Playwright end-to-end tests for web apps to verify critical user flows, catch regressions, and stabilize releases before production deployment.
Files
E2E Playwright Testing
Patterns and conventions for reliable end-to-end browser testing with Playwright.
Comprehensive E2E testing guide for web applications. Contains 8 rules across 6 categories covering locator strategies, authentication reuse, form testing (including React/SPA-specific gotchas), assertions, test organization, reliability, and CI/CD configuration.
Stack Detection
Before writing or reviewing E2E tests, detect the project stack:
Step 1 — Check for Playwright
# Look in package.json devDependencies:
# "@playwright/test" → Playwright is installed
# Check for playwright.config.js or playwright.config.ts- If
@playwright/testis present → use Playwright patterns from this skill - If
cypressis present → this skill does not apply
Step 2 — Detect Frontend Framework
# Check package.json dependencies:
# "react" + "@inertiajs/react" → React + Inertia.js (SPA with server routing)
# "react" + "react-router" → React SPA
# "vue" → Vue.js
# "next" → Next.jsWhy this matters:
- React + Inertia.js: Use
waitForURLnotwaitForLoadState('networkidle')— Inertia useshistory.pushState - React controlled inputs:
fill()works for text butkeyboard.type()needed for date/time - SPA navigation: Page doesn't do full reload — assertions must wait for content, not network idle
Step 3 — Detect Auth Pattern
# Check for session-based (Laravel Sanctum) or token-based (JWT) auth
# Session: storageState saves cookies
# Token: may need to save tokens to localStorageStep 4 — Detect Rate Limiting
# Check routes for throttle middleware
# If present in dev, tests will hit 429 after ~5 login attempts
# Solution: production-only throttle or increase limits in test env---
Metadata
- Version: 1.0.0
- Rule Count: 8 rules across 6 categories
- License: MIT
When to Apply
- Writing or reviewing Playwright E2E tests
- Setting up E2E testing for a new project
- Debugging flaky browser tests
- Testing form submissions, authentication flows, or user interactions
- Choosing locator strategies for elements
- Configuring Playwright for CI/CD
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Locators | CRITICAL | loc |
| 2 | Authentication | CRITICAL | auth |
| 3 | Assertions | HIGH | assert |
| 4 | Forms & Inputs | HIGH | form |
| 5 | Test Organization | MEDIUM | org |
| 6 | Reliability | MEDIUM | rel |
Quick Reference
1. Locators (CRITICAL)
loc-prefer-role-locators- Use getByRole/getByLabel over CSS selectorsloc-strict-mode- Handle strict mode violations with exact/first/scoped
2. Authentication (CRITICAL)
auth-storage-state- Reuse login state via setup project pattern
3. Assertions (HIGH)
assert-web-first- Use auto-retrying expect(locator) assertions
4. Forms & Inputs (HIGH)
form-react-date-inputs- Use keyboard.type() for date/time in React appsform-custom-checkboxes- Handle sr-only checkbox components
5. Test Organization (MEDIUM)
org-mirror-routes- Directory structure mirrors route groups
6. Reliability (MEDIUM)
rel-no-wait-for-timeout- Never use arbitrary waitForTimeout
Essential Patterns
Locator Priority
// 1st: Role (best — mirrors accessibility)
page.getByRole('button', { name: 'Submit' })
page.getByRole('tab', { name: 'Network' })
page.getByRole('heading', { name: 'Dashboard' })
// 2nd: Label (for form fields)
page.getByLabel('Email')
// 3rd: Text (for static content, use exact when ambiguous)
page.getByText('Welcome', { exact: true })
// 4th: ID (for inputs without proper labels)
page.locator('#password')
// Last resort: CSS selector
page.locator('button.submit-btn')Auth Setup Pattern
// Setup project logs in once per role, all tests reuse the state
// 3 logins for 90+ tests (not 90 logins)
// In test files:
test.use({ role: 'customer' });
test('shows dashboard', async ({ authedPage: page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});React Date Input Gotcha
// fill() doesn't trigger React onChange for date/time inputs
// Use keyboard.type() instead:
await dateInput.click();
await page.keyboard.type('16042026'); // DDMMYYYY
await timeInput.click();
await page.keyboard.type('1000AM'); // 10:00 AMStrict Mode Fix
// Bad: matches "Verified" AND "Unverified"
page.getByRole('button', { name: 'Verified' })
// Good: exact match
page.getByRole('button', { name: 'Verified', exact: true })
// Bad: matches heading AND breadcrumb
page.getByText('POS Demo')
// Good: use role to disambiguate
page.getByRole('heading', { name: 'POS Demo' })Parallelism Decision
// Default Playwright: fullyParallel: true
// Use this when: tests are independent, each test creates its own data
// Override to sequential: workers: 1
// Use this when: tests share a seeded database with mutable state
// Without this, one test's buy transaction changes the balance another test expectsConfiguration Template
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: false, // true if tests are independent
workers: 1, // increase if no shared mutable state
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'auth-setup', testMatch: /auth\.setup\.js$/, testDir: './auth' },
{ name: 'chrome', use: { ...devices['Desktop Chrome'] }, dependencies: ['auth-setup'] },
{ name: 'mobile', use: { ...devices['iPhone 14'] }, dependencies: ['auth-setup'], testMatch: /responsive/ },
],
});References
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
E2E Playwright Testing
Patterns and conventions for reliable end-to-end browser testing with Playwright.
Comprehensive E2E testing guide for web applications. Contains 8 rules across 6 categories covering locator strategies, authentication reuse, form testing (including React/SPA-specific gotchas), assertions, test organization, reliability, and CI/CD configuration.
Stack Detection
Before writing or reviewing E2E tests, detect the project stack:
Step 1 — Check for Playwright
# Look in package.json devDependencies:
# "@playwright/test" → Playwright is installed
# Check for playwright.config.js or playwright.config.ts- If
@playwright/testis present → use Playwright patterns from this skill - If
cypressis present → this skill does not apply
Step 2 — Detect Frontend Framework
# Check package.json dependencies:
# "react" + "@inertiajs/react" → React + Inertia.js (SPA with server routing)
# "react" + "react-router" → React SPA
# "vue" → Vue.js
# "next" → Next.jsWhy this matters:
- React + Inertia.js: Use
waitForURLnotwaitForLoadState('networkidle')— Inertia useshistory.pushState - React controlled inputs:
fill()works for text butkeyboard.type()needed for date/time - SPA navigation: Page doesn't do full reload — assertions must wait for content, not network idle
Step 3 — Detect Auth Pattern
# Check for session-based (Laravel Sanctum) or token-based (JWT) auth
# Session: storageState saves cookies
# Token: may need to save tokens to localStorageStep 4 — Detect Rate Limiting
# Check routes for throttle middleware
# If present in dev, tests will hit 429 after ~5 login attempts
# Solution: production-only throttle or increase limits in test env---
Metadata
- Version: 1.0.0
- Rule Count: 8 rules across 6 categories
- License: MIT
When to Apply
- Writing or reviewing Playwright E2E tests
- Setting up E2E testing for a new project
- Debugging flaky browser tests
- Testing form submissions, authentication flows, or user interactions
- Choosing locator strategies for elements
- Configuring Playwright for CI/CD
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Locators | CRITICAL | loc |
| 2 | Authentication | CRITICAL | auth |
| 3 | Assertions | HIGH | assert |
| 4 | Forms & Inputs | HIGH | form |
| 5 | Test Organization | MEDIUM | org |
| 6 | Reliability | MEDIUM | rel |
Quick Reference
1. Locators (CRITICAL)
loc-prefer-role-locators- Use getByRole/getByLabel over CSS selectorsloc-strict-mode- Handle strict mode violations with exact/first/scoped
2. Authentication (CRITICAL)
auth-storage-state- Reuse login state via setup project pattern
3. Assertions (HIGH)
assert-web-first- Use auto-retrying expect(locator) assertions
4. Forms & Inputs (HIGH)
form-react-date-inputs- Use keyboard.type() for date/time in React appsform-custom-checkboxes- Handle sr-only checkbox components
5. Test Organization (MEDIUM)
org-mirror-routes- Directory structure mirrors route groups
6. Reliability (MEDIUM)
rel-no-wait-for-timeout- Never use arbitrary waitForTimeout
Essential Patterns
Locator Priority
// 1st: Role (best — mirrors accessibility)
page.getByRole('button', { name: 'Submit' })
page.getByRole('tab', { name: 'Network' })
page.getByRole('heading', { name: 'Dashboard' })
// 2nd: Label (for form fields)
page.getByLabel('Email')
// 3rd: Text (for static content, use exact when ambiguous)
page.getByText('Welcome', { exact: true })
// 4th: ID (for inputs without proper labels)
page.locator('#password')
// Last resort: CSS selector
page.locator('button.submit-btn')Auth Setup Pattern
// Setup project logs in once per role, all tests reuse the state
// 3 logins for 90+ tests (not 90 logins)
// In test files:
test.use({ role: 'customer' });
test('shows dashboard', async ({ authedPage: page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});React Date Input Gotcha
// fill() doesn't trigger React onChange for date/time inputs
// Use keyboard.type() instead:
await dateInput.click();
await page.keyboard.type('16042026'); // DDMMYYYY
await timeInput.click();
await page.keyboard.type('1000AM'); // 10:00 AMStrict Mode Fix
// Bad: matches "Verified" AND "Unverified"
page.getByRole('button', { name: 'Verified' })
// Good: exact match
page.getByRole('button', { name: 'Verified', exact: true })
// Bad: matches heading AND breadcrumb
page.getByText('POS Demo')
// Good: use role to disambiguate
page.getByRole('heading', { name: 'POS Demo' })Parallelism Decision
// Default Playwright: fullyParallel: true
// Use this when: tests are independent, each test creates its own data
// Override to sequential: workers: 1
// Use this when: tests share a seeded database with mutable state
// Without this, one test's buy transaction changes the balance another test expectsConfiguration Template
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: false, // true if tests are independent
workers: 1, // increase if no shared mutable state
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'auth-setup', testMatch: /auth\.setup\.js$/, testDir: './auth' },
{ name: 'chrome', use: { ...devices['Desktop Chrome'] }, dependencies: ['auth-setup'] },
{ name: 'mobile', use: { ...devices['iPhone 14'] }, dependencies: ['auth-setup'], testMatch: /responsive/ },
],
});References
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
---
Full Rule Reference
---
--- title: Prefer Role-Based Locators impact: CRITICAL impactDescription: "test resilience to UI changes" tags: locators, accessibility, getByRole, best-practice ---
Prefer Role-Based Locators
Impact: CRITICAL (test resilience to UI changes)
Use getByRole, getByLabel, and getByText over CSS selectors or test IDs. Role-based locators mirror how users and assistive technology interact with the page, making tests resilient to refactoring.
Incorrect
// Bad: CSS selectors break when classes change
await page.locator('.btn-primary').click();
await page.locator('#submit-form').click();
await page.locator('div > form > button:nth-child(2)').click();
// Bad: data-testid couples tests to implementation
await page.locator('[data-testid="login-button"]').click();Problems:
- CSS classes are styling concerns, not behavior contracts
- Selectors break on every UI redesign
- Tests don't verify accessibility (screen readers can't find
.btn-primary) data-testidadds noise to production HTML
Correct
// Good: Role-based — mirrors user interaction
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('tab', { name: 'Network' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).toBeVisible();
// Good: Label-based — matches form field labels
await page.getByLabel('Email').fill('user@example.com');
// Good: Text-based — for non-interactive content
await expect(page.getByText('Welcome back')).toBeVisible();
// Good: Use exact when text is ambiguous
await page.getByRole('button', { name: 'Verified', exact: true });Priority order: 1. getByRole — buttons, tabs, headings, links, checkboxes 2. getByLabel — form inputs with labels 3. getByText — static content, with { exact: true } when needed 4. locator('#id') — for inputs without labels (e.g., #password) 5. CSS selectors — last resort for complex DOM structures
Reference: Playwright Docs — Locators
---
--- title: Handle Strict Mode Violations impact: CRITICAL impactDescription: "prevents test failures from ambiguous selectors" tags: locators, strict-mode, exact, disambiguation ---
Handle Strict Mode Violations
Impact: CRITICAL (prevents test failures from ambiguous selectors)
Playwright runs in strict mode by default — locators that match multiple elements throw an error. Handle this proactively with { exact: true }, .first(), or scoped locators.
Incorrect
// Bad: "Verified" also matches "Unverified"
await page.getByRole('button', { name: 'Verified' }).click();
// Bad: "POS Demo" appears in heading AND breadcrumb
await expect(page.getByText('POS Demo')).toBeVisible();
// Bad: "Item Type" appears in form AND summary
await expect(page.getByText('Item Type')).toBeVisible();
// Bad: "Convert" matches tab name AND submit button
await page.getByRole('button', { name: /convert/i }).click();Problems:
- Strict mode throws: "resolved to 2 elements"
- Tests fail with confusing error messages
- Different elements have the same visible text
Correct
// Good: exact match prevents substring matching
await page.getByRole('button', { name: 'Verified', exact: true }).click();
// Good: use role to disambiguate heading vs breadcrumb
await expect(page.getByRole('heading', { name: 'POS Demo' })).toBeVisible();
// Good: scope to a specific container
await expect(page.locator('.form-section').getByText('Item Type')).toBeVisible();
// Good: use specific button text
await page.getByRole('button', { name: /convert 10 sp/i }).click();
// Good: use .first() when you know the first match is correct
await page.getByText('Dashboard').first().click();
// Good: filter with parent context
await page.locator('button').filter({ hasText: 'Arif Azmi' }).first().click();Strategies (in order of preference): 1. { exact: true } — prevents "Verified" matching "Unverified" 2. getByRole('heading', ...) — disambiguates heading from breadcrumb/sidebar 3. Scoped locator — container.getByText(...) limits search area 4. .first() — when the first match is always correct 5. .filter({ hasText: ... }) — combine with other locators
Reference: Playwright Docs — Strict Mode
---
--- title: Reuse Authentication with Storage State impact: CRITICAL impactDescription: "eliminates login overhead and rate-limit failures" tags: authentication, storage-state, setup-project, session ---
Reuse Authentication with Storage State
Impact: CRITICAL (eliminates login overhead and rate-limit failures)
Log in once per role in a setup project, save browser state to JSON files, and reuse via storageState in all tests. Never log in per-test.
Incorrect
// Bad: Logging in at the start of every test
test('dashboard shows data', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.locator('#password').fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard');
// Now the actual test...
await expect(page.getByText('Welcome')).toBeVisible();
});
test('profile shows name', async ({ page }) => {
// Same login repeated — slow, hits rate limits
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
// ...
});Problems:
- Each test wastes 1-2 seconds on login
- Login rate limits (429) block the entire suite after ~5 tests
- Login failures cascade — every test fails, not just auth tests
Correct
// auth/auth.setup.js — runs once before all tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const AUTH_DIR = path.join(import.meta.dirname, '..', '.auth');
const users = [
{ name: 'customer', file: 'customer.json', email: 'customer@example.com' },
{ name: 'admin', file: 'admin.json', email: 'admin@example.com' },
];
for (const user of users) {
setup(`authenticate as ${user.name}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.locator('#password').fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: path.join(AUTH_DIR, user.file) });
});
}// fixtures/test.js — custom fixture for role-based auth
import { test as base } from '@playwright/test';
import path from 'path';
const AUTH_DIR = path.join(import.meta.dirname, '..', '.auth');
export const test = base.extend({
role: ['customer', { option: true }],
authedPage: async ({ browser, role }, use) => {
const context = await browser.newContext({
storageState: path.join(AUTH_DIR, `${role}.json`),
});
const page = await context.newPage();
await use(page);
await context.close();
},
});// specs/dashboard.spec.js — tests get pre-authenticated pages
import { test, expect } from '../fixtures/test.js';
test.describe('Dashboard', () => {
test.use({ role: 'customer' });
test('shows welcome message', async ({ authedPage: page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
});// playwright.config.js — setup project runs first
export default defineConfig({
projects: [
{ name: 'auth-setup', testMatch: /auth\.setup\.js$/, testDir: './auth' },
{ name: 'chrome', use: { ...devices['Desktop Chrome'] }, dependencies: ['auth-setup'] },
],
});Benefits:
- Login happens once per role (3 logins for 90+ tests)
- No rate-limit issues even with strict throttling
- Each test starts instantly with pre-loaded cookies
- Role switching is a simple
test.use({ role: 'admin' })
Reference: Playwright Docs — Authentication
---
--- title: Use Web-First Assertions impact: HIGH impactDescription: "eliminates flaky timing-dependent tests" tags: assertions, auto-retry, web-first, expect ---
Use Web-First Assertions
Impact: HIGH (eliminates flaky timing-dependent tests)
Playwright's expect(locator) assertions auto-retry until the condition is met or timeout. Never use manual waits or DOM queries followed by manual checks.
Incorrect
// Bad: Manual DOM query — no auto-retry, race condition
const button = await page.$('.submit-btn');
expect(button).not.toBeNull();
// Bad: Using waitForTimeout — arbitrary delay, still flaky
await page.waitForTimeout(2000);
const text = await page.textContent('.status');
expect(text).toBe('Success');
// Bad: isVisible() returns immediately — no retry
const visible = await page.locator('.toast').isVisible();
expect(visible).toBe(true);
// Bad: Checking count for existence
await expect(page.locator('.item')).toHaveCount(1);Problems:
page.$()returns null if element hasn't rendered yetwaitForTimeoutwastes time on fast environments, still flaky on slow onesisVisible()is a snapshot — doesn't wait for animations or lazy renderingtoHaveCount(1)is fragile —toBeVisible()expresses intent better
Correct
// Good: Web-first assertions auto-retry with configurable timeout
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog')).not.toBeVisible();
// Good: Custom timeout for slow operations
await expect(page.getByText('Uploaded')).toBeVisible({ timeout: 10_000 });
// Good: Text content assertions
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.locator('#balance')).toContainText('RM');
// Good: Attribute assertions
await expect(page.locator('input')).toHaveValue('100');
await expect(page.locator('html')).toHaveClass(/dark/);
// Good: URL assertion after navigation
await expect(page).toHaveURL(/\/dashboard/);Common assertions:
toBeVisible()/toBeHidden()— element visibilitytoBeEnabled()/toBeDisabled()— button/input statetoHaveText()/toContainText()— text contenttoHaveValue()— input valuetoHaveAttribute()— HTML attributetoHaveURL()— page URLtoHaveClass()— CSS class
Reference: Playwright Docs — Assertions
---
--- title: Handle Custom Checkbox Components impact: HIGH impactDescription: "prevents click-no-effect bugs on styled checkboxes" tags: forms, checkbox, sr-only, custom-components, react ---
Handle Custom Checkbox Components
Impact: HIGH (prevents click-no-effect bugs on styled checkboxes)
Custom checkbox components (like those using Tailwind's sr-only pattern) hide the native <input> and render a styled <div>. Playwright's .check() may not trigger React's onChange on hidden inputs. Multiple <label htmlFor> elements pointing to the same input cause double-toggle (check then uncheck).
Incorrect
// Bad: .check() on sr-only input — may not trigger React onChange
await page.locator('#terms').check();
// Bad: .check({ force: true }) — bypasses visibility but still no React event
await page.locator('#terms').check({ force: true });
// Bad: clicking a label when there are TWO labels for the same input
// <Checkbox id="terms" /> ← has its own <label> wrapper
// <label htmlFor="terms">I agree</label> ← second label
// Clicking either label toggles twice = no change
await page.locator('label[for="terms"]').click();Problems:
sr-onlyinputs are invisible —.check()can't find themforce: trueclicks the element but React doesn't see the event- Two
<label htmlFor>elements cause double-toggle (browser behavior) - Test shows checkbox as "checked" visually but React state is
false
Correct
// Good: Click the component's own <label> wrapper (contains the visual checkbox)
await page.locator('label:has(#terms)').click();
// Good: Click the T&C text that toggles via onClick handler
await page.getByText('I agree to the terms').click();
// Good: For components with only one label, click the label directly
await page.locator('label[for="remember"]').click();
// Good: Verify the checkbox state with web-first assertion
await expect(page.locator('#terms')).toBeChecked();Fix the component if two labels exist (code bug):
// Bad: Two labels for same input — browser toggles twice
<Checkbox id="terms" />
<label htmlFor="terms">I agree</label>
// Good: Use <span> for the text, let Checkbox own the label
<Checkbox id="terms" />
<span onClick={() => setAgreed(v => !v)}>I agree</span>Reference: MDN — Label Element
---
--- title: Handle React Controlled Date and Time Inputs impact: HIGH impactDescription: "prevents silent form state desync" tags: forms, date-input, time-input, react, controlled-components ---
Handle React Controlled Date and Time Inputs
Impact: HIGH (prevents silent form state desync)
Playwright's fill() sets the DOM value but does not always trigger React's onChange for date and time inputs. The HTML value updates but React state stays empty, causing form validation to fail silently.
Incorrect
// Bad: fill() updates DOM but React state stays empty
await page.locator('#date').fill('2026-04-14');
await page.locator('#time').fill('10:00');
// The submit button remains disabled because React state is:
// { preferred_date: '', preferred_time: '' }
// Even though the inputs visually show the valuesProblems:
fill()dispatches events that React date/time inputs may not listen to- Form appears filled visually but React state is empty
- Submit button stays disabled, test times out with confusing failure
- Only affects date/time input types — text inputs work fine with
fill()
Correct
// Good: Use keyboard.type() to simulate real user input
// Date inputs accept digits in the browser's locale format (DD/MM/YYYY or MM/DD/YYYY)
const dateInput = page.locator('#preferred-date');
await dateInput.scrollIntoViewIfNeeded();
await dateInput.click();
await page.keyboard.type('16042026'); // DDMMYYYY for DD/MM/YYYY locale
// Time inputs accept digits + AM/PM
const timeInput = page.locator('#preferred-time');
await timeInput.click();
await page.keyboard.type('1000AM'); // 10:00 AM
// Brief wait needed: keyboard.type() on date inputs triggers React state
// updates asynchronously — 200ms allows the controlled component to sync
await page.waitForTimeout(200);// Alternative: Use evaluate to set value + dispatch native events
await page.evaluate((val) => {
const el = document.getElementById('preferred-date');
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, '2026-04-16');// For standard text/number inputs, fill() works correctly
await page.locator('#amount').fill('100'); // Works
await page.locator('#email').fill('a@b.com'); // Works
await page.locator('#weight').fill('25.5'); // WorksWhen to use `keyboard.type()` vs `fill()`:
fill()— text, number, email, password, search inputskeyboard.type()— date, time, datetime-local inputs in React apps- Both require
.click()first to focus the input
Reference: Playwright Docs — Input
---
--- title: Mirror Route Structure in Test Organization impact: MEDIUM impactDescription: "scalable test organization as app grows" tags: organization, structure, routes, feature-based ---
Mirror Route Structure in Test Organization
Impact: MEDIUM (scalable test organization as app grows)
Organize E2E test files to mirror your application's route groups. This makes tests discoverable and scalable as features are added.
Incorrect
tests/e2e/specs/
test1.spec.js # What does this test?
test2.spec.js # No grouping
all-tests.spec.js # One giant file with 200 tests
login-and-buy.spec.js # Mixed concernsProblems:
- Can't find the test for a specific feature
- Adding a new feature means figuring out which file to edit
- One giant file causes merge conflicts
- Mixed concerns make failures hard to diagnose
Correct
tests/e2e/specs/
auth/
login.spec.js
register.spec.js
customer/ # matches role:customer routes
egold/
buy.spec.js
sell.spec.js
redeem.spec.js
kyc-gate.spec.js
gold-saving/
new-agreement.spec.js
special-trade-in/
new-agreement.spec.js
semantan-point/
convert.spec.js
team/
member-profile.spec.js
admin/ # matches role:admin routes
customers/
kyc-approve.spec.js
egold/
approve-reject.spec.js
pos-demo/
transaction.spec.js
management/ # matches role:superadmin routes
branches/
create-edit.spec.js
gold-price/
add-price.spec.js
ui/ # cross-cutting concerns
dark-mode.spec.js
responsive.spec.js
navigation.spec.js
global-search.spec.jsNaming convention:
- Folders match route prefixes (
/admin/*→admin/) - Files match the feature or page name
ui/folder for cross-cutting concerns (theme, responsive, navigation)- One
describeblock per file, focused on one page/feature
Reference: Playwright Docs — Test Organization
---
--- title: Never Use Arbitrary waitForTimeout impact: MEDIUM impactDescription: "prevents slow and flaky tests" tags: reliability, waiting, auto-wait, flaky-tests ---
Never Use Arbitrary waitForTimeout
Impact: MEDIUM (prevents slow and flaky tests)
Playwright auto-waits for elements before interacting. Adding waitForTimeout is almost always a sign of a missing assertion or incorrect locator strategy.
Incorrect
// Bad: Waiting for toast to appear
await page.click('#submit');
await page.waitForTimeout(3000);
await expect(page.getByText('Success')).toBeVisible();
// Bad: Waiting for page transition
await page.click('a[href="/dashboard"]');
await page.waitForTimeout(2000);
await expect(page.getByRole('heading')).toBeVisible();
// Bad: Waiting for React re-render
await page.fill('#amount', '100');
await page.waitForTimeout(1000);
const isEnabled = await page.locator('#submit').isEnabled();Problems:
- 3 second wait runs on every test even when page loads in 200ms
- On slow CI, 2 seconds may not be enough — still flaky
- Accumulates across test suite (30 tests x 3s = 90s wasted)
Correct
// Good: Wait for the element directly — auto-retries until visible
await page.click('#submit');
await expect(page.getByText('Success')).toBeVisible({ timeout: 8_000 });
// Good: Wait for URL change after navigation
await page.click('a[href="/dashboard"]');
await page.waitForURL('**/dashboard');
await expect(page.getByRole('heading')).toBeVisible();
// Good: Wait for button state to change
await page.fill('#amount', '100');
await expect(page.locator('#submit')).toBeEnabled({ timeout: 3_000 });
// Good: Wait for network request to complete
await Promise.all([
page.waitForResponse('/api/data'),
page.click('#load-data'),
]);Acceptable uses of waitForTimeout (rare):
- After
keyboard.type()on date inputs — React needs a tick to process (200ms max) - After clipboard operations — OS-level delay
- Never more than 500ms, and always with a comment explaining why
Reference: Playwright Docs — Auto-waiting
{
"name": "E2E Playwright Testing",
"version": "1.0.0",
"description": "End-to-end testing with Playwright for web applications. Covers locator strategies, authentication reuse, form testing, assertions, test organization, and debugging. Triggers on tasks involving E2E tests, browser testing, Playwright, form submission testing, or user flow testing.",
"license": "MIT",
"author": {
"name": "Agent Skills Contributors",
"url": "https://github.com/AsyrafHussin/agent-skills"
},
"categories": [
{
"name": "Locators",
"prefix": "loc",
"impact": "CRITICAL",
"ruleCount": 2
},
{
"name": "Authentication",
"prefix": "auth",
"impact": "CRITICAL",
"ruleCount": 1
},
{
"name": "Assertions",
"prefix": "assert",
"impact": "HIGH",
"ruleCount": 1
},
{
"name": "Forms & Inputs",
"prefix": "form",
"impact": "HIGH",
"ruleCount": 2
},
{
"name": "Test Organization",
"prefix": "org",
"impact": "MEDIUM",
"ruleCount": 1
},
{
"name": "Reliability",
"prefix": "rel",
"impact": "MEDIUM",
"ruleCount": 1
}
],
"references": [
"https://playwright.dev",
"https://playwright.dev/docs/best-practices",
"https://playwright.dev/docs/locators",
"https://playwright.dev/docs/auth",
"https://playwright.dev/docs/test-assertions"
],
"keywords": [
"playwright",
"e2e",
"end-to-end",
"browser-testing",
"automation",
"locators",
"assertions",
"form-testing",
"react",
"inertia",
"spa"
],
"keyFeatures": [
"Stack detection (Playwright vs Cypress, React/Inertia/Vue, session vs token auth)",
"Role-based locator priority (getByRole > getByLabel > getByText > CSS)",
"Auth storageState reuse via setup project pattern",
"React date/time input handling with keyboard.type()",
"Custom sr-only checkbox interaction patterns",
"Strict mode disambiguation strategies",
"Parallelism decision guide (shared DB vs independent tests)"
],
"rulesTotal": 8,
"lastUpdated": "2026-04-13"
}
E2E Playwright Testing
End-to-end testing patterns with Playwright for web applications.
Overview
8 rules across 6 categories covering locator strategies, authentication reuse, form testing (including React-specific gotchas), assertions, test organization, and reliability.
Key Patterns
- Locators: Role-based over CSS selectors, strict mode handling
- Auth: Setup project + storageState for login reuse
- Forms: keyboard.type() for React date/time inputs, sr-only checkbox handling
- Reliability: No arbitrary waits, single worker for shared DB, SPA navigation
Based On
Patterns derived from real E2E implementation on a Laravel + React + Inertia.js gold trading platform (35+ pages, 94 Playwright tests). Bugs discovered during implementation informed the rules:
1. CSRF 419 failures from rate limiting during auth 2. React date inputs not triggering onChange with fill() 3. Double-label checkbox toggling twice (check + uncheck) 4. Strict mode violations from ambiguous text locators
Installation
Copy the e2e-playwright-testing folder to your .claude/skills/ directory.
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Locators (loc)
Impact: CRITICAL Description: Choosing the right locators is the foundation of reliable E2E tests. Prefer user-facing attributes (role, label, text) over implementation details (CSS classes, IDs). Use getByRole as the primary strategy, getByLabel/getByText as secondary, and CSS/XPath only as a last resort. Handle strict mode violations with { exact: true } or scoped locators.
2. Authentication (auth)
Impact: CRITICAL Description: Authentication is the most common E2E bottleneck. Log in once per role using the setup project pattern, save browser state (cookies + localStorage) to JSON files, and reuse via storageState in test contexts. Never log in per-test — it wastes time and creates flaky rate-limit failures.
3. Assertions (assert)
Impact: HIGH Description: Use Playwright's web-first assertions (expect(locator)) which auto-retry until the condition is met or timeout. Never use page.$(selector) + manual checks. Assert on user-visible state (text content, visibility, enabled/disabled), not internal implementation.
4. Forms & Inputs (form)
Impact: HIGH Description: Form testing catches the most real bugs in web apps. Handle React controlled inputs correctly — fill() works for text but keyboard.type() is needed for date/time pickers. Custom checkbox components using sr-only patterns need special click handling to avoid double-toggle bugs.
5. Test Organization (org)
Impact: MEDIUM Description: Organize tests by feature area mirroring your route structure. Use custom fixtures for role-based authentication. Keep test files focused — one describe block per page or feature.
6. Reliability (rel)
Impact: MEDIUM Description: Flaky tests destroy CI confidence. Never use arbitrary waitForTimeout — use auto-waiting locators and web-first assertions instead. Run tests sequentially when they share mutable database state.
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of why this rule matters.
Incorrect
// Bad exampleProblems:
- Problem 1
- Problem 2
Correct
// Good exampleBenefits:
- Benefit 1
- Benefit 2
Reference: Link
Use Web-First Assertions
Impact: HIGH (eliminates flaky timing-dependent tests)
Playwright's expect(locator) assertions auto-retry until the condition is met or timeout. Never use manual waits or DOM queries followed by manual checks.
Incorrect
// Bad: Manual DOM query — no auto-retry, race condition
const button = await page.$('.submit-btn');
expect(button).not.toBeNull();
// Bad: Using waitForTimeout — arbitrary delay, still flaky
await page.waitForTimeout(2000);
const text = await page.textContent('.status');
expect(text).toBe('Success');
// Bad: isVisible() returns immediately — no retry
const visible = await page.locator('.toast').isVisible();
expect(visible).toBe(true);
// Bad: Checking count for existence
await expect(page.locator('.item')).toHaveCount(1);Problems:
page.$()returns null if element hasn't rendered yetwaitForTimeoutwastes time on fast environments, still flaky on slow onesisVisible()is a snapshot — doesn't wait for animations or lazy renderingtoHaveCount(1)is fragile —toBeVisible()expresses intent better
Correct
// Good: Web-first assertions auto-retry with configurable timeout
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog')).not.toBeVisible();
// Good: Custom timeout for slow operations
await expect(page.getByText('Uploaded')).toBeVisible({ timeout: 10_000 });
// Good: Text content assertions
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.locator('#balance')).toContainText('RM');
// Good: Attribute assertions
await expect(page.locator('input')).toHaveValue('100');
await expect(page.locator('html')).toHaveClass(/dark/);
// Good: URL assertion after navigation
await expect(page).toHaveURL(/\/dashboard/);Common assertions:
toBeVisible()/toBeHidden()— element visibilitytoBeEnabled()/toBeDisabled()— button/input statetoHaveText()/toContainText()— text contenttoHaveValue()— input valuetoHaveAttribute()— HTML attributetoHaveURL()— page URLtoHaveClass()— CSS class
Reference: Playwright Docs — Assertions
Reuse Authentication with Storage State
Impact: CRITICAL (eliminates login overhead and rate-limit failures)
Log in once per role in a setup project, save browser state to JSON files, and reuse via storageState in all tests. Never log in per-test.
Incorrect
// Bad: Logging in at the start of every test
test('dashboard shows data', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.locator('#password').fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard');
// Now the actual test...
await expect(page.getByText('Welcome')).toBeVisible();
});
test('profile shows name', async ({ page }) => {
// Same login repeated — slow, hits rate limits
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
// ...
});Problems:
- Each test wastes 1-2 seconds on login
- Login rate limits (429) block the entire suite after ~5 tests
- Login failures cascade — every test fails, not just auth tests
Correct
// auth/auth.setup.js — runs once before all tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const AUTH_DIR = path.join(import.meta.dirname, '..', '.auth');
const users = [
{ name: 'customer', file: 'customer.json', email: 'customer@example.com' },
{ name: 'admin', file: 'admin.json', email: 'admin@example.com' },
];
for (const user of users) {
setup(`authenticate as ${user.name}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.locator('#password').fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: path.join(AUTH_DIR, user.file) });
});
}// fixtures/test.js — custom fixture for role-based auth
import { test as base } from '@playwright/test';
import path from 'path';
const AUTH_DIR = path.join(import.meta.dirname, '..', '.auth');
export const test = base.extend({
role: ['customer', { option: true }],
authedPage: async ({ browser, role }, use) => {
const context = await browser.newContext({
storageState: path.join(AUTH_DIR, `${role}.json`),
});
const page = await context.newPage();
await use(page);
await context.close();
},
});// specs/dashboard.spec.js — tests get pre-authenticated pages
import { test, expect } from '../fixtures/test.js';
test.describe('Dashboard', () => {
test.use({ role: 'customer' });
test('shows welcome message', async ({ authedPage: page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
});// playwright.config.js — setup project runs first
export default defineConfig({
projects: [
{ name: 'auth-setup', testMatch: /auth\.setup\.js$/, testDir: './auth' },
{ name: 'chrome', use: { ...devices['Desktop Chrome'] }, dependencies: ['auth-setup'] },
],
});Benefits:
- Login happens once per role (3 logins for 90+ tests)
- No rate-limit issues even with strict throttling
- Each test starts instantly with pre-loaded cookies
- Role switching is a simple
test.use({ role: 'admin' })
Reference: Playwright Docs — Authentication
Handle Custom Checkbox Components
Impact: HIGH (prevents click-no-effect bugs on styled checkboxes)
Custom checkbox components (like those using Tailwind's sr-only pattern) hide the native <input> and render a styled <div>. Playwright's .check() may not trigger React's onChange on hidden inputs. Multiple <label htmlFor> elements pointing to the same input cause double-toggle (check then uncheck).
Incorrect
// Bad: .check() on sr-only input — may not trigger React onChange
await page.locator('#terms').check();
// Bad: .check({ force: true }) — bypasses visibility but still no React event
await page.locator('#terms').check({ force: true });
// Bad: clicking a label when there are TWO labels for the same input
// <Checkbox id="terms" /> ← has its own <label> wrapper
// <label htmlFor="terms">I agree</label> ← second label
// Clicking either label toggles twice = no change
await page.locator('label[for="terms"]').click();Problems:
sr-onlyinputs are invisible —.check()can't find themforce: trueclicks the element but React doesn't see the event- Two
<label htmlFor>elements cause double-toggle (browser behavior) - Test shows checkbox as "checked" visually but React state is
false
Correct
// Good: Click the component's own <label> wrapper (contains the visual checkbox)
await page.locator('label:has(#terms)').click();
// Good: Click the T&C text that toggles via onClick handler
await page.getByText('I agree to the terms').click();
// Good: For components with only one label, click the label directly
await page.locator('label[for="remember"]').click();
// Good: Verify the checkbox state with web-first assertion
await expect(page.locator('#terms')).toBeChecked();Fix the component if two labels exist (code bug):
// Bad: Two labels for same input — browser toggles twice
<Checkbox id="terms" />
<label htmlFor="terms">I agree</label>
// Good: Use <span> for the text, let Checkbox own the label
<Checkbox id="terms" />
<span onClick={() => setAgreed(v => !v)}>I agree</span>Reference: MDN — Label Element
Handle React Controlled Date and Time Inputs
Impact: HIGH (prevents silent form state desync)
Playwright's fill() sets the DOM value but does not always trigger React's onChange for date and time inputs. The HTML value updates but React state stays empty, causing form validation to fail silently.
Incorrect
// Bad: fill() updates DOM but React state stays empty
await page.locator('#date').fill('2026-04-14');
await page.locator('#time').fill('10:00');
// The submit button remains disabled because React state is:
// { preferred_date: '', preferred_time: '' }
// Even though the inputs visually show the valuesProblems:
fill()dispatches events that React date/time inputs may not listen to- Form appears filled visually but React state is empty
- Submit button stays disabled, test times out with confusing failure
- Only affects date/time input types — text inputs work fine with
fill()
Correct
// Good: Use keyboard.type() to simulate real user input
// Date inputs accept digits in the browser's locale format (DD/MM/YYYY or MM/DD/YYYY)
const dateInput = page.locator('#preferred-date');
await dateInput.scrollIntoViewIfNeeded();
await dateInput.click();
await page.keyboard.type('16042026'); // DDMMYYYY for DD/MM/YYYY locale
// Time inputs accept digits + AM/PM
const timeInput = page.locator('#preferred-time');
await timeInput.click();
await page.keyboard.type('1000AM'); // 10:00 AM
// Brief wait needed: keyboard.type() on date inputs triggers React state
// updates asynchronously — 200ms allows the controlled component to sync
await page.waitForTimeout(200);// Alternative: Use evaluate to set value + dispatch native events
await page.evaluate((val) => {
const el = document.getElementById('preferred-date');
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, '2026-04-16');// For standard text/number inputs, fill() works correctly
await page.locator('#amount').fill('100'); // Works
await page.locator('#email').fill('a@b.com'); // Works
await page.locator('#weight').fill('25.5'); // WorksWhen to use `keyboard.type()` vs `fill()`:
fill()— text, number, email, password, search inputskeyboard.type()— date, time, datetime-local inputs in React apps- Both require
.click()first to focus the input
Reference: Playwright Docs — Input
Prefer Role-Based Locators
Impact: CRITICAL (test resilience to UI changes)
Use getByRole, getByLabel, and getByText over CSS selectors or test IDs. Role-based locators mirror how users and assistive technology interact with the page, making tests resilient to refactoring.
Incorrect
// Bad: CSS selectors break when classes change
await page.locator('.btn-primary').click();
await page.locator('#submit-form').click();
await page.locator('div > form > button:nth-child(2)').click();
// Bad: data-testid couples tests to implementation
await page.locator('[data-testid="login-button"]').click();Problems:
- CSS classes are styling concerns, not behavior contracts
- Selectors break on every UI redesign
- Tests don't verify accessibility (screen readers can't find
.btn-primary) data-testidadds noise to production HTML
Correct
// Good: Role-based — mirrors user interaction
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('tab', { name: 'Network' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).toBeVisible();
// Good: Label-based — matches form field labels
await page.getByLabel('Email').fill('user@example.com');
// Good: Text-based — for non-interactive content
await expect(page.getByText('Welcome back')).toBeVisible();
// Good: Use exact when text is ambiguous
await page.getByRole('button', { name: 'Verified', exact: true });Priority order: 1. getByRole — buttons, tabs, headings, links, checkboxes 2. getByLabel — form inputs with labels 3. getByText — static content, with { exact: true } when needed 4. locator('#id') — for inputs without labels (e.g., #password) 5. CSS selectors — last resort for complex DOM structures
Reference: Playwright Docs — Locators
Handle Strict Mode Violations
Impact: CRITICAL (prevents test failures from ambiguous selectors)
Playwright runs in strict mode by default — locators that match multiple elements throw an error. Handle this proactively with { exact: true }, .first(), or scoped locators.
Incorrect
// Bad: "Verified" also matches "Unverified"
await page.getByRole('button', { name: 'Verified' }).click();
// Bad: "POS Demo" appears in heading AND breadcrumb
await expect(page.getByText('POS Demo')).toBeVisible();
// Bad: "Item Type" appears in form AND summary
await expect(page.getByText('Item Type')).toBeVisible();
// Bad: "Convert" matches tab name AND submit button
await page.getByRole('button', { name: /convert/i }).click();Problems:
- Strict mode throws: "resolved to 2 elements"
- Tests fail with confusing error messages
- Different elements have the same visible text
Correct
// Good: exact match prevents substring matching
await page.getByRole('button', { name: 'Verified', exact: true }).click();
// Good: use role to disambiguate heading vs breadcrumb
await expect(page.getByRole('heading', { name: 'POS Demo' })).toBeVisible();
// Good: scope to a specific container
await expect(page.locator('.form-section').getByText('Item Type')).toBeVisible();
// Good: use specific button text
await page.getByRole('button', { name: /convert 10 sp/i }).click();
// Good: use .first() when you know the first match is correct
await page.getByText('Dashboard').first().click();
// Good: filter with parent context
await page.locator('button').filter({ hasText: 'Arif Azmi' }).first().click();Strategies (in order of preference): 1. { exact: true } — prevents "Verified" matching "Unverified" 2. getByRole('heading', ...) — disambiguates heading from breadcrumb/sidebar 3. Scoped locator — container.getByText(...) limits search area 4. .first() — when the first match is always correct 5. .filter({ hasText: ... }) — combine with other locators
Reference: Playwright Docs — Strict Mode
Mirror Route Structure in Test Organization
Impact: MEDIUM (scalable test organization as app grows)
Organize E2E test files to mirror your application's route groups. This makes tests discoverable and scalable as features are added.
Incorrect
tests/e2e/specs/
test1.spec.js # What does this test?
test2.spec.js # No grouping
all-tests.spec.js # One giant file with 200 tests
login-and-buy.spec.js # Mixed concernsProblems:
- Can't find the test for a specific feature
- Adding a new feature means figuring out which file to edit
- One giant file causes merge conflicts
- Mixed concerns make failures hard to diagnose
Correct
tests/e2e/specs/
auth/
login.spec.js
register.spec.js
customer/ # matches role:customer routes
egold/
buy.spec.js
sell.spec.js
redeem.spec.js
kyc-gate.spec.js
gold-saving/
new-agreement.spec.js
special-trade-in/
new-agreement.spec.js
semantan-point/
convert.spec.js
team/
member-profile.spec.js
admin/ # matches role:admin routes
customers/
kyc-approve.spec.js
egold/
approve-reject.spec.js
pos-demo/
transaction.spec.js
management/ # matches role:superadmin routes
branches/
create-edit.spec.js
gold-price/
add-price.spec.js
ui/ # cross-cutting concerns
dark-mode.spec.js
responsive.spec.js
navigation.spec.js
global-search.spec.jsNaming convention:
- Folders match route prefixes (
/admin/*→admin/) - Files match the feature or page name
ui/folder for cross-cutting concerns (theme, responsive, navigation)- One
describeblock per file, focused on one page/feature
Reference: Playwright Docs — Test Organization
Never Use Arbitrary waitForTimeout
Impact: MEDIUM (prevents slow and flaky tests)
Playwright auto-waits for elements before interacting. Adding waitForTimeout is almost always a sign of a missing assertion or incorrect locator strategy.
Incorrect
// Bad: Waiting for toast to appear
await page.click('#submit');
await page.waitForTimeout(3000);
await expect(page.getByText('Success')).toBeVisible();
// Bad: Waiting for page transition
await page.click('a[href="/dashboard"]');
await page.waitForTimeout(2000);
await expect(page.getByRole('heading')).toBeVisible();
// Bad: Waiting for React re-render
await page.fill('#amount', '100');
await page.waitForTimeout(1000);
const isEnabled = await page.locator('#submit').isEnabled();Problems:
- 3 second wait runs on every test even when page loads in 200ms
- On slow CI, 2 seconds may not be enough — still flaky
- Accumulates across test suite (30 tests x 3s = 90s wasted)
Correct
// Good: Wait for the element directly — auto-retries until visible
await page.click('#submit');
await expect(page.getByText('Success')).toBeVisible({ timeout: 8_000 });
// Good: Wait for URL change after navigation
await page.click('a[href="/dashboard"]');
await page.waitForURL('**/dashboard');
await expect(page.getByRole('heading')).toBeVisible();
// Good: Wait for button state to change
await page.fill('#amount', '100');
await expect(page.locator('#submit')).toBeEnabled({ timeout: 3_000 });
// Good: Wait for network request to complete
await Promise.all([
page.waitForResponse('/api/data'),
page.click('#load-data'),
]);Acceptable uses of waitForTimeout (rare):
- After
keyboard.type()on date inputs — React needs a tick to process (200ms max) - After clipboard operations — OS-level delay
- Never more than 500ms, and always with a comment explaining why
Reference: Playwright Docs — Auto-waiting