The get_by_* locators cover most needs, but sometimes you need a CSS or XPath selector: an element with no accessible name, a structure-based relationship, or an existing Selenium suite you are migrating. page.locator() accepts both. In this lesson you will write CSS selectors from simple to advanced, use Playwright’s extra pseudo-classes, write XPath with axes, and learn the trade-offs that decide which to use.
π What You’ll Learn: page.locator with CSS and XPath, CSS ids, classes, attributes and attribute operators, descendant and child combinators, :nth-child and :checked, Playwright’s :has-text, :text, :has and :visible pseudo-classes, XPath paths, predicates, text() and contains(), axes such as following-sibling and ancestor, explicit css= and xpath= prefixes, and a checklist for choosing selectors.
π§ page.locator(): CSS or XPath
page.locator(selector) accepts a CSS selector or an XPath expression and returns a normal Locator, with the same auto-waiting, strictness and filtering as the get_by_* locators:
- A selector starting with
//or..is treated as XPath. - Everything else is treated as CSS (Playwright also understands a few extra pseudo-classes, shown below).
- You can be explicit with a prefix:
css=...orxpath=....
All examples in this lesson run on the SQA Tools practice page. Each test receives the opened page from this fixture:
π tests/conftest.py
import pytest
from playwright.sync_api import Page
@pytest.fixture
def practice(page: Page) -> Page:
page.goto("https://sqatools.in/automation-practice-page/")
return page
π¨ CSS Selectors from Simple to Advanced
π tests/test_css.py
from playwright.sync_api import Page, expect
def test_css_basics(practice: Page):
# By id: #id
practice.locator("#username").fill("qa_learner")
# By tag + attribute: tag[attr='value']
practice.locator("input[name='password']").fill("Secret@123")
# By class: .class (the drag box has class "drag")
expect(practice.locator(".drag")).to_contain_text("Drag Me")
# Attribute only, and tag + id
expect(practice.locator("[placeholder='Search...']")).to_be_visible()
expect(practice.locator("select#country")).to_have_value("")
# Several attributes at once
expect(practice.locator("input[type='radio'][value='female']")).to_have_id("female")
| Pattern | Meaning | Example |
|---|---|---|
#id | Element with this id | #username |
.class | Element with this class | .drag |
tag | Elements of this tag | select |
[attr] | Has the attribute | [disabled] |
[attr='v'] | Attribute equals | [name='password'] |
tag#id, tag.class | Combined | select#country |
to_contain_text("Drag Me")is used instead ofto_have_texton purpose: on the live site, an advertising script inserts a “Try Cloud Tools” link inside the drag box, so its full text is not just “Drag Me”. Real pages often contain injected content; check for the text that matters to your test.- The empty value of
select#countryis the “βSelect Countryβ” placeholder option, which hasvalue="". to_have_id()checks the element’s id attribute.
Attribute operators
π tests/test_css.py
def test_css_attribute_operators(practice: Page):
# ^= starts with, $= ends with, *= contains
expect(practice.locator("a[href^='https://www.google']")).to_have_id("googleLink")
expect(practice.locator("input[id$='Picker']")).to_have_count(3) # date, time, datetime
expect(practice.locator("input[placeholder*='username']")).to_have_count(1)
| Operator | Meaning |
|---|---|
[attr^='x'] | Value starts with x |
[attr$='x'] | Value ends with x |
[attr*='x'] | Value contains x |
Relationships: descendant, child, sibling and position
π tests/test_css.py
def test_css_relationships(practice: Page):
# Descendant (space): any depth. Child (>): direct children only.
expect(practice.locator("table tbody tr")).to_have_count(3)
expect(practice.locator("table > tbody > tr > td")).to_have_count(9)
# :nth-child(n) is 1-based: the 2nd row, 2nd cell
expect(practice.locator("table tbody tr:nth-child(2) td:nth-child(2)")).to_have_text("Rahul")
# Adjacent sibling (+): the label right after the Python checkbox
expect(practice.locator("#python + label")).to_have_text("Python")
# State pseudo-classes: :checked, :disabled
practice.locator("#java").check()
expect(practice.locator("input[type='checkbox']:checked")).to_have_count(1)
expect(practice.locator("input:disabled")).to_have_id("disabledInput")
- A space means descendant (inside, at any depth);
>means direct child. :nth-child(2)counts from 1 in CSS, unlike Playwright’snth(), which counts from 0.+selects the element immediately after another one at the same level.:checkedand:disabledselect elements by their current state.
β¨ Playwright’s Extra Pseudo-classes
Playwright adds pseudo-classes that plain CSS does not have:
π tests/test_css.py
def test_playwright_pseudo_classes(practice: Page):
# :has-text("...") - element whose text contains the string (case-insensitive)
expect(practice.locator("tr:has-text('Rahul')")).to_contain_text("Tester")
# :text("...") - the smallest element containing the text
expect(practice.locator("td:text('Anita')")).to_have_count(1)
# :has(selector) - element containing a matching element (standard CSS in modern browsers)
expect(practice.locator("tr:has(td:text-is('Developer'))")).to_contain_text("Anita")
# :visible - only visible elements
expect(practice.locator("input[type='text']:visible")).to_have_count(6)
:has-text("x")matches any element whose text contains x, including parents; combine it with a tag (tr:has-text(...)) to avoid matching the whole page.:text("x")matches the smallest element that contains x;:text-is("x")requires the exact full text.:has(...)keeps elements that contain a match.:visibledrops hidden matches (here the hidden input), likefilter(visible=True).
π€οΈ XPath
XPath navigates the document as a tree. It can do one thing CSS cannot: move up to parents and ancestors.
π tests/test_xpath.py
from playwright.sync_api import Page, expect
def test_xpath_basics(practice: Page):
# Absolute-anywhere path with an attribute predicate
practice.locator("//input[@id='username']").fill("xpath_user")
expect(practice.locator("#username")).to_have_value("xpath_user")
# Exact text and partial text
expect(practice.locator("//button[text()='Submit']")).to_be_visible()
expect(practice.locator("//button[contains(text(), 'Alert')]")).to_have_count(3)
# Positions are 1-based; (...)[n] picks the nth match overall
expect(practice.locator("//table/tbody/tr[2]/td[2]")).to_have_text("Rahul")
expect(practice.locator("(//table//td)[last()]")).to_have_text("Developer")
def test_xpath_axes(practice: Page):
# following-sibling: the cell after "Rahul" in the same row
expect(practice.locator("//td[text()='Rahul']/following-sibling::td")).to_have_text("Tester")
# preceding-sibling: the cell before "Rahul"
expect(practice.locator("//td[text()='Rahul']/preceding-sibling::td")).to_have_text("2")
# Going UP: the row (ancestor) that contains "Anita"
expect(practice.locator("//td[text()='Anita']/ancestor::tr")).to_contain_text("Developer")
# .. is the parent: the section around the "Iframe" heading
expect(practice.locator("//h2[text()='Iframe']/..").locator("iframe")).to_have_id("sampleIframe")
| XPath | Meaning |
|---|---|
//tag | tag anywhere in the page |
[@attr='v'] | Attribute equals |
[text()='x'] / [contains(text(), 'x')] | Exact / partial text |
[2], [last()] | Position (1-based) |
/following-sibling::td | Siblings after the element |
/preceding-sibling::td | Siblings before the element |
/ancestor::tr | An ancestor of the element |
/.. | The parent element |
- A path in parentheses with an index,
(//table//td)[last()], picks from all matches in the page, while//td[last()]would pick the last cell of each row. - Chaining
.locator("iframe")after an XPath locator searches inside it, just like with CSS.
β οΈ Watch out: XPath does not pierce shadow DOM, and long absolute paths such as /html/body/div[3]/div/main/... break whenever the layout changes. Keep XPath short and anchored on stable text or attributes.
π Mixing Selectors with Locators
You do not have to choose one style per test. Chain whatever reads best:
π tests/test_xpath.py
def test_mixing(practice: Page):
row = practice.get_by_role("row").filter(has_text="Deepesh") # role + filter
expect(row.locator("td").nth(2)).to_have_text("Trainer") # CSS inside it
expect(row.locator("xpath=./td[1]")).to_have_text("1") # XPath relative to the row
expect(practice.locator("css=#country option")).to_have_count(5) # explicit css= prefix
xpath=./td[1]uses an explicit prefix and a relative path (./), so it searches from the row, not from the page root.css=makes the selector type explicit; it is optional for CSS.
β¨οΈ Terminal
pytest -q -s tests
π₯οΈ Output
....... [100%]
7 passed, 1 warning in 14.25s
β Choosing a Selector: Checklist
- Can you use get_by_role with a name? Do it.
- Is it a form field with a label or placeholder? Use get_by_label or get_by_placeholder.
- Does the element have a stable id or test attribute?
#idorget_by_test_idis fine. - Need structure (a cell next to another cell, a parent)? Use a short CSS or XPath anchored on text or attributes.
- Avoid: auto-generated classes (
.css-1x2y3z), long absolute paths, and indexes that depend on layout.
β Frequently Asked Questions
π€ CSS or XPath: which is faster?
The difference is negligible in Playwright tests. Choose for readability and stability; CSS is usually shorter, XPath is needed to move up the tree.
π€ Why does my CSS :nth-child(1) not match the first row?
:nth-child counts all sibling elements of any type, starting at 1. Check that the parent has no other children before the row, or use Playwright’s .nth(0) / .first on the locator instead.
π€ Can I reuse my Selenium XPath locators?
Yes, page.locator("//...") accepts them. It is a good migration path; then gradually replace fragile ones with role-based locators.
π€ Is :has-text case-sensitive?
No. :has-text() and :text() are case-insensitive and ignore extra whitespace; :text-is() needs the exact text.
π Summary
page.locator()takes CSS by default and XPath for selectors starting with//; prefixescss=andxpath=make it explicit.- CSS: ids, classes, attributes and
^=,$=,*=, combinators (space,>,+),:nth-child,:checked,:disabled. - Playwright extras:
:has-text,:text,:text-is,:has,:visible. - XPath adds axes (
following-sibling,preceding-sibling,ancestor,..); keep selectors short and anchored, and prefer get_by_role where possible.
Next, you will perform every kind of user action: clicks, typing, keyboard keys, hover, drag and drop, dropdowns and more.
π¬ Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page