| name | playwright-locators |
| description | Pick locators that survive UI changes and lean on Playwright's auto-waiting to kill flakes. Use when authoring or refactoring a spec, replacing brittle CSS/XPath, or chasing flaky failures. |
Locators & auto-wait
Most "flaky" Playwright tests fight the browser instead of describing user intent. Two habits fix 80%:
- Pick locators the way a screen reader would.
- Let
expect(locator) do the waiting — never sleep.
Locator priority
getByRole(role, { name }) — semantics, survives copy changes
getByLabel(text) — form fields
getByPlaceholder(text) — when no label
getByText(text) — non-interactive copy
getByTestId(id) — explicit contract via data-testid
- ❌ Long CSS chains / positional XPath — selector rot
Auto-wait rules
- Never
page.waitForTimeout().
- Avoid
waitForLoadState('networkidle') — flaky on apps with analytics.
- Never
.textContent() + expect(value).toBe(...) — bypasses retry. Use expect(locator).toHaveText(...).
- The
await goes on the assertion, not the value.
Web-first assertion cheatsheet
await expect(locator).toBeVisible();
await expect(locator).toHaveText('Products');
await expect(locator).toContainText('$39.95');
await expect(locator).toHaveCount(6);
await expect(page).toHaveURL(/\/inventory$/);
Filter lists, don't index
const card = page.locator('main > div')
.filter({ has: page.getByRole('heading', { name, level: 3 }) });
Real version: productCard(name) in pages/inventory.page.ts.
Common failure modes
- Strict-mode violation → locator matched >1 element. Narrow with
{ name }, not .first().
- "Not visible" → page hasn't navigated. Anchor with
await expect(...).toBeVisible() first.
- Login flake → wait on the next page's URL/heading, not a spinner.
Workshop exercises in .roo/WORKSHOP_GUIDE.md.