Playwright Testing
Write reliable, fast, maintainable Playwright tests for SPAs.
Contents
-
Core Concepts (Locators, Waiting, Assertions)
-
Test Structure → See reference/structure.md
-
Performance → See reference/performance.md
-
Debugging → See reference/debugging.md
-
CI Configuration → See reference/ci.md
Core principle: Test what users see and do. If a user can't find an element by its role or text, neither should your test.
Quality layers: Reliable (no flakes) → Fast (parallel, minimal waits) → Maintainable (survives refactors)
Philosophy: User-centric locators by default. Implementation details (test-ids, CSS selectors) are escape hatches, not first choices.
The Process
-
Identify: What user behavior are we testing?
-
Locate: Find elements the way users would (role, label, text)
-
Act: Perform user actions (click, fill, navigate)
-
Assert: Verify visible outcomes, not internal state
-
Stabilize: Handle async, add appropriate waits
Red Flags - STOP
-
page.locator('.btn-primary')
-
CSS class selectors
-
page.waitForTimeout(1000)
-
arbitrary sleeps
-
page.locator('[data-testid="x"]') as first choice
-
Testing component internals instead of user outcomes
-
Long test files with no page objects or fixtures
Locator Priority
digraph locator_priority { rankdir=TB; node [shape=box];
"Need to find element" [shape=diamond];
"Has accessible role?" [shape=diamond];
"Has label/placeholder?" [shape=diamond];
"Has visible text?" [shape=diamond];
"getByRole" [style=filled fillcolor=lightgreen];
"getByLabel/getByPlaceholder" [style=filled fillcolor=lightgreen];
"getByText" [style=filled fillcolor=lightgreen];
"getByTestId" [style=filled fillcolor=lightyellow];
"Need to find element" -> "Has accessible role?";
"Has accessible role?" -> "getByRole" [label="yes"];
"Has accessible role?" -> "Has label/placeholder?" [label="no"];
"Has label/placeholder?" -> "getByLabel/getByPlaceholder" [label="yes"];
"Has label/placeholder?" -> "Has visible text?" [label="no"];
"Has visible text?" -> "getByText" [label="yes"];
"Has visible text?" -> "getByTestId" [label="no (last resort)"];
}
Priority Locator When to Use Example
1st getByRole
Buttons, links, inputs, headings, lists getByRole('button', { name: 'Submit' })
2nd getByLabel
Form inputs with labels getByLabel('Email address')
3rd getByPlaceholder
Inputs with placeholder text getByPlaceholder('Search...')
4th getByText
Static text content, paragraphs getByText('Welcome back')
5th getByAltText
Images getByAltText('Company logo')
Last getByTestId
Dynamic content, no semantic meaning getByTestId('total-price')
Role Examples
// Buttons page.getByRole('button', { name: 'Save changes' }) page.getByRole('button', { name: /submit/i }) // regex for flexibility
// Links page.getByRole('link', { name: 'View profile' })
// Form inputs page.getByRole('textbox', { name: 'Username' }) page.getByRole('checkbox', { name: 'Remember me' }) page.getByRole('combobox', { name: 'Country' })
// Structure page.getByRole('heading', { name: 'Dashboard', level: 1 }) page.getByRole('list').getByRole('listitem') page.getByRole('dialog', { name: 'Confirm deletion' })
// Tables page.getByRole('table').getByRole('row', { name: /john/i })
Disambiguating Multiple Matches
// Specify which match you want await page.getByRole('button', { name: 'Delete' }).first().click(); await page.getByRole('listitem').last().click(); await page.getByRole('row').nth(2).click(); // 0-indexed
// Filter by content or child elements await page.getByRole('listitem').filter({ hasText: 'John' }).click(); await page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Edit' }) }).click();
// Chain locators to scope await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
Locator Anti-patterns
Bad Why Good
.locator('.submit-btn')
Breaks on class rename getByRole('button', { name: 'Submit' })
.locator('#email-input')
Coupled to implementation getByLabel('Email')
.locator('div > span:nth-child(2)')
Extremely brittle getByText('...') or add test-id
getByTestId everywhere Misses accessibility bugs Use semantic locators first
Unscoped locator with multiple matches Flaky, might click wrong element Use .first() , .filter() , or scope with parent
Waiting & Async
Playwright auto-waits for most actions. Don't add manual waits unless you have a specific reason.
digraph waiting { rankdir=TB; node [shape=box];
"What are you waiting for?" [shape=diamond];
"Element to appear" [shape=diamond];
"Auto-wait (built-in)" [style=filled fillcolor=lightgreen];
"expect + toBeVisible" [style=filled fillcolor=lightgreen];
"waitForResponse" [style=filled fillcolor=lightyellow];
"waitForURL" [style=filled fillcolor=lightyellow];
"NEVER waitForTimeout" [style=filled fillcolor=lightpink];
"What are you waiting for?" -> "Auto-wait (built-in)" [label="clicking/filling"];
"What are you waiting for?" -> "Element to appear" [label="element"];
"What are you waiting for?" -> "waitForResponse" [label="API call"];
"What are you waiting for?" -> "waitForURL" [label="navigation"];
"Element to appear" -> "expect + toBeVisible" [label="use assertion"];
"Element to appear" -> "NEVER waitForTimeout" [label="don't guess"];
}
Built-in Auto-Waiting
These actions auto-wait - no manual wait needed:
// All of these wait automatically for element to be actionable await page.getByRole('button', { name: 'Submit' }).click(); await page.getByLabel('Email').fill('test@example.com'); await page.getByRole('checkbox').check(); await page.getByRole('combobox').selectOption('US');
Explicit Waits (When Needed)
// Wait for element state (prefer assertions) await expect(page.getByText('Success')).toBeVisible(); await expect(page.getByRole('button')).toBeEnabled(); await expect(page.getByRole('list')).not.toBeEmpty();
// Wait for navigation await page.waitForURL('**/dashboard'); await page.waitForURL(url => url.searchParams.has('token'));
// Wait for API response (useful for loading states) await page.getByRole('button', { name: 'Load data' }).click(); await page.waitForResponse(resp => resp.url().includes('/api/data') && resp.status() === 200 );
// Wait for network idle (use sparingly - can be slow) await page.waitForLoadState('networkidle');
// Wait for specific request to complete before asserting const responsePromise = page.waitForResponse('/api/users'); await page.getByRole('button', { name: 'Refresh' }).click(); await responsePromise; await expect(page.getByRole('list')).toContainText('John');
// Promise.all pattern - click and wait simultaneously await Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/data')), page.getByRole('button', { name: 'Submit' }).click() ]);
Waiting Anti-patterns
Bad Why Good
waitForTimeout(2000)
Arbitrary, slow, still flaky Wait for specific condition
waitForTimeout(100) in loop Polling manually Use expect with auto-retry
waitForLoadState('networkidle') everywhere Slow, unreliable with polling Wait for specific response
No wait + immediate assert Race condition expect auto-retries assertions
Assertion Auto-Retry
Playwright assertions auto-retry until timeout. Use this instead of manual waits:
// BAD: manual wait then check await page.waitForTimeout(1000); const text = await page.getByTestId('status').textContent(); expect(text).toBe('Complete');
// GOOD: auto-retrying assertion await expect(page.getByText('Complete')).toBeVisible();
Soft Assertions
Use expect.soft() to continue testing after assertion failure (collect multiple failures):
test('validates all form fields', async ({ page }) => { await page.goto('/profile');
// Soft assertions don't stop the test - useful for checking multiple things
await expect.soft(page.getByLabel('Name')).toHaveValue('John');
await expect.soft(page.getByLabel('Email')).toHaveValue('john@example.com');
await expect.soft(page.getByLabel('Phone')).toHaveValue('555-1234');
// Test continues even if some assertions fail
// All failures reported at end
});
Handling Overlays and Popups
Use addLocatorHandler when overlays might interfere with test actions:
// Setup handler for cookie consent popup await page.addLocatorHandler( page.getByRole('dialog', { name: 'Cookie consent' }), async () => { await page.getByRole('button', { name: 'Accept' }).click(); } );
// Now write test normally - handler auto-dismisses popup if it appears await page.goto('/dashboard'); await page.getByRole('button', { name: 'Settings' }).click();
Test Structure
For page objects, fixtures, and test organization, see reference/structure.md.
Quick tips:
-
Page objects encapsulate interactions, not assertions
-
Fixtures handle common setup/teardown
-
One behavior per test
-
Use test.describe for grouping related tests
Performance
For parallel execution, auth optimization, and API shortcuts, see reference/performance.md.
Quick tips:
-
Reuse auth state via storageState
-
Create test data via API, not UI
-
Use fullyParallel: true
-
Avoid waitForLoadState('networkidle')
Debugging
For debug mode, traces, and common scenarios, see reference/debugging.md.
Quick tips:
-
npx playwright test --debug for local debugging
-
View traces for CI failures: npx playwright show-trace trace.zip
-
Use await page.pause() to stop and inspect
CI Configuration
For GitHub Actions, sharding, and CI-specific config, see reference/ci.md.
Quick tips:
-
forbidOnly: !!process.env.CI prevents test.only in CI
-
retries: 2 for CI to handle transient failures
-
Upload artifacts for debugging failures
Quality Checklist
Create TodoWrite items for each applicable check before finalizing tests.
Layer 1: Reliable (No Flakes)
-
No waitForTimeout() calls - using condition-based waits
-
Assertions use expect() with auto-retry, not manual checks
-
Tests are independent - no shared state between tests
-
Waiting for specific API responses, not networkidle
-
Locators are specific enough (single element match)
-
Tests pass consistently (run 5x locally before committing)
Layer 2: Fast
-
Authentication reused via storageState
-
Test data created via API, not UI
-
Tests run in parallel (fullyParallel: true )
-
No unnecessary waitForLoadState('networkidle')
-
Heavy setup in fixtures, not repeated per test
-
Sharding configured for large test suites
Layer 3: Maintainable
-
Locators use roles/labels, not CSS selectors
-
Page objects encapsulate interactions
-
Test names describe user action + expected outcome
-
One behavior per test - not testing multiple things
-
Fixtures handle common setup/teardown
-
No magic strings - constants for repeated values
Common Patterns Reference
Need Pattern
Authenticated user storageState fixture
Test data setup API calls in beforeEach or fixture
Wait for data load waitForResponse('/api/...')
Click + wait for response Promise.all([waitForResponse(...), click()])
Multiple similar tests test.describe
- parameterized data
Slow operation Increase timeout for specific test
Modal/dialog getByRole('dialog') then scope within
Dropdown selection getByRole('combobox').selectOption()
File upload setInputFiles() on file input
Hover menu locator.hover() then click revealed item
Drag and drop locator.dragTo(target)
iframes frameLocator() then scope within
New tab/window page.waitForEvent('popup')
Multiple matches .first() , .last() , .nth(n) , .filter()
Check multiple things expect.soft() for non-blocking assertions
Dismiss popups/overlays addLocatorHandler()
When to Add data-testid
Use data-testid as escape hatch when:
-
Element has no semantic role (decorative container)
-
Dynamic content with no stable text (generated IDs, prices)
-
Multiple identical elements where position matters
-
Third-party components without accessible markup
// Acceptable: price that changes dynamically <span data-testid="cart-total">{formatCurrency(total)}</span> page.getByTestId('cart-total')
// Still prefer scoping with semantic locators page.getByRole('region', { name: 'Cart' }).getByTestId('total')
Quick Reference Commands
Run all tests
npx playwright test
Run specific file
npx playwright test login.spec.ts
Run tests matching name
npx playwright test -g "login"
Run in headed mode
npx playwright test --headed
Debug mode with inspector
npx playwright test --debug
Update snapshots
npx playwright test --update-snapshots
Generate test from recording
npx playwright codegen localhost:3000
Show last HTML report
npx playwright show-report