The Testing Academy · Class Notes Thursday, 17 September (IST)
Live class · study guide

XPath, strict mode, and locators for elements that change

XPath before Playwright's own locators, because it is the fallback when they cannot reach an element and it is still what interviews ask about. Covers absolute versus relative, the confidence ranking for choosing an attribute, converting XPath to CSS, strict mode and first, the functions for elements whose ids change on every build, and the axes used on web tables. Built live into a spec that failed twice before it passed.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright 3x class, rebuilt from the session recording and the batch repository, which received the spec written during the session (commit "feat: add XPath and strict mode locator spec"). The Eraser deck was not reachable while this page was written. The repository README documents one improvement on the code as it was typed live, and that difference is called out below rather than quietly corrected.

01

What this class covered

  • What XPath is: a W3C query language over the document tree
  • Absolute versus relative XPath, and why absolute is banned in the framework
  • The core syntax, and what the star costs you
  • Testing an expression in DevTools before it goes in code
  • A confidence ranking for attributes: id, data-qa, name, placeholder, class
  • Converting an XPath into a CSS selector by dropping two characters
  • Playwright strict mode, and what first, last and nth actually admit
  • Building the spec live, including the two failures on the way
  • XPath functions: contains, starts-with, ends-with, text, normalize-space
  • Handling dynamic elements whose ids change on every build
  • XPath axes, and the one place they earn their keep: web tables
02

Where this sits

The structure of a Playwright test is covered, and page.goto() with its options was the last thing looked at. Before the class reaches the locators it will actually use day to day, it is doing XPath and CSS properly.

Said out loud at the start, and worth repeating. "First of all, we are learning the bad way. This is not a good way in Playwright." The order is deliberate: master the fallback first, so that when the default locators cannot reach an element you are not stuck, and so that you can answer the interview question. XPath and CSS are what most Selenium work runs on, which is the other reason they still matter.

03

XPath is a query language over a tree

An HTML document is a tree. html contains head and body, the body contains divs, those divs contain more. XPath is a query language for selecting nodes in that tree, the way SQL selects rows, and it works on XML too.

It is a W3C standard, which is the fact that matters: anything defined at W3C is supported by every modern browser. The same is true of CSS selectors, which is why both work everywhere without a plugin.

The core shape:

Text
//tagname[@attribute='value']
  |      |     |
  |      |     the attribute: id, class, name, alt, href, src, data-qa, anything
  |      the tag: input, a, div, h1, form, or * for any
  anywhere in the document

* works, and costs you. If you do not know the tag, //*[@id='x'] is valid. It is slower, because the engine has to consider every element in the document instead of only the tags you named. Name the tag when you know it.

04

Absolute versus relative, and why one is banned

Absolute XPath is the full path from the root. It is what you get from Copy full XPath in DevTools:

Text
/html/body/header/div/div/a

Relative XPath starts from any recognisable anchor and finds the element from there:

Text
//a[@id='btn-make-appointment']

Why absolute is a rule, not a preference. Add one wrapper div anywhere above the element, which happens on almost every front-end change, and the whole path shifts. Nothing about the element changed, but the locator is now pointing at the wrong node or at nothing. The decision recorded for the batch's framework: absolute XPath is never used, other than in very rare cases.

Try it in the browser before you put it in code. Open DevTools, press Ctrl+F in the Elements panel and paste the expression. It reports how many nodes matched. 1 of 1 means you have a unique locator. Anything else means you have work to do, or a decision to make.

05

Not all attributes are equally trustworthy

The most useful idea in the session, and it applies just as much to CSS and to the default locators that come later.

WHAT YOU ANCHOR ON DECIDES HOW LONG THE TEST LIVES @id highest confidence, when hand written and unique on the page @data-qa also high: your team owns it, and nothing visual depends on it @name @placeholder usually stable, but often matches several elements @class lowest. Avoid. Framework classes change per build and per breakpoint. trust churn Why class is the trap A utility class like W(100%) exists because of a layout decision, not because of what the element is. On a desktop it may read 100. On a tablet, 80. On a phone it may not be emitted at all. So a class-anchored locator can pass on your machine and fail in a mobile viewport project, with nothing in the diff to explain it.
The ranking given in class. It survives the move to the default locators later, because the question it answers is the same: what about this element is actually stable?
06

