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
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.
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:
//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.
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:
/html/body/header/div/div/a
Relative XPath starts from any recognisable anchor and finds the element from there:
//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.
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.
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.
//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:
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:
page.locator("//input[@id='free-trial-step1-email']") // XPath
page.locator("#free-trial-step1-email") // CSS
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:
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().
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 |
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.
- The error element is a
div, not aninput. Writing//input[contains(@class,'invalid-reason')]matched nothing. Check the tag in the inspector before you write the expression. - 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:
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:
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 |
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:
//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.
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 |
//div[@class='mammal']/following-sibling::div
//div[@class='mammal']/ancestor::div
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.
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.
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.