What this class covered
- The CSS selector cheat sheet: combinators, attribute selectors, positional selectors
- Which CSS pseudo-classes you can skip in Playwright, and why
- Three ways to run a test: UI mode, debug mode, headed and headless
- Why a command-line flag beats the config file
- The locator priority ladder, and the 80% claim behind it
- ARIA, the accessibility tree, and the roles HTML gives you for free
- getByRole, and why name is the accessible name and not the name attribute
- What exact true changes about name matching
- A link that looks like a button, and why the role is still link
- Why getByRole returns a Locator rather than a Promise
Where this sits
Last class ended with the project set up and one task: open the site, click, and type a username and password. A good number of the room had it working, so this session closes out CSS and then starts the topic the rest of the month is built on.
The shape of the locator story, now that three of its four parts are done:
- Normal locators, the classic four hooks: id, name, class, tag.
- XPath, covered last class along with strict mode.
- CSS selectors, finished today.
- Playwright default locators, started today, and the ones you will actually use.
The number that reframes everything. The seven built-in locators handle roughly 80% of the elements you will ever need. The XPath and CSS work was not wasted, it is what you fall back to for the other 20%, and it is what interviewers still ask about. But the default path through a page is the accessibility tree, not the DOM.
The CSS cheat sheet
The combinators, which are the part people reach for daily:
| Syntax | Means | Example |
|---|---|---|
* |
any element | * |
input |
by tag | input |
#id |
by id | #login-username |
.class |
by class | .text-input |
tag.class |
tag with class | input.text-input |
.a.b |
one element carrying both classes | .btn.primary |
form input |
descendant, at any depth | form input |
form > input |
direct child only | form > input |
label + input |
the element immediately after | label + input |
label ~ input |
a later sibling under the same parent | label ~ input |
Attribute selectors, which mirror what XPath already gave you:
| Syntax | Means |
|---|---|
[required] |
the attribute is present at all |
[type="email"] |
exact value |
[name^="user"] |
value starts with |
[name$="name"] |
value ends with |
[name*="ser"] |
value contains |
[data-qa="hocewoqisi"] |
any custom attribute works the same way |
One correction from the spoken pass. Going through the sheet out loud, the three substring operators got swapped around at one point. The sheet on screen was right, and the right way round is the one above: ^= starts with, $= ends with, *= contains. The fourth, ~=, matches one whole word inside a space-separated list, which is rarer.
Positional selectors, for when nothing else distinguishes the element:
li:first-child the first one
li:last-child the last one
li:nth-child(3) the third one
li:nth-child(2n) every even one
li:nth-child(2n+1) every odd one
The pseudo-classes you can skip. CSS also offers :checked, :disabled, :enabled, :required and :focus. You will rarely want them in Playwright, because Playwright already checks that state for you before it acts and asserts on it through expect. They are worth recognising in a code review, not worth reaching for.
And the standing warning that applies to every one of these: prefer stable anchors. A framework-generated class like text-input W(100%) or an obfuscated data-qa="hocewoqisi" changes on the next build. A long positional chain breaks the moment a row is inserted.
Three ways to watch a test run
Worth getting straight, because two of them sound the same and are not.
| Command | What you get |
|---|---|
npx playwright test |
headless, no window, fastest, what CI uses |
npx playwright test --headed |
a real browser window you can watch |
npx playwright test --debug |
line by line execution with the Inspector, no timeline |
npx playwright test --ui |
the full UI mode: timeline, network, console, rollback |
UI mode is the one to learn. It is closer to a debugger than a runner. You get the test list, a step-by-step timeline you can scrub, and for any step: the locator it used, the network requests in flight, console output, attachments, annotations and errors. You can click back up the timeline and see the page as it was at that step. Anyone who has used Cypress will recognise the shape of it immediately.
--debug is the smaller tool: it steps the test one command at a time in the Inspector, without the timeline and network panes.
Asked in class, and a fair interview question. If headless is set in playwright.config.ts and you also pass a flag on the command line, which wins? The command line. Playwright reads the flag first and falls back to the config file only when the flag is absent. The working rule for the batch: while you are developing a test you want to see it, so run headed; let CI run headless.
The priority ladder
This is the sequence to walk when you meet a new element. Try each rung, and only drop to the next when the one above cannot reach it.
Where the class and the repository disagree. Spoken in class, the order put getByTestId second, straight after role. The repository README, written up alongside the code, orders it role, label, placeholder, text, testid, then CSS or XPath. The disagreement is only about where the test id sits, and it is a genuine judgement call: a data-testid is perfectly stable, but it is invisible to users, so a team that leans on it stops noticing when a label goes missing. Either order is defensible. What both agree on is the top (role) and the bottom (XPath).
ARIA, and why getByRole exists at all
ARIA is Accessible Rich Internet Applications, a set of roles and attributes that make web content usable by people with disabilities. Every modern browser builds an accessibility tree alongside the DOM, and developers are expected to write markup that produces a sensible one.
That tree is what getByRole reads. This is the whole idea: instead of asking "which element sits at this CSS path", you ask "which element on this page plays this role and answers to this name", which is the same question a screen reader asks.
Most roles you never have to set, because HTML already implies them:
| Element | Role |
|---|---|
a with an href |
link |
button, input type="submit" |
button |
input type="text", email, password |
textbox |
textarea |
textbox |
input type="checkbox" |
checkbox |
input type="radio" |
radio |
input type="range" |
slider |
input type="number" |
spinbutton |
select |
combobox, or listbox when it is multi-select |
option |
option |
img with alt text |
image |
h1 to h6 |
heading |
ul, ol |
list |
table |
table |
tr |
row |
td |
cell |
progress |
progressbar |
meter |
meter |
form |
form |
Trust the browser, not your eyes. summary can present as a button and details as a group, and a developer can override a role explicitly. The one that catches everybody is covered below: an anchor styled to look exactly like a button is still a link.
The trap: name is not the name attribute
This is where the class spent its time, and it is worth the space.
The second argument to getByRole is an options object, and the option everyone uses is name. It looks like it should match the HTML name attribute. It does not. It matches the accessible name, which is what a screen reader would announce for that element.
Here is the real VWO login field, which has both:
<input type="email" class="text-input W(100%)" name="username"
id="login-username" data-qa="hocewoqisi" placeholder="Enter email ID">
The HTML name is username. The accessible name is Email. Passing username finds nothing; passing Email works. That failure happened live in class before the explanation landed, which is the right order to meet it in.
The spec pushed during the session, quoted from the repository:
import { test, expect } from '@playwright/test';
test("login form fields by role", async ({ page }) => {
await page.goto("https://app.wingify.com/#/login");
const username = page.getByRole("textbox", { name: "Email", exact: true });
const password = page.getByRole("textbox", { name: "Password" });
await username.fill('admin@vwo.com');
await password.fill('1234');
});
Where does "Email" come from, when there is no aria-label? Asked in class, and the honest answer is that this element is under-labelled: ideally a developer would have given it an aria-label or a linked <label>. Failing that, the browser falls back through what it does have, and type="email" plus the placeholder are enough for it to present the field as the email box. The accessible name is computed, not just copied from one attribute.
What exact changes
By default, name matching is case-insensitive and substring-based. So name: "Email" would also match an element whose accessible name is "Email Address", and name: "appointment" matches "Make Appointment".
exact: true turns that off: the whole name must match, case-sensitively.
page.getByRole("link", { name: "appointment" }) // matches
page.getByRole("link", { name: "make appointment" }) // matches, case-insensitive
page.getByRole("link", { name: "Make Appointment", exact: true }) // matches
page.getByRole("link", { name: "appointment", exact: true }) // does NOT match
Is this the same as Selenium's partial link text? Close, but not the same, and the difference is worth saying in an interview. Partial link text matches a substring of the visible text of an anchor only. The default getByRole match is a substring of the accessible name, on any role, and the accessible name may come from an aria-label the user never sees rendered as text.
The second example: it looks like a button
The other spec pushed during the class, and it exists to make exactly one point.
import { test, expect } from '@playwright/test';
test("navigate via the Make Appointment link", async ({ page }) => {
await page.goto("https://katalon-demo-cura.herokuapp.com/");
const mainButton = page.getByRole("link", { name: "Make Appointment", exact: true });
await mainButton.click();
});
On screen that control is a large, solid, button-shaped thing. In the markup it is an <a>, so the browser reports it as a link, and getByRole("button", ...) finds nothing at all. The class name on it is a costume.
The rule that falls out of this. When the role is not obvious, do not guess from the screenshot: ask the browser. Codegen's Pick locator reports the role Playwright actually sees, and the UI mode step detail shows the locator that was used. Guessing the role is the single most common reason a getByRole call matches nothing.
getByRole returns a locator, not a promise
A small thing that caused a visible wobble mid-class, and worth pinning down because it changes how the code reads.
const username = page.getByRole("textbox", { name: "Email" }); // Locator, returned immediately
await username.fill('admin'); // Promise, must be awaited
getByRole and the rest of the getBy* family are synchronous. They return a Locator, which is a description of how to find an element, not the element itself and not a promise. Nothing has touched the page yet. Putting await in front of it is harmless but does nothing.
The actions and assertions are the async part. fill, click, check, textContent and expect(locator).toBeVisible() all return promises and all need await.
Why this matters beyond syntax. Because a locator is just a description, you can build it once and reuse it, and it re-resolves against the live page every time you use it. That is why a locator stored in a variable still works after the page has re-rendered, which would not be true of a captured element handle.
What this actually buys you
Stated plainly at the end of the session, and it is the argument for the whole approach:
- No dependence on
idattributes that the app may not have. - No dependence on the
nameattribute. - No breakage when a dynamic class name changes between builds.
- Shadow DOM stops being a special case, because the accessibility tree is flat.
- The locator reads like the requirement: find the textbox for the email.
And the comparison that was drawn. Selenium has no equivalent of this, at least not yet: there is no role-and-accessible-name locator in its standard API, so element location there still goes through id, name, class, CSS or XPath. If you are moving from Selenium to Playwright, this is the part of the API with no counterpart to map onto, which is why it deserves the hour it got.
Two questions the room asked
If Copilot, codegen and Cursor can all write the locator for me, why are interviews still asking me to find elements by hand? The answer given, and it is the one to reuse: because anyone can run a code generator. What an interviewer is testing is whether you can tell when the generated locator is wrong, and fix it. Generated code anchors on whatever was convenient at record time, often a brittle CSS path. You cannot rely on it fully, so the fundamentals are exactly what gets probed. The tools raise the floor, they do not remove the need to know what good looks like.
Can I use codegen to record a script and inspect locators? Yes, that is what it was built for, and Pick locator is the part to use most. Record to see the shape of a flow, then rewrite the locators by hand up the priority ladder before you commit anything.
Learning this without waiting for the next class
A method demonstrated live rather than described, and the reason it was: the instructor will not be sitting next to you when a locator fails at 11pm.
Open Claude in the same folder as your test, point it at the exact element and the exact line that is failing, and ask it to explain using the explain like I'm five skill. The jacket-and-sign explanation of the accessible name in this page came out of doing precisely that, live, on the failing name: "username" call.
The framing given to the room. "I can teach you certain things, I can guide you, I can mentor you. Most of it you have to do yourself." Self-learning was presented as the actual skill being taught, with AI as the tool that makes it possible between classes.
Tasks and announcements
- There is a test tomorrow at 9:00 AM. It covers coding, JavaScript and TypeScript. The window is twelve hours, 9:00 AM to 9:00 PM, but the recommendation was to sit it in the first hour rather than leave it.
- Post doubts in the doubt thread, not scattered in chat, so they can be picked up in the next session.
- Two questions were explicitly deferred to the next class: a live walkthrough of debugging and fixing a broken locator, and the email validation case raised during the getByRole examples.
- Perspective offered on the workload: roughly 200 JavaScript and TypeScript exercises are behind the batch, and roughly another 200 Playwright exercises are ahead of it. Shadow DOM, SVG, web tables, dropdowns, alerts, windows and frames are all still to come, and the point made was that none of them are hard once the locator model is solid.