The XPath you already know is nearly a CSS selector

Asked in class whether the @ is required, and the answer turned into the most practical thing in the session.

Text
//input[@id='free-trial-step1-email']     XPath
  input[id='free-trial-step1-email']      drop // and @, and it is CSS
       #free-trial-step1-email            CSS knows the tag from the id

Drop // and @, and you usually have valid CSS. And then CSS lets you go further: if you know the id, the tag is redundant, because the id is unique. This is worth internalising as a conversion rather than memorising two separate syntaxes.

Both forms go into Playwright the same way. The explicit engine prefix still works and is the older style:

TypeScript
page.locator("xpath=//input[@id='free-trial-step1-email']")
page.locator("css=#free-trial-step1-email")

But you do not need it. Pass the selector directly and Playwright works out which it is, because any string starting with // is treated as XPath:

TypeScript
page.locator("//input[@id='free-trial-step1-email']")   // XPath
page.locator("#free-trial-step1-email")                 // CSS
07

Strict mode, and what first actually admits

Playwright runs every locator in strict mode. If a locator matches more than one element, the action throws rather than silently picking one.

This is a deliberate design choice and a good one: silently acting on "the first thing that matched" is how a suite ends up clicking the wrong button for six months with nobody noticing.

When a locator legitimately matches several elements, three escape hatches:

TypeScript
page.locator("//div[contains(@class,'invalid-reason')]").first()
page.locator("//div[contains(@class,'invalid-reason')]").last()
page.locator("//div[contains(@class,'invalid-reason')]").nth(11)   // the 12th

What .first() really says. It does not fix ambiguity, it opts out of the check. You are recording a decision that the first match is the one you want, and if the page order changes, your test quietly targets something else. The better fix, in order: narrow the selector, then .filter({ hasText }) to disambiguate by content, and only then .first().

08

The spec built live

The scenario: on the Wingify free trial page, enter an invalid email, submit, and verify the error message.

On the URL. VWO is now part of Wingify, so app.vwo.com has become app.wingify.com and the trial page is on wingify.com. Older exercises in the batch still reference the old domain.

The locators chosen, and why each one:

Element Locator Reason
Email field //input[@id='free-trial-step1-email'] it has an id
Consent checkbox [data-qa='...gdpr-consent-checkbox'] a data-qa the team owns
Submit button //button[@data-qa='page-su-submit'] same
Error message //div[contains(@class,'invalid-reason')] no id, only classes, so contains
TypeScript
import { test, expect } from '@playwright/test';

test("Verify the error message in the wingify free trial", async ({ page }) => {
    await page.goto("https://wingify.com/free-trial/");

    await page.locator("//input[@id='free-trial-step1-email']").fill("abccd");
    await page.locator("[data-qa='free-trial-step1-gdpr-consent-checkboxgdpr-consent-checkbox']").click();

    const errorMessage = page.locator("//div[contains(@class,'invalid-reason')]").first();
    await page.locator("//button[@data-qa='page-su-submit']").first().click();

    await expect(errorMessage).toContainText("The email address you entered is incorrect.");
});

It failed twice on the way, and both failures teach something.

  1. The error element is a div, not an input. Writing //input[contains(@class,'invalid-reason')] matched nothing. Check the tag in the inspector before you write the expression.
  2. The error never appeared, because filling the field is not enough. The submit button has to be clicked for validation to run. A missing step, not a missing locator, which is the more common cause of "my locator does not work".

Where does await go, and where does it not? page.locator(...) is synchronous. It returns a Locator, a description of how to find something, and touches nothing. No await. So is .first(), which returns another locator. .click(), .fill() and .textContent() all return promises, and all need await. The rule to carry: the locator is a noun, the action is a verb, and only verbs are awaited.

The one improvement the repository makes on the live code

Worth flagging rather than quietly fixing, because the difference is a real source of flake.

As typed in class, the assertion read the text first and then checked the string:

