| name | selector-strategies |
| description | Resilient selector patterns for Playwright using accessibility-first approach. Use when finding elements, improving test stability, or replacing brittle CSS/XPath selectors. Covers getByRole, getByLabel, getByPlaceholder, getByText, getByTestId priority hierarchy.
|
Selector Strategies Skill
Comprehensive guide to writing resilient, accessible selectors for Playwright tests. Better than relying on MCP servers!
Why This Matters
Bad selectors cause:
- ❌ Flaky tests that break when UI changes
- ❌ Slow tests (inefficient selectors)
- ❌ Accessibility issues (non-semantic selectors)
- ❌ Maintenance nightmares
Good selectors provide:
- ✅ Resilient tests that survive UI changes
- ✅ Fast, reliable test execution
- ✅ Accessibility validation built-in
- ✅ Self-documenting code
Selector Priority Hierarchy
Use selectors in this order (top = best):
1. getByRole ⭐ BEST - Accessibility-first, semantic
2. getByLabel ⭐ Forms with labels
3. getByPlaceholder ⭐ Forms without labels
4. getByText ⚠️ Use sparingly - can be brittle
5. getByTestId ⚠️ Last resort - requires code changes
6. CSS/XPath ❌ AVOID - Brittle and non-accessible
1. getByRole (⭐ BEST - Use This First)
Why It's Best
- ✅ Tests accessibility (if your test can find it, screen readers can too)
- ✅ Semantic and meaningful
- ✅ Resilient to CSS/structure changes
- ✅ Forces better HTML practices
Common Roles
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('link', { name: 'View Details' }).click();
await page.getByRole('link', { name: /prescriptions/i }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('SecurePass123');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('radio', { name: 'Priority shipping' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.getByRole('heading', { level: 1, name: 'Welcome' }).waitFor();
const items = page.getByRole('listitem');
await expect(items).toHaveCount(5);
const table = page.getByRole('table');
const rows = page.getByRole('row');
const cells = page.getByRole('cell');
await expect(page.getByRole('alert')).toHaveText('Error: Invalid input');
await expect(page.getByRole('status')).toHaveText('Loading...');
await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click();
await page.getByRole('main').waitFor();
await page.getByRole('complementary').waitFor();
Advanced getByRole Patterns
await page
.getByRole('button')
.filter({ hasText: 'Delete' })
.first()
.click();
await page
.getByRole('region', { name: 'Prescriptions' })
.getByRole('button', { name: 'Refill' })
.click();
const submitButton = page.getByRole('button', { name: 'Submit' })
.or(page.getByRole('link', { name: 'Submit' }));
Full List of ARIA Roles
alert, alertdialog, application, article, banner, button,
cell, checkbox, columnheader, combobox, complementary,
contentinfo, definition, dialog, directory, document,
feed, figure, form, grid, gridcell, group, heading, img,
link, list, listbox, listitem, log, main, marquee, math,
menu, menubar, menuitem, menuitemcheckbox, menuitemradio,
navigation, none, note, option, presentation, progressbar,
radio, radiogroup, region, row, rowgroup, rowheader,
scrollbar, search, searchbox, separator, slider, spinbutton,
status, switch, tab, table, tablist, tabpanel, term,
textbox, timer, toolbar, tooltip, tree, treegrid, treeitem
2. getByLabel (⭐ Forms)
When to Use
- Form inputs with associated
<label> elements
- Best for accessible form testing
Examples
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123');
await page.getByLabel('Phone number').fill('555-1234');
await page.getByLabel('I accept the terms').check();
await page.getByLabel('Subscribe to newsletter').uncheck();
await page.getByLabel('Standard shipping').click();
await page.getByLabel('Express shipping').click();
await page.getByLabel('Country').selectOption('United States');
await page.getByLabel('State').selectOption({ label: 'Washington' });
await page.getByLabel(/email/i).fill('user@example.com');
Common Patterns
test('fill out patient form', async ({ page }) => {
await page.getByLabel('First name').fill('John');
await page.getByLabel('Last name').fill('Doe');
await page.getByLabel('Date of birth').fill('1990-01-01');
await page.getByLabel('Email address').fill('john@example.com');
await page.getByLabel('Phone number').fill('555-0100');
await page.getByRole('button', { name: 'Submit' }).click();
});
3. getByPlaceholder (Forms without labels)
When to Use
- Input fields without visible labels
- When placeholder text is descriptive enough
Examples
await page.getByPlaceholder('Enter your email').fill('user@example.com');
await page.getByPlaceholder('Search medications...').fill('Lisinopril');
await page.getByPlaceholder('Search prescriptions').fill('123456');
await page.getByPlaceholder('Search prescriptions').press('Enter');
await page.getByPlaceholder(/search/i).fill('query');
⚠️ Warning: Prefer getByLabel when labels exist, as it's more accessible.
4. getByText (⚠️ Use Sparingly)
When to Use
- Text content that's unique and unlikely to change
- Navigation items
- Status messages
When NOT to Use
- Dynamic content (numbers, dates, user-generated content)
- Content that might be translated
- Content that changes frequently
Examples
await page.getByText('Prescription History').click();
await expect(page.getByText('Welcome back!')).toBeVisible();
await expect(page.getByText('Order confirmed', { exact: true })).toBeVisible();
await expect(page.getByText(/successfully submitted/i)).toBeVisible();
await page.getByText('5 prescriptions').click();
await page.getByText('Dr. Smith').click();
Better Alternatives to getByText
await page.getByText('Submit').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Email').click();
await page.getByLabel('Email').click();
5. getByTestId (⚠️ Last Resort)
When to Use
- Complex components with no better selectors
- Dynamic content where role/label isn't practical
- Third-party components you don't control
Setup
<div data-testid="prescription-card-123">
<h3>Lisinopril 10mg</h3>
<button>Refill</button>
</div>
await page.getByTestId('prescription-card-123').click();
await expect(page.getByTestId('prescription-card-123')).toBeVisible();
await page
.getByTestId('prescription-list')
.getByRole('button', { name: 'Refill' })
.click();
TestId Naming Conventions
data-testid="prescription-card-123"
data-testid="patient-search-results"
data-testid="payment-method-form"
data-testid="card1"
data-testid="div"
data-testid="component"
6. CSS Selectors & XPath (❌ AVOID)
Why to Avoid
await page.locator('#submit-btn').click();
await page.locator('.form-input[name="email"]').fill('user@example.com');
await page.locator('div > div > button:nth-child(2)').click();
await page.locator('//div[@class="container"]/button[1]').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('user@example.com');
Only Acceptable Use Cases
await page.locator('[data-automation-id="legacy-component"]').click();
await page.locator('input[type="hidden"][name="csrf"]').getAttribute('value');
Chaining Selectors
Scoping with Locators
await page
.getByRole('region', { name: 'Patient Information' })
.getByRole('button', { name: 'Edit' })
.click();
await page
.getByRole('list')
.getByRole('listitem')
.filter({ hasText: 'Prescription #123' })
.getByRole('button', { name: 'Refill' })
.click();
await page
.getByRole('main')
.getByRole('article')
.first()
.getByRole('link', { name: 'Read more' })
.click();
Filtering
await page
.getByRole('listitem')
.filter({ hasText: 'Active' })
.first()
.click();
await page
.getByRole('button')
.filter({ has: page.getByText('Delete') })
.click();
await page
.getByRole('button')
.filter({ hasNot: page.getByText('Disabled') })
.click();
Decision Tree: Which Selector to Use?
Is it a button, link, or form element?
├─ YES → Use getByRole
│
└─ NO → Is it a form input with a label?
├─ YES → Use getByLabel
│
└─ NO → Is it a form input with a placeholder?
├─ YES → Use getByPlaceholder
│
└─ NO → Is it unique text content?
├─ YES → Use getByText (with caution)
│
└─ NO → Can you add data-testid?
├─ YES → Use getByTestId
│
└─ NO → Use CSS selector (last resort)
Using Playwright Codegen Effectively
Instead of MCP servers, use Playwright's built-in tools:
Generate Selectors
npx playwright codegen https://your-app.com
npx playwright codegen --device="iPhone 13" https://your-app.com
npx playwright codegen --viewport-size=1280,720 https://your-app.com
Debug Existing Tests
npx playwright test --debug
npx playwright test login.spec.ts --debug
Inspector for Selector Testing
await page.pause();
Real-World Examples
Example 1: Pharmacy Prescription Refill
test('refill prescription', async ({ page }) => {
await page.getByRole('navigation').getByRole('link', { name: 'Prescriptions' }).click();
await page.getByRole('heading', { name: 'Your Prescriptions' }).waitFor();
const prescriptionCard = page
.getByRole('article')
.filter({ hasText: 'Lisinopril 10mg' });
await prescriptionCard.getByRole('button', { name: 'Refill' }).click();
const modal = page.getByRole('dialog');
await expect(modal.getByRole('heading', { name: 'Confirm Refill' })).toBeVisible();
await modal.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('alert')).toHaveText('Prescription refill requested successfully');
});
Example 2: Patient Search
test('search for patient', async ({ page }) => {
await page.getByLabel('Search patients').fill('John Doe');
await page.getByRole('button', { name: 'Search' }).click();
await page.getByRole('region', { name: 'Search Results' }).waitFor();
const results = page.getByRole('listitem');
await expect(results).toHaveCount.greaterThan(0);
await results.first().getByRole('link', { name: 'View Details' }).click();
});
Example 3: Form Submission
test('submit patient form', async ({ page }) => {
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Smith');
await page.getByLabel('Date of birth').fill('1985-06-15');
await page.getByLabel('Email').fill('jane.smith@example.com');
await page.getByLabel('Phone').fill('555-0199');
await page.getByLabel('Insurance provider').selectOption('Blue Cross');
await page.getByLabel('I agree to the terms and conditions').check();
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('alert')).toHaveText('Patient information saved successfully');
});
Testing Selector Resilience
Good Test: Survives UI Changes
test('resilient test', async ({ page }) => {
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('alert')).toHaveText('Success');
});
Bad Test: Breaks Easily
test('brittle test', async ({ page }) => {
await page.locator('#submit-btn').click();
await page.locator('div.message.success').waitFor();
});
Quick Reference
Selector Preference Order
- ⭐
getByRole - Buttons, links, form elements, headings
- ⭐
getByLabel - Form inputs with labels
- ⭐
getByPlaceholder - Form inputs without labels
- ⚠️
getByText - Unique, static text (use sparingly)
- ⚠️
getByTestId - Last resort
- ❌ CSS/XPath - Avoid unless absolutely necessary
Common Selectors Cheat Sheet
page.getByRole('button', { name: 'Text' })
page.getByRole('link', { name: 'Text' })
page.getByRole('textbox', { name: 'Label' })
page.getByLabel('Label')
page.getByRole('checkbox', { name: 'Label' })
page.getByRole('heading', { name: 'Text' })
page.getByRole('list')
page.getByRole('listitem')
page.getByRole('alert')
page.getByRole('region', { name: 'Region Name' })
Related Resources