BROWSER

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

StrategyReach for it when

1. role= — how the user finds it

Click    role=button[name="Save"]Click    role=link[name="Get started"]Fill Text    role=textbox[name="Email"]    admin@example.com

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:

  1. aria-labelledby
  2. aria-label
  3. An associated <label>
  4. Visible text content
  5. 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

Click    [data-testid="checkout-submit"]

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

Click    text=Sign inClick    "Sign in"Click    text=/^Sign in$/i

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

Click    id=submit-buttonClick    \#submit-button

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

Click    css=button.primaryClick    .checkout > button

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

Click    xpath=//button[@type="submit"]Click    //div[@class="row"]//button

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:

Click    /html/body/div[3]/div/div[2]/button

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>...
html16 linesUTF-8

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"]
Robot Framework1 lineUTF-8

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"]
Robot Framework1 lineUTF-8

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:

PrefixMatches

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 likeTreated as
Get Element    //html/body/div      # xpathGet Element    "foo"                # text exact matchGet Element    div                  # css
Robot Framework3 linesUTF-8

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.

# Find the element with text "Login", then the input beside itClick    "Login" >> xpath=../input# Find a css element, then a button inside it by textClick    css=.checkout >> text=Confirm# Start with a role, narrow with cssGet Text    role=listitem >> css=.price

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:

# The article that contains "Hello" — not the text node inside itGet Element    *css=article >> text=Hello

When >> appears in the text you are matching

Escape it by quoting the value, or the chain splits in the wrong place:

Get Text    text="some >> text"

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:

KindDoesExamples

nth= — pick one out of many

Zero-based, and -1 is the last one:

Click    css=.result >> nth=0     # the first resultClick    css=.result >> nth=2     # the thirdClick    css=.result >> nth=-1    # the last

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

Click    css=button.save >> visible=trueGet Element Count    css=.row >> visible=false    ==    2

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

SyntaxMeaningExample
tagElement typebutton
.classClass.submit-button
#idID#email
[attr]Has attribute[disabled]
[attr="value"]Attribute equals[type="submit"]
A BDescendantform button
A > BDirect childform > button
A + BNext siblinglabel + input
A ~ BAny following siblinglabel ~ button
:nth-child(n)Child by positionli:nth-child(2)
:not(...)Exclude matchesbutton: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-classMatches
# The row that contains the name, then the button inside that rowClick    css=tr:has-text("Ada Lovelace") >> role=button[name="Edit"]# A card that contains an image, rather than a card whose text mentions oneGet Text    css=.card:has(img) >> css=.title

: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.

Fill Text    css=input:right-of(:text("Postcode")) >> nth=0    00100

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.

SyntaxMeaningExample
//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 >>>:

Get Text    iframe#preview >>> h1Click       iframe[name="editor"] >>> role=button[name="Bold"]

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:

Get Text    css=my-widget >> css=button

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.

# Matches .label inside the component's shadow rootGet Text    css=my-widget .label# Matches only if .label is in the light DOMGet Text    css:light=my-widget .label

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.

*** Settings ***Library    Browser    strict=False    # opt out globally

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:

${button} =    Get Element    role=button[name="Save"]Get Element States    ${button}    contains    enabledClick    ${button}

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:

${row} =    Get Element    css=tr.selectedClick       ${row} >> css=button.delete    # relative to the row

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= and visible= are filters, not strategies: they narrow the step before them, and their order in a chain changes the answer.