XPath in Playwright
The xpath= prefix, auto-detection of // expressions, chaining XPath with CSS and role locators, strict mode, and when Playwright's built-in locators are the better choice.
XPath in Playwright
Playwright supports XPath fully, and its documentation also tells you to prefer role, text and test-id locators. Both are true. This lesson shows the mechanics and then the judgement call.
Writing an XPath locator
// explicit prefix
page.locator('xpath=//button[normalize-space()="Save"]')
// auto-detected: anything starting with // or .. is treated as XPath
page.locator('//button[normalize-space()="Save"]')
page.locator('..') // parent, rarely useful on its own
page.locator('xpath=//button[normalize-space()="Save"]')
page.locator('//button[normalize-space()="Save"]')
An expression starting with ( (for example (//li)[1]) is not auto-detected. Use the xpath= prefix for those.
Locators are lazy and re-resolved
A Playwright Locator is a description, not an element. It is evaluated when you act on it, and re-evaluated on retry. That removes the stale-element problem Selenium has, and it means the XPath is run against the live DOM on every action.
const save = page.locator('//button[normalize-space()="Save"]');
await save.click(); // evaluated now
await save.click(); // evaluated again, fresh
Strict mode
Actions like click() and fill() require the locator to resolve to exactly one element. Two matches throw:
Error: strict mode violation: locator('//button[normalize-space()="Save"]') resolved to 2 elements
This is a feature. It catches the modal-versus-page duplicate immediately instead of clicking the wrong one. Fix it by anchoring (//*[@role="dialog"]//button[...]), or by .first() / .nth(i) when order is genuinely the intent.
Chaining XPath with other engines
locator.locator() searches within the previous match. You can switch engines at each step, which is how you cross shadow roots (XPath cannot pierce them; CSS can):
page.locator('//article[.//h3[normalize-space()="USB-C Hub"]]') // xpath to the card
.locator('button.add-to-cart') // css inside it
.click();
page.locator('//my-dropdown[@data-testid="country"]') // xpath to the host
.locator('button.trigger') // css pierces the shadow root
.click();
Inside a chained XPath step, Playwright evaluates relative to the parent element, and a leading // still means “anywhere under that element”, unlike the DOM API. .// is also accepted.
Filtering
filter() lets you keep XPath for the shape and use Playwright for the content:
page.locator('//tr')
.filter({ hasText: 'USB-C Hub' })
.getByRole('button', { name: 'Edit' })
.click();
hasText is case-insensitive and whitespace-normalised, which is what you usually want and slightly more than normalize-space()= gives you.
Frames
No switching. Use frameLocator:
await page.frameLocator('iframe[title="Secure card frame"]')
.locator('//input[@name="cardnumber"]')
.fill('4242 4242 4242 4242');
When the built-in locators are better
Playwright’s recommended order is getByRole, getByLabel, getByPlaceholder, getByText, getByTestId, then CSS or XPath. The reasoning: role and label locators describe what a user perceives, they pierce shadow DOM, they are resilient to markup changes, and the trace viewer shows them clearly.
| Task | Prefer | XPath equivalent |
|---|---|---|
| Click a button by its name | getByRole('button', { name: 'Save' }) | //button[normalize-space()='Save'] |
| Fill a field by its label | getByLabel('Email') | //input[@id=//label[normalize-space()='Email']/@for] |
| Click a test-id element | getByTestId('save') | //*[@data-testid='save'] |
| Element inside a component | locator('my-card').getByRole(...) | XPath cannot enter shadow DOM |
XPath still wins when you need:
- Structural relationships the role tree does not express: “the cell two columns right of this one”, “the input after this heading”.
- Ancestor navigation:
ancestor::tr[1]. - Attribute logic:
starts-with,containson data attributes,not(). - Portability: the same string works in Selenium, Cypress, Appium and scraping code.
A reasonable rule: reach for getByRole first; when the locator needs a comment to explain it, try XPath and see if it reads better.
Debugging
npx playwright codegen records locators as you click and prefers role locators; it will fall back to CSS and rarely to XPath. The trace viewer and page.pause() let you test locators live: in the inspector, type an XPath in the “Pick locator” box and it highlights matches.
Assertions with XPath
await expect(page.locator('//tr[td[normalize-space()="Pending"]]')).toHaveCount(3);
await expect(page.locator('//*[@role="alert"]')).toContainText('Saved');
toHaveCount and toBeVisible auto-retry, so the XPath is re-evaluated until it passes or times out.
Next Steps
- Locator Strategy in 2026 - The full argument for and against XPath in modern suites
- Shadow DOM and iframes - Why the chaining trick works
- XPath in Cypress and Puppeteer - The other JavaScript runners