Core concepts
Finding elements
Which selector strategy to reach for, why the order matters, and how to chain across iframes and shadow DOM.
Every keyword that touches the page takes a selector. Which strategy you choose
decides how often your suite breaks for reasons that have nothing to do with the
software under test.
This page covers the strategies, the order I would reach for them in, and the syntax for chaining, iframes and shadow DOM.
Pick a strategy
| Strategy | Reach for it when | |
|---|---|---|
1. role= — how the user finds it
A role selector matches on what the element is and what it is called — the same two things a screen-reader user navigates by. It is semantic rather than structural, so it survives a redesign that moves the button, restyles it, or rebuilds the surrounding markup.
The name here is the accessible name, which the browser computes in this order:
aria-labelledbyaria-label- An associated
<label> - Visible text content
title
There is a bonus that is easy to miss. If you cannot write a role selector because the element has no proper role or no accessible name, you have found an accessibility bug. A screen-reader user cannot identify that control either. That is worth an issue, not a workaround.
2. data-testid= — the one attribute that belongs to us
Every other attribute on the page belongs to someone else. Classes belong to the designers, ids to the developers, text to the copywriters — and all three of them are entitled to change their minds without telling you.
A dedicated test id is the only hook that exists for testing, and the only one nobody will change by accident. If long-term stability is what you are buying, buy this one.
Be clear-eyed about what it costs, though. A test id is invisible to the user, so a suite built on test ids is using the interface to test the functionality behind it, not testing the interface. That is often exactly the right trade — just make it deliberately rather than by default.
3. text= — what is written on it
Text selectors use a user-facing property, like role=, which is why they rank
above anything structural. They are one step behind role= because text alone
does not say what the element is, and because text is language-dependent: the
moment the app is localised, every text selector is a translation away from
failing.
text=Sign in matches by substring, case-insensitively. Quoting the value —
"Sign in" — makes it a whole-string, case-sensitive match. Both normalise
whitespace: edges trimmed and internal runs collapsed, so "Sign in" still
matches <p> Sign in </p>. The regex form is the exception — it runs
against the raw text, so text=/^Sign in$/i will not match a padded node.
4. id= — less stable than it looks
An id feels like a stable, unique handle, and sometimes it is. But ids belong to the developers, they are frequently generated by a framework, and nothing stops them changing in a refactor that nobody thought was user-visible. The stability is a false sense of safety unless you have agreed with the developers that these particular ids are contractual.
5. css= — acceptable, not preferable
CSS is web-native and every web developer reads it, which is a real advantage: a developer looking at your selector understands it immediately and can tell you when a change will break it.
It ranks below the four above because it selects on structure and styling — exactly the things a redesign changes. A class name is a styling decision, typically not a contract with you.
CSS is the implicit default: a selector that is not obviously something else is treated as CSS.
6. xpath= — the last resort
XPath is CSS's powerful, unpleasant relative. It is more verbose for the same result, many web developers do not read it fluently, it is not web-native, and it invites selecting by document position rather than function — which is the most brittle thing you can possibly do.
It is genuinely more powerful, and occasionally something is unselectable without it. Use it then, and only then. It is the last resort, not a general-purpose tool.
And if you are about to paste something like this out of your browser's devtools:
DON'T! That selector describes where the button sits today, not what it is. It will break on the next layout change, and the failure will look like a bug in the software rather than in the test.
A legitimate use for XPath is relative navigation: start from an element you can identify reliably, then move through the DOM to an otherwise ambiguous element.
For example, imagine a form with several fields, each with the same info button:
...<div class="field"> <label for="email">Email</label> <div class="control"> <input id="email" type="text"> <button type="button" aria-label="More information">ⓘ</button> </div></div><div class="field"> <label for="phone">Phone</label> <div class="control"> <input id="phone" type="text"> <button type="button" aria-label="More information">ⓘ</button> </div></div>...
role=button[name="More information"] alone is ambiguous: there are two of them. But the Email textbox is easy to identify. We can anchor there, move up to the common parent, and then find the button within it:
Click xpath=//label[text()="Email"]/..//button[@aria-label="More information"]The above example solves the problem but just because you need one functionality of XPath, does not mean you have to use it all the way. See Cascading Selectors
Click role=textbox[name="Email"] >> xpath=.. >> role=button[name="More information"]Here XPath is doing something useful and narrowly scoped: navigating relative to a reliably identified element. We are not describing where the element happens to sit in the entire document; we are expressing a local relationship between two elements.
That is a good use of XPath.
Also available: the data-testid aliases
data-testid= has two siblings that do exactly the same job against a different
attribute. Which one you use is decided by what your developers already put in
the markup, not by preference:
| Prefix | Matches |
|---|---|
All three behave identically. Pick the one your application emits and stay with it.
How a strategy is used
You can always be explicit with a strategy=value prefix. Spaces around the
separator are ignored by css=, xpath= and text=, so css=foo, css= foo
and css = foo are equivalent. They are not ignored by id= or the
test-id engines: the space becomes part of the value, so id = save silently
matches nothing.
Without a prefix, the strategy is inferred:
| Selector looks like | Treated as |
|---|---|
Get Element //html/body/div # xpathGet Element "foo" # text exact matchGet Element div # css
Cascading Selectors with >>
This is the part that makes Browser's selectors worth learning. Strategies
combine in a single string, left to right, with >>. Each step searches inside
the result of the previous one.
That means you rarely need one clever selector. You need two obvious ones.
When the chain returns the wrong element
By default a chain returns what the last step matched. Prefix a step with *
to return that step's element instead, while still requiring the rest of the
chain to match:
When >> appears in the text you are matching
Escape it by quoting the value, or the chain splits in the wrong place:
Filter selectors
Some prefixes do not find elements at all. They take what the previous step found and narrow it, which is why they are only useful as a step in a chain. Used alone they apply to the whole document rather than being rejected.
It is worth holding the two kinds apart in your head:
| Kind | Does | Examples |
|---|---|---|
nth= — pick one out of many
Zero-based, and -1 is the last one:
This is the honest escape hatch from strict mode: when a
selector legitimately matches several elements and you want a specific one,
say so. It is still positional, so prefer narrowing by something meaningful
first — css=.result >> text=Helsinki beats nth=3 whenever it is available.
visible= — keep only what can be seen
Useful when a page keeps hidden copies of things in the DOM — a mobile menu next to a desktop one, a template, a collapsed panel.
CSS Basics and Advanced
CSS Basics
| Syntax | Meaning | Example |
|---|---|---|
tag | Element type | button |
.class | Class | .submit-button |
#id | ID | #email |
[attr] | Has attribute | [disabled] |
[attr="value"] | Attribute equals | [type="submit"] |
A B | Descendant | form button |
A > B | Direct child | form > button |
A + B | Next sibling | label + input |
A ~ B | Any following sibling | label ~ button |
:nth-child(n) | Child by position | li:nth-child(2) |
:not(...) | Exclude matches | button:not([disabled]) |
:has(...) | Has matching descendant/relative | .field:has(input[name="email"]) |
One useful distinction to xpath: CSS can select following siblings with + and ~, but it has no simple equivalent of XPath's .. for selecting a parent directly.
Filtering inside a CSS selector
Playwright adds pseudo-classes to CSS that stay inside one step, rather than becoming another link in the chain. These are strategies-with-conditions, not filters, because they still describe which element you want:
| Pseudo-class | Matches |
|---|---|
:has-text() is the one you will reach for most. Note the difference from
:text(): tr:has-text("Ada") is the whole row, while tr :text("Ada") is the
cell.
Layout selectors
Playwright can also select by where an element sits relative to another:
:right-of(), :left-of(), :above(), :below() and :near().
They return every element in that direction, sorted by distance — not the
nearest one — so under strict mode you need a nth=0 to say you meant the
closest.
XPath Basics
XPath has tons of features and was generally designed to navigate in XML trees. Here are some of the more common used ones.
As you can see, unlike in CSS, class and id attributes are not treated specially. To identify an element whose class attribute contains error, you need to use the contains() function.
| Syntax | Meaning | Example |
|---|---|---|
// | Descendant anywhere below | //button |
/ | Direct child | //form/button |
.. | Parent | //input/.. |
@ | Attribute | //input[@name="email"] |
[...] | Filter / condition | //button[@type="submit"] |
* | Any element | //*[@data-id="123"] |
text() | Element text | //button[text()="Save"] |
contains() | Partial match | //div[contains(@class,"error")] |
[1], [2] | Positional match | (//button)[1] |
ancestor:: | Navigate upward | //input/ancestor::form |
following-sibling:: | Following sibling | //label/following-sibling::input |
There are way more functionalities supported by XPath that you may learn somewhere else.
Crossing into iframes with >>>
Selector chains stop at frame boundaries by default. To cross one, use >>>:
No context switching, and no switching back afterwards — the frame boundary is just another step in the chain.
Two rules it is easy to trip over. >>> must have spaces around it: written
as a>>>b it is parsed as ordinary CSS and you get a timeout with no
explanation. And the clause immediately before it must select the <iframe>
element itself — under strict mode, exactly one of them.
Shadow DOM
Browser pierces open shadow roots automatically, so a normal chain reaches into a web component without any special syntax:
This is one of the places Browser is genuinely ahead of older tools: automatic piercing means a component-based frontend does not need a different approach from any other page.
Closed shadow roots cannot be pierced by anything, by design — if you hit one, that is a conversation with the developers rather than a selector problem.
Piercing is what most engines do — css, text, role and the attribute
engines all cross open shadow roots. xpath does not. Every descendant
combinator, including the implicit one at the start of a selector, crosses any
number of open roots. Elements are searched in the light DOM first, then inside
open shadow roots, in document order. No engine enters an iframe — that needs
>>>.
Turning piercing off
One engine stops at the shadow boundary: css:light=, which behaves like
document.querySelector and follows the CSS spec exactly.
Reach for css:light= when you specifically need to assert that something is
not inside a shadow root. The rest of the time the piercing default is what
you want.
Strict mode
By default, a selector that matches more than one element is an error rather than a silent pick of the first one.
Leave it on. A selector matching three elements when you meant one is a bug in the selector, and strict mode tells you immediately instead of at some later point when the order changes.
Element references
Get Element returns a reference you can pass to other keywords, so an
expensive lookup is done once:
What you get back is a selector string — the selector Playwright resolved for
that element — not a snapshot of the DOM node. Get Elements returns a list of
them.
That distinction matters in both directions. It re-resolves on every use, so it survives a re-render that would invalidate a stored node. But it is only a selector, so it goes in the first clause of a chain and nothing more:
A reference works like any other first clause, >>> included: if it points at
an iframe, ${frame} >>> h1 crosses into it. And there is no element= prefix —
the value is already an ordinary selector, so it needs no strategy in front of it.
In short
- Reach for
role=first. If you cannot, you may have found an accessibility bug. - Use
data-testid=when stability is the priority, knowing what it trades away. text=is fine until you localise.css=is acceptable;id=is less stable than it looks.xpath=last, and never by document position.- Two obvious selectors chained with
>>beat one clever one. nth=andvisible=are filters, not strategies: they narrow the step before them, and their order in a chain changes the answer.