04 · Locators¶
What a locator is¶
A Locator is a description of how to find an element — not a reference to
a specific DOM node captured at one moment in time. This distinction matters:
a locator is re-evaluated against the live page every time you act on it, so
if an element re-renders (React re-mounting a component, for instance) your
locator still finds the current version of it. This is a deliberate design
difference from Selenium's WebElement, which is a snapshot reference that
goes stale if the underlying DOM node is replaced.
login_button = page.locator("#login-button") # describes, doesn't fetch yet
login_button.click() # resolved and acted on now
# no output — clicking succeeds as long as an element matching
# #login-button exists and is actionable at click time
Locator priority: prefer what a user perceives¶
Playwright's own documentation states a clear preference order, and it's worth internalizing why, not just memorizing it: a test should find elements the way a real user or assistive technology would — by role, label, or visible text — not by implementation details like CSS classes or DOM structure, which change on every refactor with zero effect on what the user actually experiences.
1. get_by_role — the recommended default¶
page.get_by_role("button", name="Sign in").click()
page.get_by_role("link", name="Forgot password?").click()
page.get_by_role("heading", name="Welcome back", level=1)
page.get_by_role("checkbox", name="Remember me").check()
page.get_by_role("textbox", name="Email").fill("user@example.com")
# no output — each resolves to the accessible role Chrome/Firefox/
# WebKit compute for that element, matched against its accessible name
This matches the ARIA role and accessible name the browser itself computes
for an element — the same information a screen reader uses. It works whether
the element is a <button>, a <div role="button">, or an <input
type="submit">, because it's testing the accessibility tree, not the tag.
2. get_by_label / get_by_placeholder — form fields¶
page.get_by_label("Email address").fill("user@example.com")
page.get_by_label("Password").fill("hunter2")
page.get_by_placeholder("Search products...").fill("wireless mouse")
# no output — get_by_label matches an <input> associated with a
# <label for="..."> or wrapping <label>, by the label's visible text
3. get_by_text — visible text content¶
# no output — exact=True requires a full string match rather
# than substring containment, avoiding accidental matches on
# longer strings that merely contain "Add to cart"
Good for asserting on messages, headings, or content that isn't
interactive — less ideal for buttons/links where get_by_role gives you
richer matching (and doubles as a check that the element is exposed
correctly to assistive technology).
4. get_by_test_id — an explicit escape hatch¶
When a page genuinely has no stable accessible role, label, or text (a
purely decorative interactive icon, dynamically generated content with no
fixed copy), a data-testid attribute added specifically for tests is a
legitimate, explicit contract between the frontend and the test suite — far
more stable than a CSS class chosen for styling, which can change for
reasons that have nothing to do with test coverage.
5. CSS and XPath — last resort¶
page.locator("#login-button")
page.locator(".product-card >> nth=2")
page.locator("//div[@class='card']//button")
# no output — all three work, but none convey what the element
# means to a user, and all three break silently on a styling refactor
Playwright fully supports CSS and XPath selectors, and sometimes they're genuinely the pragmatic choice on a legacy page you don't control. Reach for them last, and prefer CSS over XPath when you do — XPath is more powerful but harder to read and slower to evaluate in most engines.
Chaining and filtering¶
product_card = page.locator(".product-card").filter(has_text="Wireless Mouse")
product_card.get_by_role("button", name="Add to cart").click()
row = page.get_by_role("row").filter(has=page.get_by_text("Invoice #1042"))
row.get_by_role("button", name="Download").click()
# no output — filter() narrows a locator that would otherwise
# match multiple elements down to the one matching the extra condition
filter(has_text=...) narrows by the element's own text; filter(has=...)
narrows by whether a child matches another locator. This composes far
better than a single long CSS selector for anything resembling "the row
containing X" or "the card titled Y."
Locators are lists — .first, .nth(), .count()¶
cards = page.locator(".product-card")
print("Total cards:", cards.count())
cards.first.click()
cards.nth(2).click()
for i in range(cards.count()):
print(cards.nth(i).text_content())
A Locator can match zero, one, or many elements; most action methods
(.click(), .fill()) require it to resolve to exactly one at the moment
of the action and raise a clear error if it matches more than one —
protecting you from silently clicking "whichever one happened to be first"
by accident. Use .first, .last, or .nth(i) when you deliberately mean
one of several matches.
Scoping locators to a container¶
# no output — resolves the link only within <nav>, so an
# identically-named "Pricing" link in the footer is never a candidate
Calling .locator() or .get_by_role() on an existing locator (instead of
on page) scopes the search to inside that element — the single most
effective technique for disambiguating a page with repeated text or
structure, like a navbar and footer that both link to "Pricing."
How It Actually Works¶
A Locator object is intentionally inert until you act on it — it stores
only a selector string (e.g. internal:role=button[name="Sign in"] for
get_by_role) and a reference to its parent frame. Nothing is queried yet.
The moment you call .click(), .fill(), or read .text_content(),
Playwright sends a fresh DOM.querySelector/accessibility-tree query over
CDP at that exact instant, gets back the current matching node(s), and
throws them away again once the action finishes. This is what makes locators
resilient to re-renders where a captured WebElement reference would go
stale: there is no cached node to go stale, only a query re-run live every
time.
get_by_role in particular doesn't search the raw DOM tree at all — it asks
the browser to compute its accessibility tree (the same structure exposed
via CDP's Accessibility.getFullAXTree), which each engine builds from DOM
semantics, ARIA attributes, and computed styles combined. That's why
get_by_role("button", name="Sign in") matches a <div role="button"> a
CSS selector would never associate with "button" — Playwright is reading the
browser's own interpretation of what the element means to assistive
technology, not the tag name.
filter(has_text=...) and filter(has=...) compile down to an internal
selector engine step (internal:has-text=, internal:has=) evaluated
entirely inside the query — Playwright doesn't fetch the unfiltered list into
Python and filter it there; the filtering happens as part of the single
protocol round trip, which is why chained locators stay fast even against
pages with hundreds of matching elements.
Exercise¶
Using https://books.toscrape.com/:
- Use
get_by_role("link", ...)to click into a category from the sidebar (e.g. "Travel"), and confirm viapage.urlthat navigation happened. - On the category page, use
page.locator(".product_pod")to get all product cards, print.count(), then loop and print each product's title via.locator("h3 a").get_attribute("title")(the full title is in thetitleattribute because the visible text is truncated with CSS). - Use
.filter(has_text=...)to find the specific product card whose title contains a word you choose (pick one you saw printed in step 2), and print its price via.locator(".price_color"). - Scope a locator to
.locator(".product_pod").firstand, from that scoped locator, get the "Add to basket" link'shrefattribute — confirming scoping avoids ambiguity when many cards share the same button text. - Deliberately write a
page.locator(".product_pod")action call (like.click()) without.firstor.nth()when more than one match exists, and read the resulting Playwright error message closely — it names the ambiguity and shows you how many elements it found.