Chapter 3 · Locators and Strict Mode
Chapter goals: Master the "re-query on every action" design philosophy of Locators and all built-in locator types, understand why strict mode saves your tests, and learn to use filter chains for precise list-item targeting.
3.1 Locator vs ElementHandle: One Re-render Away from Disaster
A Locator is the core of Playwright's auto-wait and retry capabilities: it does not represent a specific DOM node, but rather a query for "how to find the node". Every time you perform an operation on a Locator, it re-runs the lookup against the current DOM:
locator = page.get_by_role("button", name="Sign in")
locator.hover() # lookup once, at this moment
locator.click() # lookup again — using the freshest DOMIf the page re-renders between the two operations (a daily occurrence with modern frontend frameworks), the Locator automatically points to the new element. The older ElementHandle "freezes" the node at creation time — after a re-render it becomes an orphan and any operation throws Element is not attached to the DOM:
# ❌ Anti-pattern: handle is a snapshot, dead after re-render
handle = page.query_selector("#submit")
# ✅ Correct: locator is a live query
page.locator("#submit").click()Remember one sentence
Your test code should almost never contain query_selector or $. Seeing an ElementHandle usually means you've fallen into an anti-pattern.
3.2 Built-in Locator Family
The official recommendation is to prefer user-facing attribute locators — they are more testable and more semantic:
# By ARIA role + accessible name (preferred)
page.get_by_role("button", name="Sign in").click()
page.get_by_role("checkbox", name="Subscribe").check()
# By label (form controls)
page.get_by_label("Password").fill("secret")
# By placeholder
page.get_by_placeholder("name@example.com").fill("me@test.com")
# By text (for non-interactive div/span; supports regex and exact=True)
expect(page.get_by_text(re.compile("welcome, john", re.IGNORECASE))).to_be_visible()
# By image alt text, by title attribute
page.get_by_alt_text("playwright logo").click()
# By test id (default: data-testid attribute)
page.get_by_test_id("directions").click()Priority recommendation: get_by_role → form fields use get_by_label → non-interactive text uses get_by_text → if the team agrees on test ids, use get_by_test_id.
Test ids are the most structure-resilient anchor — copy changes and DOM restructuring don't break them. They read the data-testid attribute by default; you can change the attribute name globally:
playwright.selectors.set_test_id_attribute("data-pw")CSS/XPath are still available (page.locator("css=..."), auto-detects //button), but long chains like #tsf > div:nth-child(2) > ... are specifically called out as bad practice by the docs — they shatter as soon as the DOM structure changes.
3.3 Strict Mode: Killing Ambiguity Before It Runs
Traditional frameworks silently click the first match when multiple elements match — errors get swallowed. Playwright enables strict mode by default: any operation/assertion that resolves to multiple elements fails immediately:
# With two <button> elements on the page:
page.locator("button").click()
# TimeoutError: strict mode violation: locator resolved to 2 elementsThis is protection, not annoyance — mismatched "the button I thought I was clicking" and "the button that actually got clicked" is a classic source of flaky tests. Actively narrow the scope instead of leaving it to chance:
page.locator("button", has_text="Save").first.click() # Explicitly take the first
page.get_by_role("listitem").nth(1) # Explicitly take the nth (use with caution)
page.locator("button").filter(visible=True).click() # Match only visible elements3.4 Filter Chains: The Right Way to Target List Items
To click the buy button in the second product row, the correct approach is to filter the "row" first, then drill down:
# Text filter: find the list item containing "Product 2", then find the button inside it
page.get_by_role("listitem").filter(
has_text="Product 2"
).get_by_role("button", name="Add to cart").click()
# Sub-element condition filter: the row must contain a matching heading
page.get_by_role("listitem").filter(
has=page.get_by_role("heading", name="Product 2")
).get_by_role("button", name="Add to cart").click()
# Negative filter: exclude out-of-stock items
expect(page.get_by_role("listitem").filter(has_not_text="Out of stock")).to_have_count(5)Note: inner locators in filter(has=...) match downward from the filtered element — don't write selectors that start from the document root. Multiple filters can be chained to narrow further.
Shadow DOM needs no special treatment either: all locators (except XPath) pierce shadow roots by default (closed mode excluded).
3.5 Chapter Summary
- Locator = live query, re-resolved on every action; ElementHandle = dead snapshot, broken after re-render;
- Locator priority: role → label/text → placeholder/alt/title → test id → CSS/XPath fallback;
- Strict mode makes "matched multiple elements" fail immediately, forcing unambiguous locators;
- For lists, use
filter(has_text= / has= / has_not_text=)chains to drill down; - Test ids are most resilient; customize with
selectors.set_test_id_attribute().
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. After a page re-renders, which approach continues to work correctly?
2. A locator matches 3 elements and you call click() — what happens?
3. To click the "Add to cart" button inside the list item containing "Product 2", the correct写法 is?
4. Which statement about get_by_test_id is incorrect?
🛠️ Hands-On Exercises
- Open any site with a search box; locate and type into it using both
get_by_placeholderandget_by_role("searchbox"). - Build a local HTML page with two identically-named buttons; reproduce the strict mode violation, then fix it with
.firstandfilter(visible=True)respectively. - Write a page with 5
<li>products (2 marked "Out of stock"); assert that the count of in-stock items is 3.
Proceed to Chapter 4: Auto-Wait and Actionability.