| name | selector-strategies |
| description | Comprehensive guide to writing resilient, accessible selectors for Playwright tests. Use when this capability is needed. |
| metadata | {"author":"abhirkeesara"} |
Selector Strategies Skill
Comprehensive guide to writing resilient, accessible selectors for Playwright tests.
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: /products/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: 'Shopping Cart' })
.getByRole('button', { name: 'Checkout' })
.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).();
Common Patterns
test('fill out registration 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 products...').fill('Laptop');
await page.getByPlaceholder('Search for items').fill('headphones');
await page.getByPlaceholder('Search for items').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('Product Catalog').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 items in cart').click();
await page.getByText('John 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="product-card-123">
<h3>Wireless Headphones</h3>
<button>Add to Cart</button>
</div>
await page.getByTestId('product-card-123').click();
await expect(page.getByTestId('product-card-123')).toBeVisible();
await page
.getByTestId('product-list')
.getByRole('button', { name: 'Add to Cart' })
.click();
TestId Naming Conventions
data-testid="product-card-123"
data-testid="user-search-results"
data-testid="checkout-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: 'User Profile' })
.getByRole('button', { name: 'Edit' })
.click();
await page
.getByRole('list')
.getByRole('listitem')
.filter({ hasText: 'Product #123' })
.getByRole('button', { name: 'Buy Now' })
.click();
await page
.getByRole('main')
.getByRole('article')
.first()
.getByRole('link', { name: 'Read more' })
.click();
Filtering and Locator Combinations
Filtering allows you to narrow down locators to match specific conditions. Playwright provides powerful filtering methods for precise element selection.
Filter by Text Content
await page
.getByRole('listitem')
.filter({ hasText: 'Active' })
.first()
.click();
await expect(
page.getByRole('listitem').filter({ hasNotText: 'Out of stock' })
).toHaveCount(5);
await page
.getByRole('listitem')
.filter({ hasText: /Product \d+/ })
.first()
.click();
Filter by Child/Descendant Elements
await page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'Delete' }) })
.click();
await expect(
page.getByRole('listitem').filter({ hasNot: page.getByText('Disabled') })
).toHaveCount(3);
const productCard = page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: 'Product 2' }) })
.filter({ has: page.getByRole('button', { name: 'Add to cart' }) });
Combine Multiple Locators (AND Logic)
const subscribeButton = page
.getByRole('button')
.and(page.getByTitle('Subscribe to newsletter'));
await subscribeButton.click();
const primaryDeleteButton = page
.getByRole('button', { name: 'Delete' })
.and(page.locator('.primary-action'));
const submitButton = page
.getByRole('button', { name: 'Submit' })
.and(page.getByTestId('submit-form'));
Alternative Locators (OR Logic)
const newEmail = page.getByRole('button', { name: 'New' });
const dialog = page.getByText('Confirm security settings');
await expect(newEmail.or(dialog).first()).toBeVisible();
const submitButton = page
.getByRole('button', { name: 'Submit' })
.or(page.getByRole('button', { name: 'Send' }))
.or(page.getByRole('button', { name: 'Confirm' }));
const closeButton = page
.getByRole('dialog')
.getByRole('button', { name: 'Close' })
.or(page.getByRole('button', { name: 'X' }));
Chain Multiple Filters
const expensiveAvailableProduct = page
.getByRole('listitem')
.filter({ hasNotText: 'Out of stock' })
.filter({ hasNotText: 'Coming Soon' })
.filter({ hasText: /\$[1-9][0-9]{2,}/ })
.filter({ has: page.getByRole('button', { name: 'Add to cart' }) })
.first();
const healthyWidgets = page
.locator('.dashboard-widget')
.filter({ hasText: 'Active' })
.filter({ hasNotText: 'Error' })
.filter({ hasNot: page.locator('.error-badge') });
Working with Multiple Elements (List Operations)
Selecting Specific Items
await page.getByRole('listitem').first().click();
await page.getByRole('listitem').last().click();
await page.getByRole('listitem').nth(1).click();
await page.getByRole('listitem').nth(0).click();
await page.getByRole('listitem').nth(-1).click();
Counting Elements
const itemCount = await page.getByRole('listitem').count();
expect(itemCount).toBeGreaterThan(0);
await expect(page.getByRole('listitem')).toHaveCount(5);
const activeItems = await page
.getByRole('listitem')
.filter({ hasText: 'Active' })
.count();
Iterating Through Elements
const items = await page.getByRole('listitem').all();
for (const item of items) {
const text = await item.textContent();
console.log(text);
}
for (const item of items) {
const hasDeleteButton = await item
.getByRole('button', { name: 'Delete' })
.isVisible();
if (hasDeleteButton) {
await item.getByRole('button', { name: 'Delete' }).click();
break;
}
}
const products = await page.getByRole('article').all();
const productNames: string[] = [];
for (const product of products) {
const name = await product.getByRole('heading').textContent();
(name) productNames.(name);
}
Assertions on Lists
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText([
'Apple',
'Banana',
'Orange',
'Mango',
'Grape'
]);
const products = page.getByRole('listitem');
expect(await products.count()).toBeGreaterThan(3);
expect(await products.count()).toBeLessThan(10);
const items = await page.getByRole('listitem').all();
for (const item of items) {
await expect(item).toContainText('Price:');
}
Real-World Filtering Scenarios
E-commerce Product Lists
const affordableInStockProducts = page
.getByRole('article')
.filter({ hasNotText: 'Out of stock' })
.filter({ hasNotText: 'Pre-order' })
.filter({ hasText: /\$[1-5][0-9]/ });
await affordableInStockProducts
.first()
.getByRole('button', { name: 'Add to cart' })
.click();
const freeShippingProducts = page
.getByRole('article')
.filter({ has: page.getByText('Free Shipping') })
.filter({ hasNotText: 'Out of stock' });
await expect(freeShippingProducts).toHaveCount(8);
Table Row Selection
const userRow = page
.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'john@example.com' }) });
await userRow.getByRole('button', { name: 'Edit' }).click();
const systemUsers = page
.getByRole('row')
.filter({ hasNot: page.getByRole('button', { name: 'Delete' }) });
await expect(systemUsers).toHaveCount(3);
const activeUsers = page
.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'Active' }) })
.filter({ hasNot: page.getByRole('cell', { name: 'Suspended' }) });
Dashboard Cards
const healthyWidgets = page
.locator('.dashboard-widget')
.filter({ hasText: 'Active' })
.filter({ hasNotText: 'Error' })
.filter({ hasNot: page.locator('.error-badge') });
for (const widget of await healthyWidgets.all()) {
await expect(widget.getByRole('button', { name: 'Refresh' })).toBeVisible();
}
const errorWidgets = page.locator('.dashboard-widget').filter({ hasText: 'Error' });
const loadingWidgets = page.locator('.dashboard-widget').filter({ hasText: 'Loading' });
const activeWidgets = page.locator('.dashboard-widget').filter({ hasText: 'Active' });
console.log(`Error: `);
.();
.();
Conditional UI Handling
const notification = page
.getByRole('dialog', { name: 'Success' })
.or(page.getByRole('status', { name: 'Success' }));
await expect(notification).toBeVisible();
const closeButton = notification
.getByRole('button', { name: 'Close' })
.or(notification.getByRole('button', { name: 'Dismiss' }))
.or(notification.getByRole('button', { name: 'OK' }));
await closeButton.click();
const submitButton = page
.getByRole('button', { name: 'Submit' })
.or(page.getByRole('button', { name: 'Send' }))
.or(page.getByRole('button', { name: 'Enviar' }))
.(page.(, { : }));
Form Validation States
const fieldsWithErrors = page
.locator('input')
.filter({ has: page.locator('.error-message') });
const errorCount = await fieldsWithErrors.count();
console.log(`Found ${errorCount} fields with errors`);
const requiredInputs = page
.locator('input[required]')
.filter({ hasNot: page.locator('[value]') });
const emptyRequired = page.locator('input[required]').filter({
has: page.locator(':blank')
});
const validFields = page
.locator('input')
.filter({ hasNot: page.locator('.error-message') })
.filter({ hasText: '' });
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
Use Playwright's built-in tools for selector discovery:
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: E-Commerce Add to Cart
test('add product to cart', async ({ page }) => {
await page.getByRole('navigation').getByRole('link', { name: 'Products' }).click();
await page.getByRole('heading', { name: 'Our Products' }).waitFor();
const productCard = page
.getByRole('article')
.filter({ hasText: 'Wireless Headphones' });
await productCard.getByRole('button', { name: 'Add to Cart' }).click();
const modal = page.getByRole('dialog');
await expect(modal.getByRole('heading', { name: 'Added to Cart' })).toBeVisible();
await modal.getByRole(, { : }).();
(page.(, { : })).();
});
Example 2: User Search
test('search for user', async ({ page }) => {
await page.getByLabel('Search users').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.first()).toBeVisible();
await results.first().getByRole('link', { name: 'View Profile' }).click();
});
Example 3: Form Submission
test('submit contact form', async ({ page }) => {
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Smith');
await page.getByLabel('Email').fill('jane.smith@example.com');
await page.getByLabel('Phone').fill('555-0199');
await page.getByLabel('Message').fill('I have a question about your products.');
await page.getByLabel('Subject').selectOption('Product Inquiry');
await page.getByLabel('I agree to the terms and conditions').check();
await page.getByRole('button', { name: 'Submit' }).click();
await (page.()).();
});
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
Converted and distributed by TomeVault — claim your Tome and manage your conversions.