Locators are how Playwright finds elements, and choosing good ones is the single biggest factor in whether your tests are stable or flaky. In this lesson you will learn the built-in get_by_* locators and the order to prefer them in, how to narrow results with filter, nth and chaining, why Playwright refuses ambiguous locators (strictness), and how locators reach into shadow DOM, all on the SQA Tools Automation Practice Page.
π What You’ll Learn: what a locator is and why it is lazy, the recommended locator priority, get_by_role with name, exact and state options, get_by_label, get_by_placeholder, get_by_text, get_by_alt_text, get_by_title and get_by_test_id, strictness and the real strict mode violation error, first, last and nth, filter with has_text, has and visible, chaining and combining with and_ and or_, counting and reading many elements, and shadow DOM.
π― What Is a Locator?
A locator describes how to find element(s) on the page. Creating one does not search the page; the search happens each time you act on it or assert with it. Two consequences make tests stable:
- If the page re-renders an element, the locator simply finds the new one; you never hold a “stale” reference.
- Every action and
expect()waits until the locator matches an element in the right state.
username = page.get_by_placeholder("Enter username") # nothing searched yet
username.fill("qa_learner") # found, waited for, filled
π Which Locator Should I Use?
Playwright recommends locators that reflect what users see, because they survive changes to CSS classes and page structure:
| Priority | Locator | Finds elements by |
|---|---|---|
| 1 | get_by_role(role, name=...) | ARIA role and accessible name: buttons, links, checkboxes, headings, rows… |
| 2 | get_by_label(text) | The text of the <label> linked to a form field |
| 3 | get_by_placeholder(text) | An input’s placeholder |
| 4 | get_by_text(text) | Visible text (for non-interactive elements) |
| 5 | get_by_alt_text(text) / get_by_title(text) | An image’s alt text / a title attribute |
| 6 | get_by_test_id(id) | A dedicated test attribute such as data-testid |
| 7 | locator("css or xpath") | CSS or XPath selectors (next lesson) |
The rest of this lesson tests each of them on the practice page. All examples live in tests/test_locators.py and share one fixture that opens the page:
π tests/conftest.py
import pytest
from playwright.sync_api import Page
URL = "https://sqatools.in/automation-practice-page/"
@pytest.fixture
def practice(page: Page) -> Page:
page.goto(URL)
return page
π§ get_by_role: The First Choice
π tests/test_locators.py
import re
from playwright.sync_api import Page, expect
def test_get_by_role(practice: Page):
# Buttons, matched by their accessible name (the visible text)
expect(practice.get_by_role("button", name="Submit")).to_be_visible()
# Headings, optionally by level (h2 = level 2)
expect(practice.get_by_role("heading", name="Web Table", level=2)).to_be_visible()
# Radio buttons and checkboxes, by their label
practice.get_by_role("radio", name="Female").check()
expect(practice.get_by_role("radio", name="Female")).to_be_checked()
# State filters: only checkboxes that are currently checked
practice.get_by_role("checkbox", name="Python").check()
expect(practice.get_by_role("checkbox", checked=True)).to_have_count(1)
# Links and table rows
expect(practice.get_by_role("link", name="Open Google")).to_have_attribute("target", "_blank")
expect(practice.get_by_role("row", name="2 Rahul Tester")).to_be_visible()
- The first argument is the role:
button,link,textbox,checkbox,radio,combobox(a dropdown),heading,row,cell,imgand many more. HTML elements have implicit roles:<button>is a button,<a href>is a link,<input type="checkbox">is a checkbox. name=matches the accessible name: the button text, the link text, the label of a field. By default the match is case-insensitive and also matches a substring. Passexact=Truefor an exact, case-sensitive match, or a compiled regex for patterns.- Extra options narrow the match:
level=for headings,checked=,disabled=,expanded=,selected=andpressed=for states. - A table row’s accessible name is the text of its cells joined with spaces, which is why
"2 Rahul Tester"matches the second data row.
π‘ Tip: Not sure what role or name an element has? Use Codegen’s Pick locator (Lesson 7), or print page.locator("body").aria_snapshot() to see the page the way get_by_role sees it.
π·οΈ Placeholder, Label, Text, Alt Text and Title
π tests/test_locators.py
def test_other_get_by_locators(practice: Page):
# Placeholder text of an input
practice.get_by_placeholder("Enter username").fill("qa_learner")
expect(practice.get_by_placeholder("Enter username")).to_have_value("qa_learner")
# The label next to a checkbox: <input id="python"> <label for="python">Python</label>
expect(practice.get_by_label("Python", exact=True)).not_to_be_checked()
# Visible text of a non-interactive element
expect(practice.get_by_text("This is the bottom of the page for scrolling practice.")).to_be_attached()
# An image's alt text, and an element's title attribute
expect(practice.get_by_alt_text("Sample Image")).to_be_visible()
expect(practice.get_by_title("Hover text")).to_have_text("Hover Over Me")
get_by_labelfinds the form control that a<label>points to (or wraps). It is the best choice for forms with proper labels.get_by_textis for content such as paragraphs and messages. For buttons and links, preferget_by_role(..., name=...), which also checks the element type.exact=Truematters here: without it,get_by_label("Python")would also match any label that merely contains “Python”.to_be_attached()checks that the element exists in the page;to_be_visible()additionally needs it to be visible.
π§ͺ get_by_test_id and a Custom Test Attribute
Many teams add attributes purely for testing, such as data-testid="login-button". get_by_test_id("login-button") finds them. By default Playwright looks at data-testid; if your app uses another name, change it once, for example in a session fixture in conftest.py:
import pytest
@pytest.fixture(scope="session", autouse=True)
def configure_test_id(playwright):
playwright.selectors.set_test_id_attribute("data-test")
Sauce Demo uses data-test, so after this setting page.get_by_test_id("login-button") finds its login button. Test IDs are very stable, but users do not see them, so role-based locators are still preferred where they work.
π tests/test_test_id.py
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(scope="session", autouse=True)
def configure_test_id(playwright):
playwright.selectors.set_test_id_attribute("data-test")
def test_get_by_test_id(page: Page):
page.goto("https://www.saucedemo.com/")
page.get_by_test_id("username").fill("standard_user")
page.get_by_test_id("password").fill("secret_sauce")
page.get_by_test_id("login-button").click()
expect(page.get_by_test_id("title")).to_have_text("Products")
β οΈ Strictness: One Locator, One Element
When you act on a locator (click, fill, check…), it must match exactly one element. If it matches several, Playwright refuses to guess and raises a *strict mode violation*. The practice page has a perfect example: its “Click to Replace Me” button adds a second button with the same id, which is invalid HTML but common in real apps:
π tests/test_strictness.py
import pytest
from playwright.sync_api import Error, Page, expect
def test_strict_mode_violation(practice: Page):
practice.get_by_role("button", name="Click to Replace Me").click()
with pytest.raises(Error) as info:
practice.locator("#staleBtn").click(timeout=2000)
print("\n" + str(info.value).splitlines()[0])
# Fix 1: a more specific locator
expect(practice.get_by_role("button", name="New Button (Old is Stale)")).to_be_visible()
# Fix 2: say which match you want
expect(practice.locator("#staleBtn").last).to_have_text("New Button (Old is Stale)")
# Counting is allowed: assertions on many elements are not strict
expect(practice.locator("#staleBtn")).to_have_count(2)
π₯οΈ Output
Locator.click: Error: strict mode violation: locator("#staleBtn") resolved to 2 elements:
The full error message lists both elements and even suggests better locators:
π₯οΈ Output
1) <button id="staleBtn" onclick="replaceElement()">Click to Replace Me</button> aka get_by_role("button", name="Click to Replace Me")
2) <button id="staleBtn">New Button (Old is Stale)</button> aka get_by_role("button", name="New Button (Old is Stale)")
Strictness is a feature: without it, a test could click the wrong one of two “Delete” buttons and still pass. Fix violations by making the locator more specific, or by choosing explicitly with .first, .last or .nth(index).
π¬ Narrowing Down: nth, filter and Chaining
π tests/test_locators.py
def test_narrowing(practice: Page):
rows = practice.get_by_role("row") # header + 3 data rows
# Position: first, last, nth (0-based)
expect(rows.first).to_contain_text("ID Name Role")
expect(rows.nth(1)).to_contain_text("Deepesh")
expect(rows.last).to_contain_text("Anita")
# filter(has_text=...): keep elements that contain the text
tester_row = rows.filter(has_text="Tester")
expect(tester_row).to_have_count(1)
# Chaining: search inside another locator
expect(tester_row.get_by_role("cell").first).to_have_text("2")
# filter(has=...): keep elements that contain a matching child
trainer = practice.get_by_role("row").filter(has=practice.get_by_role("cell", name="Trainer"))
expect(trainer).to_contain_text("Deepesh")
# Data rows contain "cell"s (the header row has "columnheader"s); has_not_text excludes
data_rows = rows.filter(has=practice.get_by_role("cell"))
expect(data_rows).to_have_count(3)
expect(data_rows.filter(has_not_text="Anita")).to_have_count(2)
# has_text also accepts a regular expression
expect(rows.filter(has_text=re.compile(r"Rahul|Anita"))).to_have_count(2)
.first,.lastand.nth(i)pick by position. Use them deliberately: position-based locators break when the order changes.filter(has_text=...)keeps matches containing the text (or matching a regex);has_not_text=does the opposite.filter(has=locator)keeps elements that contain an element matching the inner locator;has_not=excludes them.- Calling a locator method on another locator (
tester_row.get_by_role("cell")) searches inside it. This is called chaining and is the cleanest way to scope a search to one card, row or dialog. filter(has=practice.get_by_role("cell"))keeps only data rows, because the header row’s cells have the rolecolumnheader, notcell. This is more robust than matching text.re.compile(r"Rahul|Anita")keeps rows whose text contains either name. Regex filters match anywhere in the element’s text, which includes surrounding whitespace, so avoid anchors like^unless you account for it.
Visible elements only, and combining locators
π tests/test_locators.py
def test_visible_and_combinations(practice: Page):
# The hidden input exists but is not visible; filter(visible=True) skips it
text_inputs = practice.locator("input[type='text']")
print(f"\ntext inputs: {text_inputs.count()}, visible: {text_inputs.filter(visible=True).count()}")
expect(practice.locator("#hiddenField")).to_be_hidden()
# and_: both conditions must match the same element
submit = practice.get_by_role("button").and_(practice.locator("#submitButton"))
expect(submit).to_have_text("Submit")
# or_: either locator (handy when a page shows one of two variants)
alert_or_confirm = practice.get_by_role("button", name="Simple Alert").or_(
practice.get_by_role("button", name="Confirm Alert"))
expect(alert_or_confirm).to_have_count(2)
filter(visible=True)keeps only visible matches.and_()requires both locators to match the same element;or_()matches elements of either.
π Reading Many Elements
π tests/test_locators.py
def test_reading_lists(practice: Page):
options = practice.locator("#country option")
print("\ncountries:", options.all_inner_texts())
names = [row.get_by_role("cell").nth(1).inner_text()
for row in practice.locator("table tbody tr").all()]
print("names:", names)
assert names == ["Deepesh", "Rahul", "Anita"]
count()returns how many elements match right now.all_inner_texts()returns a list of the visible texts.all()returns a list of locators, one per match, to loop over. Likecount(), it does not wait; make sure the list has loaded (for example with anexpect) before calling it.
π Shadow DOM
Web components hide their inner elements in a shadow root. Playwright’s role, text, label and CSS locators pierce open shadow roots automatically, even nested ones. The practice page has a button two shadow roots deep:
π tests/test_locators.py
def test_shadow_dom(practice: Page):
practice.get_by_placeholder("Nested Shadow Input").fill("inside the shadow DOM")
expect(practice.get_by_placeholder("Nested Shadow Input")).to_have_value("inside the shadow DOM")
expect(practice.locator("#shadowBtn")).to_have_text("Shadow Button")
expect(practice.get_by_text("Nested Shadow DOM Text")).to_be_visible()
No special syntax is needed. (XPath is the exception: it does not pierce shadow roots, one more reason to prefer the other locators.)
βΆοΈ Run All Examples
β¨οΈ Terminal
pytest -q -s tests
π₯οΈ Output
...
text inputs: 7, visible: 6
.
countries: ['βSelect Countryβ', 'India', 'USA', 'UK', 'Australia']
names: ['Deepesh', 'Rahul', 'Anita']
..
Locator.click: Error: strict mode violation: locator("#staleBtn") resolved to 2 elements:
..
8 passed, 1 warning in 16.91s
Seven text inputs exist but only six are visible: the seventh is the hidden field. The warning is the pytest-playwright notice you can silence with filterwarnings (Lesson 29).
β Frequently Asked Questions
π€ Why does get_by_role(“button”, name=”Submit”) also match “Submit order”?
Name matching is a case-insensitive substring match by default. Use exact=True or a regex such as re.compile(r"^Submit$") when other buttons contain the same word.
π€ Is get_by_text fine for buttons?
It works, but get_by_role("button", name=...) is better: it also checks that the element really is a button and ignores matching text elsewhere.
π€ Do locators wait for elements?
Actions and expect() wait (up to their timeouts). Methods that return information immediately, such as count(), all() and is_visible(), do not.
π€ What does “resolved to 2 elements” mean?
The locator matched two elements while an action needed exactly one. Make it more specific, scope it by chaining, or use .first, .last or .nth() on purpose.
π Summary
- Prefer user-facing locators:
get_by_rolefirst, then label, placeholder, text, alt text, title and test id; CSS/XPath last. - Locators are lazy and re-evaluated on every use; actions and
expectwait for them. - Strictness rejects ambiguous locators; fix with specificity, chaining,
filter, or explicitfirst/last/nth. filter(has_text=, has=, visible=),and_,or_,all()andall_inner_texts()cover almost every real page, and locators pierce open shadow DOM.
Next, you will learn CSS and XPath selectors for the cases where the get_by locators are not enough.
π¬ Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page