Primary action
sample="sample-primary"QA PRACTICE•LOCATOR PRACTICE HUB
Learning path · 20 topics
Master resilient Playwright locators with a live playground, practical challenges, and DOM-first QA examples.
Read the element tree before choosing a locator.
Prefer user-facing locators that survive visual changes.
Compare stable attributes with styling-oriented classes.
Match visible copy without making tests overly broad.
Use attribute, child, sibling, and state selectors.
Navigate relationships when CSS is not expressive enough.
Practice axes, normalize-space, and contains safely.
Design selectors intentionally for automation.
Diagnose ambiguous matches before an action fails.
Handle changing IDs, loading states, and rerenders.
Scope cards, rows, and nested controls precisely.
Switch context and locate elements inside frames.
Understand open roots and component boundaries.
Find dialogs rendered outside the local container.
Build selectors that are less dependent on translated text.
Inspect DOM, accessibility, and locator candidates.
Separate syntax, scope, uniqueness, and timing problems.
Connect accessible markup with reliable automation.
Replace indexes and absolute XPath with resilient alternatives.
Review a practical locator decision checklist.
What to test
What to test
Locator strategy
Trap: this statement is intentionally false. Standard CSS has no :contains(); use a role/text locator or XPath instead.
Trap: this statement is intentionally false. Standard CSS has no :contains(); use a role/text locator or XPath instead.
Live test area
Validate a locator against the sample DOM. Matching elements receive a stable visual outline.
Primary action
sample="sample-primary"Secondary action
sample="sample-secondary"Email field
sample="sample-email"Status badge
sample="sample-status"Repeated cards
sample="sample-products"Table actions
| Release 1.0 |
sample="sample-table"Dynamic attributes
sample="sample-dynamic"Portal dialog
sample="sample-portal"Challenges
0/50Beginner · Role locator challenge
Locate the Save profile button without depending on CSS classes.
Mini lesson
Interactive elements should be located the way a user or assistive technology perceives them: by role and accessible name.
Why: A class such as .btn-primary can change during a redesign. The button role and accessible name describe the control users actually interact with.
Good: page.getByRole('button', { name: 'Save profile' })
Avoid: page.locator('.btn-primary')
Live DOM target
<button class="btn-primary" data-testid="save-profile">Save profile</button><button>Cancel</button>Expected answer type: Role. Enter one locator and check whether it uniquely identifies the target.
Quick reference
Choose the signal that best describes what a user sees or what the test needs to contract.
page.getByRole('button', { name: 'Save profile' })Best for interactive controls
page.getByLabel('Email')Best for form fields
page.getByTestId('status-badge')Best for explicit contracts
| Pattern | XPath | CSS | Note | Priority |
|---|---|---|---|---|
| ID | //*[@id='sample-primary'] | #sample-primary | Stable unique attribute | High |
| Class | //*[contains(concat(' ', normalize-space(@class), ' '), ' primary ')] | .primary | Useful when the class is a real contract | Medium |
| Text | //button[normalize-space(.)='Save profile'] | N/A | CSS has no standard text selector | Medium |
| Contains text | //button[contains(normalize-space(.), 'Save')] | N/A (jQuery/Cypress :contains only) | Use role or XPath for visible text | Low |
| Starts with | //*[starts-with(@id, 'sample')] | [id^='sample'] | ^= is the CSS starts-with operator | High |
| Ends with | //*[substring(@id, string-length(@id) - string-length('email') + 1)='email'] | [id$='email'] | $= is the CSS ends-with operator | Medium |
| Contains attribute | //*[contains(@data-testid, 'status')] | [data-testid*='status'] | * = substring match | Medium |
| Parent-child | //form//input[@name='email'] | form input[name='email'] | Scope before asserting | High |
| Nth element | (//button)[2] | button:nth-of-type(2) | nth-child may select a different element | Low |
| Adjacent sibling | //label/following-sibling::input[1] | label + input | + selects the immediately adjacent sibling | Medium |
| Following sibling | //label/following-sibling::input | label ~ input | ~ selects later siblings | Low |
| Multiple attributes | //input[@name='email' and @type='email'] | input[name='email'][type='email'] | Combine stable attributes | High |
| Not condition | //button[not(@disabled)] | button:not(:disabled) | Exclude unavailable controls | Medium |
| Ancestor | //button[ancestor::section[@data-testid='settings']] | section[data-testid='settings'] button | Anchor a control to a meaningful container | High |
| Normalize space | //*[normalize-space(.)='Release approved'] | N/A | XPath trims repeated whitespace | Medium |
| Case-insensitive text | //*[translate(., 'ABC', 'abc')='ready'] | N/A | XPath translate can normalize known case | Low |
| Union | //button[@type='submit'] | //a[@role='button'] | button[type='submit'], a[role='button'] | Combine equivalent target shapes | Medium |
| Has descendant | //li[.//h3[normalize-space()='QA Sprint']] | li:has(h3) | Keep relationship inside a repeated component | High |
| Table cell | //tr[.//td='Release 1.0']//button[1] | tr:has(td:first-child) button | Scope row before action | High |
| Word class | //*[contains(concat(' ', normalize-space(@class), ' '), ' ready ')] | .ready | Match one complete whitespace-delimited class | Medium |
Automation tools
Turn a locator into a small find + click + assertion example.
const target = page.getByRole('button', { name: 'Save profile' });
await target.click();
await expect(target).toBeVisible();Playwright reference
Quick examples for choosing a resilient locator before writing your answer.
Prefer accessible roles and names for buttons, links, headings and form controls.
page.getByRole('button', { name: 'Sign in' })Locate non-interactive elements by their visible text.
page.getByText('Welcome, John')
page.getByText('Welcome, John', { exact: true })Use the associated label when locating form controls.
page.getByLabel('Password')Useful for inputs that expose a meaningful placeholder.
page.getByPlaceholder('name@example.com')Locate images by alt text or elements with a title attribute.
page.getByAltText('Playwright logo')
page.getByTitle('Issues count')Use an explicit data-testid contract when visible text or role is not the right signal.
page.getByTestId('save-profile')Use page.locator() when a CSS selector or XPath is necessary. Playwright can auto-detect both.
page.locator('button[data-testid="save-profile"]')
page.locator('//input[@name="email"]')Chain locators or filter repeated elements to make the target unique.
page.getByTestId('settings-form').getByLabel('Email')
page.getByRole('listitem').filter({ hasText: 'Product 2' })XPath reference
Use the expressions below inside page.locator('...'). Each row shows the purpose and the matching pattern.
Known attribute & value
//tagName[@attribute='value']Known value only
//tagName[@*='value']Known visible text
//tagName[text()='exact text']//tagName[.='exact text']//*[text()='exact text']//*[.='exact text']Known visible text (partial)
//tagName[contains(text(), 'exact text')]Dynamic element (contains)
//tagName[contains(@attribute, 'value')]Dynamic element (starts with)
//tagName[starts-with(@attribute, 'value')]Dynamic element (ends with)
//tagName[substring(@attribute, string-length(@attribute) - string-length('value') + 1) = 'value']Multiple attributes
//tagName[@attribute1='value1'][@attribute2='value2']//tagName[@attribute1='value1' and @attribute2='value2']//tagName[@attribute1='value1' or @attribute2='value2']Relative to known element
//<Known part>/parent::*//<Known part>/parent::tagName//<Known part>/following-sibling::*//<Known part>/following-sibling::tagNameQuality guide
Good: page.getByRole('button', { name: 'Save' })
Avoid: .primary-button
User-facing semantics survive visual restyling.
Good: page.getByLabel('Email')
Avoid: input:nth-child(1)
The label is a meaningful contract for users and tests.
Good: page.getByTestId('checkout-submit')
Avoid: [data-testid='button-17']
Names should describe behavior, not render order.
Good: card.filter({ hasText: 'Product 2' })
Avoid: buttons.nth(1)
Product identity should own its action.
Good: //input[@name='email']
Avoid: /html/body/main/div[2]/input
Wrappers and layout changes break absolute paths.
Good: getByText('Profile saved', { exact: true })
Avoid: getByText('saved')
Exact text avoids accidental partial matches.
Good: [data-state='ready']
Avoid: .css-1x9k2ab
Hashed classes are implementation details.
Good: getByRole('button', { name: 'Delete account' })
Avoid: getByRole('button').first()
A unique locator fails faster and explains intent.
Good: expect(toast).toBeVisible()
Avoid: waitForTimeout(2000)
Wait on user-observable state instead of time.
Good: dialog.locator('button')
Avoid: page.locator('button')
Global selectors become ambiguous as pages grow.
Good: row.locator('following-sibling::td')
Avoid: div:nth-child(4)
Axes communicate the relationship you need.
Good: row.getByRole('button', { name: 'Edit' })
Avoid: tr:nth-child(3) button
Sorting and pagination change indexes.
Good: getByTestId('status')
Avoid: getByText('Ready')
Text can legitimately change with locale or copy edits.
Good: expect(button).toBeEnabled()
Avoid: button.click({ force: true })
Force bypasses a real user constraint.
Good: data-testid='results-table'
Avoid: div > div > div
A small contract reduces selector coupling.
Good: frameLocator('iframe[title=Support]')
Avoid: page.locator('iframe button')
Frames are separate documents.
Good: component.getByRole('button')
Avoid: document.evaluate('//button', shadow)
XPath does not cross a shadow boundary.
Good: getByRole('link', { name: 'Reports' })
Avoid: a.text-blue-500
Accessible names align automation with assistive technology.
Good: getByTestId('publish')
Avoid: sleep(5); nth(2)
A stable contract addresses the root cause.
Good: const submit = page.getByRole(...)
Avoid: inline 180-character selector
Named locator intent is easier to maintain.