CSS and XPath Selectors in Playwright Python (with Playwright Pseudo-classes)

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.

Advertisement

πŸ“– 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=... or xpath=....

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")
PatternMeaningExample
#idElement with this id#username
.classElement with this class.drag
tagElements of this tagselect
[attr]Has the attribute[disabled]
[attr='v']Attribute equals[name='password']
tag#id, tag.classCombinedselect#country
  • to_contain_text("Drag Me") is used instead of to_have_text on 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#country is the “–Select Country–” placeholder option, which has value="".
  • 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)
OperatorMeaning
[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’s nth(), which counts from 0.
  • + selects the element immediately after another one at the same level.
  • :checked and :disabled select 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.
  • :visible drops hidden matches (here the hidden input), like filter(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")
XPathMeaning
//tagtag anywhere in the page
[@attr='v']Attribute equals
[text()='x'] / [contains(text(), 'x')]Exact / partial text
[2], [last()]Position (1-based)
/following-sibling::tdSiblings after the element
/preceding-sibling::tdSiblings before the element
/ancestor::trAn 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

  1. Can you use get_by_role with a name? Do it.
  2. Is it a form field with a label or placeholder? Use get_by_label or get_by_placeholder.
  3. Does the element have a stable id or test attribute? #id or get_by_test_id is fine.
  4. Need structure (a cell next to another cell, a parent)? Use a short CSS or XPath anchored on text or attributes.
  5. 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 //; prefixes css= and xpath= 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

Leave a Comment