TypeScript
const text = await errorMessage.textContent();      // samples the DOM once, right now
expect(text).toContain("The email address you entered is incorrect.");

That assertion does not retry. It looks at the page at the instant it runs, so if the error renders 50 milliseconds later, the test fails on null. The web-first form polls until it matches or times out:

TypeScript
await expect(errorMessage).toContainText("The email address you entered is incorrect.");
Form Retries? Use it when
await expect(locator).toContainText(...) yes almost always
expect(await locator.textContent()).toContain(...) no you need the raw string for other logic
09

Functions, for elements that will not sit still

Relative XPath assumes the attribute value is fixed. Often it is not.

Function Use
contains(@class,'invalid-reason') the value contains this fragment
starts-with(@id,'puis-') the value begins with a known prefix
ends-with(@id,'-title') the value ends with one (XPath 2.0)
text()='Make Appointment' match on the visible text
normalize-space()='Make Appointment' same, but tolerant of stray whitespace
... or ..., ... and ... combine two conditions

When to reach for normalize-space(). text() is an exact match, so leading, trailing or doubled spaces in the markup will break it, and you cannot see those spaces on the rendered page. If a text() match mysteriously fails, swap it for normalize-space() before you suspect anything else. The rule as given: extra spaces present, use normalize-space() instead of text().

Dynamic elements are the reason these functions exist. A generated id like puis-a1b2c3-title changes on every build, and a date-based id changes tomorrow. Find the part that does not change and anchor on that:

Text
//div[contains(@id,'cell-widget')]        the stable middle
//span[starts-with(@id,'puis-')]          the stable prefix

Shown live on a search results page, where the product title ids are generated but share a fixed prefix, and contains() returned the whole list of titles.

A useful reframing from the room. Someone objected that the result was not unique. It was not supposed to be. Wanting all the matching elements, so you can assert on a list, is a normal goal. Uniqueness is a requirement for acting on one element, not a property every locator must have.

10

Axes, and the one place you will actually use them

The last XPath concept, and the class was upfront that it has essentially one application.

If you can find one element, axes let you walk to its relatives:

Axis Finds
parent:: the node directly above
child:: the nodes directly below
ancestor:: anything above, at any depth
descendant:: anything below, at any depth
following-sibling:: siblings after it, under the same parent
preceding-sibling:: siblings before it
self:: itself
Text
//div[@class='mammal']/following-sibling::div
//div[@class='mammal']/ancestor::div
FIND ONE ELEMENT, REACH ITS WHOLE FAMILY animal ancestor:: vertebrate parent:: mammal mammal = your locator fish preceding-sibling:: other following-sibling:: carnivore herbivore child:: lion, tiger descendant::
The biology analogy used in class. In a web table the same shape reads: find the cell with the name, then take following-sibling to read that person's role.

Where this earns its place: web tables. Given a cell you can find, such as a person's name, the axes give you the rest of that row. Find the name, take following-sibling to read their role, or preceding-sibling to read their id. That is the standard pattern for asserting on a table row, and it is the thing CSS genuinely cannot do. Web tables get a full session next class.

11

Two shortcuts, and what each costs

"Can I just use codegen?" Yes, and it was recommended for anyone who admits to being lazy. But note what comes out: codegen writes getByRole and getByTestId, the default locators, not XPath. Which is a preview of where the course is going, and a hint that the generated output is usually better than a hand-written XPath.

A cheat sheet was shared in chat mapping each XPath form to its CSS equivalent, alongside starts-with, ends-with and contains. Worth keeping open for the first few weeks rather than memorising.

data-qa is not a Playwright feature. Asked whether it works in Selenium too: yes. A custom data attribute is a convention between you and your developers, and every tool can read it. It does not belong to any framework.

12

Tasks and announcements

  • Homework: automate the student login on the batch's multiple-element practice page. Fill in an email and a password, click the login practice button, and verify that the URL changes. One test case is enough.
  • A doubt thread is being created; post questions there rather than in chat.
  • Finish the certification and post it on LinkedIn.
  • Register for the BrowserStack summit using an official work email.
  • Next class: XPath axes applied to web tables, then CSS selector mastery. The axes section above is the preview of it.