Writing Playwright Tests
Tests assert observable behavior from a user's perspective. If a test fails, something is genuinely broken—not timing, not selectors, not environment.
Selector Priority
Use accessible selectors in this order. These query the accessibility tree, making tests resilient to DOM changes.
| Priority | Method | Example |
|---|---|---|
| 1 | getByRole() |
getByRole('button', { name: 'Submit' }) |
| 2 | getByLabel() |
getByLabel('Email address') |
| 3 | getByPlaceholder() |
getByPlaceholder('Search...') |
| 4 | getByText() |
getByText('Welcome', { exact: true }) |
| 5 | getByAltText() |
getByAltText('Company logo') |
| 6 | Composed locators | page.locator('section').filter({ has: getByRole('heading', { name: 'Featured' }) }) |
| 7 | aria-label attribute |
locator('[aria-label="Link title"]') |
Never use: CSS classes, nth-child, generated IDs, XPath.
Core Patterns
Waiting
// WRONG - arbitrary timeout
await page.click('#submit');
await page.waitForTimeout(2000);
// CORRECT - web-first assertion auto-retries
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible();
List Counts
// WRONG - .all() doesn't wait
const items = await page.getByRole('listitem').all();
expect(items.length).toBe(5);
// CORRECT
await expect(page.getByRole('listitem')).toHaveCount(5);
Hydration
// Wait for interactivity signal before filling uncontrolled inputs
await expect(page.getByRole('button', { name: 'Add' })).toBeEnabled();
await page.getByPlaceholder('Number').fill('12345');
Optimistic UI (temp ID → real ID)
await page.getByPlaceholder('Number').fill('12345');
await page.getByRole('button', { name: 'Add' }).click();
// Confirm action started
await expect(page.getByPlaceholder('Number')).toHaveValue('');
// Wait for real ID
const newItem = page.locator('a[href^="/item/"]').filter({ hasText: '#12345' });
await expect(async () => {
const href = await newItem.getAttribute('href');
expect(href).not.toContain('temp-');
}).toPass({ timeout: 15_000 });
Dialogs
// Set up handler BEFORE triggering
page.once('dialog', dialog => dialog.accept());
await page.getByTitle('Delete').click();
Test Data Isolation
const timestamp = Date.now();
const testName = `Test Item ${timestamp}`;
// Dedicated ID ranges per test file
const getTestId = () => 60_000 + Math.floor(Math.random() * 10_000);
CRUD Verification Workflow
Copy and track:
- [ ] Perform action
- [ ] Wait for save completion
- [ ] Reload page
- [ ] Verify persisted state
await page.getByLabel('Title').fill('New Title');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(/unsaved/)).not.toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.getByLabel('Title')).toHaveValue('New Title');
Timeout Reference
| Operation | Timeout |
|---|---|
| Simple visibility | 5s (default) |
| Mutation completion | 10-15s |
| Page load after reload | 15s |
Custom polling:
await expect(async () => {
const count = await page.getByRole('listitem').count();
expect(count).toBeGreaterThanOrEqual(5);
}).toPass({ timeout: 10_000, intervals: [100, 250, 500, 1000] });
Configuration
export default defineConfig({
retries: process.env.CI ? 2 : 1,
fullyParallel: true,
workers: process.env.CI ? 1 : 2,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
navigationTimeout: 30_000,
},
});
Debugging Flaky Tests
- Run with trace:
bunx playwright test --trace on - View trace:
bunx playwright show-trace trace.zip - Check for: race conditions, stale selectors, network timing, shared state
Unfixable Tests
test.skip('drag link to topic', async ({ page }) => {
// TODO: dnd-kit keyboard simulation incompatible with Playwright
// Manual testing required. Issue: #123
});
Pre-Commit Checklist
- [ ] No waitForTimeout() calls
- [ ] Selectors use getByRole/getByLabel/getByPlaceholder
- [ ] Test data includes timestamp
- [ ] CRUD operations verify persistence after reload
- [ ] Timeouts explicit where needed
- [ ] No shared state between tests
- [ ] Dialog handlers set up before triggering action
Reference
For role options (pressed, checked, expanded, level, etc.) and locator composition (.filter(), scoped .locator()), see: