The Testing Academy · Class Notes Saturday, 19 September (IST)
Live class · study guide

CSS selectors, the three run modes, and getByRole

The CSS selector cheat sheet, the three ways to run a test (UI mode, debug, headless), the locator priority ladder, and then getByRole in depth. The hour went on one idea: the name option is not the HTML name attribute, it is the accessible name a screen reader would announce. Two live examples, one of which failed on the first attempt for exactly that reason.

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 both getByRole specs during the session itself (commit "feat: add getByRole locator specs", pushed while the class was still running). The Eraser deck was not reachable while this page was written. Most of this recording is a Devanagari transliteration of English, which is readable but easy to mis-transcribe, so every selector, option name and URL here is quoted from the repository rather than from the dictation. Where the spoken pass and the repository disagree, the repository is used and the difference is noted.

01

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
02

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.

03

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:

Text
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.

04

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.

05

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.

TRY EACH RUNG, DROP ONLY WHEN IT CANNOT REACH 1. getByRole survives CSS and DOM churn 2. getByTestId stable, your team owns it 3. getByText visible copy 4. getByPlaceholder unlabelled inputs 5. getByLabel a real form label 6. CSS selector when nothing above reaches it 7. XPath text match, or walk to a parent more resilient more brittle Roughly 80% stop at rung 1 The first rung is not a nice-to-have. It is the default path through a page, because it asks the browser what the element IS rather than where it sits. Rungs 6 and 7 are the fallback.
The ladder as taught. The two bottom rungs are still worth knowing, both for the 20% and because interviewers ask about them.

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).

06

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.

07

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:

HTML
<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.

ONE ELEMENT, TWO THINGS CALLED "NAME" <input type="email" name="username"> The label sewn inside name="username" Read by the server when the form is submitted. Nothing on the page shows it, no screen reader says it. getByRole never sees it. The sign held up beside it accessible name = "Email" Built by the browser from, in order, aria-label, the linked label, the placeholder, then the visible text. This is what getByRole matches.
The analogy from class: the sewn-in label only the laundry reads, versus the sign a teacher holds up so everyone, including someone who cannot see the student, knows who this is.

The spec pushed during the session, quoted from the repository:

TypeScript
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.

TypeScript
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.

08

The second example: it looks like a button

The other spec pushed during the class, and it exists to make exactly one point.

TypeScript
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.

09

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.

TypeScript
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.

10

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 id attributes that the app may not have.
  • No dependence on the name attribute.
  • 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.

11

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.

12

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.

13

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.