This skill should be used when working with the /user-facing-selectors command or when the user asks about Playwright locator strategy, selector priority, user-facing locators, getByRole vs getByTestId, disambiguation strategies, "flaky selectors", "brittle tests", "data-testid", "accessible selectors", "test locators", "page.locator best practices", or needs guidance on refactoring Playwright tests to use accessible, user-facing selectors. Also useful when writing new Playwright tests — not only when refactoring existing ones.
From devnpx claudepluginhub jordyvanvorselen/claude-marketplace --plugin devThis skill uses the workspace's default tool permissions.
Executes pre-written implementation plans: critically reviews, follows bite-sized steps exactly, runs verifications, tracks progress with checkpoints, uses git worktrees, stops on blockers.
Guides idea refinement into designs: explores context, asks questions one-by-one, proposes approaches, presents sections for approval, writes/review specs before coding.
Dispatches parallel agents to independently tackle 2+ tasks like separate test failures or subsystems without shared state or dependencies.
Always prefer the highest-priority locator that uniquely identifies the element. This ladder follows Playwright's official recommendation and the Testing Library guiding principle: tests should resemble how users interact with the software.
| Priority | Locator | Use when | Example |
|---|---|---|---|
| 1 (highest) | getByRole() | Element has an ARIA role + accessible name | getByRole('button', { name: 'Submit' }) |
| 2 | getByText() | Visible text uniquely identifies the element | getByText('Welcome back') |
| 3 | getByLabel() | Form field has an associated <label> | getByLabel('Email address') |
| 4 | getByPlaceholder() | Input has a placeholder (no label available) | getByPlaceholder('Search...') |
| 5 | getByAltText() | Image or area with alt text | getByAltText('Company logo') |
| 6 | getByTitle() | Element has a title attribute | getByTitle('Close dialog') |
| 7 (lowest) | getByTestId() | Only when no semantic alternative exists | getByTestId('complex-canvas') |
When a single locator matches multiple elements, do not fall back to getByTestId(). Instead:
.filter(): page.getByRole('listitem').filter({ hasText: 'Product A' })page.getByRole('navigation').getByRole('link', { name: 'Home' })nth() as a last resort before testId: page.getByRole('button', { name: 'Delete' }).nth(0)exact: true to avoid partial matches: page.getByText('Log in', { exact: true })test.describe block names — do not rename or remove themgetByTestId — each must have a comment explaining why no user-facing alternative existsaria-label, <label>, or proper role, add it rather than keeping getByTestId/dev:atdd, using getByTestId)import { test, expect } from '@playwright/test';
test.describe('Email/Password Login (dga8)', () => {
test('Given a registered user, When they submit valid credentials, Then they see the dashboard', async ({ page }) => {
// Given
await page.goto('/login');
// When
await page.getByTestId('email-input').fill('user@example.com');
await page.getByTestId('password-input').fill('SecurePass123!');
await page.getByTestId('login-button').click();
// Then
await expect(page.getByTestId('dashboard-heading')).toBeVisible();
await expect(page.getByTestId('welcome-message')).toContainText('Welcome');
});
test('Given a registered user, When they submit wrong password, Then they see an error', async ({ page }) => {
// Given
await page.goto('/login');
// When
await page.getByTestId('email-input').fill('user@example.com');
await page.getByTestId('password-input').fill('wrong-password');
await page.getByTestId('login-button').click();
// Then
await expect(page.getByTestId('error-message')).toContainText('Invalid email or password');
});
});
/dev:user-facing-selectors)import { test, expect } from '@playwright/test';
test.describe('Email/Password Login (dga8)', () => {
test('Given a registered user, When they submit valid credentials, Then they see the dashboard', async ({ page }) => {
// Given
await page.goto('/login');
// When
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Log in' }).click();
// Then
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText(/welcome/i)).toBeVisible();
});
test('Given a registered user, When they submit wrong password, Then they see an error', async ({ page }) => {
// Given
await page.goto('/login');
// When
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('wrong-password');
await page.getByRole('button', { name: 'Log in' }).click();
// Then
await expect(page.getByRole('alert')).toContainText('Invalid email or password');
});
});
What changed:
getByTestId('email-input') → getByLabel('Email address') — the <input> has a <label>getByTestId('password-input') → getByLabel('Password') — same patterngetByTestId('login-button') → getByRole('button', { name: 'Log in' }) — <button> with textgetByTestId('dashboard-heading') → getByRole('heading', { name: 'Dashboard' }) — <h1> with textgetByTestId('welcome-message') → getByText(/welcome/i) — visible text, regex for flexibilitygetByTestId('error-message') → getByRole('alert') — error container has role="alert"Orphans removed from implementation code:
data-testid="email-input" from <input>data-testid="password-input" from <input>data-testid="login-button" from <button>data-testid="dashboard-heading" from <h1>data-testid="welcome-message" from <p>data-testid="error-message" from <div role="alert">Semantic markup added:
role="alert" to the error message container (was a plain <div>)