Browser tests

Locate elements by role

Select controls through accessible roles, names, labels, and visible behavior instead of fragile CSS implementation details.

A locator is how a Playwright test finds an element on the page. My rule: a locator should find the element the way a user would. Users don’t look for .btn-primary:nth-child(2). They look for a button that says “Add book”.

Prefer roles, labels, and text

Playwright gives us getByRole, getByLabel, and getByText for exactly this. Here’s the add-book flow:

await page.getByLabel('Title').fill('Dune')
await page.getByLabel('Author').fill('Frank Herbert')
await page.getByRole('button', { name: 'Add book' }).click()
await expect(page.getByRole('listitem')).toContainText('Dune')

Read it aloud and it’s the instruction you’d give a person. Fill in Title, fill in Author, press Add book, see Dune in the list.

The accessible name matters as much as the role. A button gets its name from its text, from an aria-label, or from an element that labels it. getByRole('button', { name: 'Add book' }) follows the same path a screen reader follows. So when this locator can’t find the button, a screen reader user probably can’t either. The test just found an accessibility bug for free.

Strictness is a feature

Locators used for actions are strict. If two buttons match, Playwright doesn’t pick one. It fails:

Error: strict mode violation: getByRole('button', { name: 'Add book' }) resolved to 2 elements

Treat that as evidence, not as an obstacle. Either the page has two controls with the same name, which confuses users too, or your locator needs scoping to the right form.

Resist .first(). It makes the error go away by clicking whichever duplicate rendered first. That’s a false positive waiting to happen.

Two ways to be fragile

CSS classes describe the implementation. A redesign renames .btn-primary and the test breaks while the feature still works.

A very broad text match has the opposite problem. getByText('Dune') can find the word in a hidden template, a navigation item, or an old notification, and pass before the saved book appears. Scope the assertion to the list, and include the author when that’s what identifies the new record:

const list = page.getByRole('list', { name: 'Books' })
await expect(list.getByRole('listitem')).toContainText('Dune, Frank Herbert')

When to use test IDs

getByTestId is fine for behavior with no user-facing semantics. Treat the ID as a stable contract, not a shortcut. And never add inaccessible markup just to make a test easier. Fix the markup so the role locator works, and users get the fix too.

Try this: write the add-book flow with labels, roles, and visible text. Then duplicate the Add book button on purpose, read the strictness error, and fix it by naming the controls or scoping the locator. No .first().

Lesson completed