Section 3 · Testing the UI
Querying the DOM Like a User
18 min3 min readCode playground
Lesson at a glance
Find elements in DOM tests the way users and assistive technology do, with Testing Library’s role, label and text queries in the recommended priority order.
- 1The guiding principle
- 2The priority order
- 3A DOM test with Testing Library
- 4getBy, queryBy, findBy
- 5When a query can’t find your element
- 6In the playground
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Here is a test I found in a codebase last year: document.querySelector('.card > div:nth-child(2) span.amount-red'). It broke when a designer changed the colour of negative amounts. Nothing the user could see had broken, except the colour, which nobody tested. The test was coupled to the DOM’s shape and the CSS, not to what the page says.
The guiding principle
Testing Library’s core idea fits in one sentence: the more your tests resemble the way your software is used, the more confidence they can give you. Users don’t see class names or nth-child. They see a button labelled “Add expense”, a field labelled “Amount”, a list of balances. Screen reader users get exactly that information through the accessibility tree: each element’s role (button, list, textbox) and accessible name (its label or text).
So Testing Library’s queries find elements by role, label and text. A test that finds the “Settle up” button by role and name keeps passing through any redesign, and fails the day the button stops being reachable for keyboard and screen reader users. That is a bug worth catching.
The priority order
getByRole with a name option covers most cases: buttons, links, headings, form fields, lists, dialogs, alerts. getByLabelText is the natural choice for form fields. getByText finds non-interactive content like a message or a balance line. Test ids are for elements with no role or text, such as a chart canvas, and they prove nothing about accessibility.
A DOM test with Testing Library
Splitwise-lite renders balances as a plain list. With jsdom as the environment and @testing-library/dom installed, a test looks like this:
// @vitest-environment jsdom
import { afterEach, test, expect } from 'vitest';
import { screen, within } from '@testing-library/dom';
import { renderBalances } from './render-balances.js';
afterEach(() => {
document.body.innerHTML = '';
});
test('lists each person with what they owe or are owed', () => {
renderBalances(document.body, { Ben: -500, Ana: 1200, Cai: 0 });
const list = screen.getByRole('list', { name: 'Balances' });
const items = within(list).getAllByRole('listitem');
expect(items.map((item) => item.textContent)).toEqual([
'Ana is owed $12.00',
'Ben owes $5.00',
'Cai is settled up',
]);
});
test('shows an empty state instead of an empty list', () => {
renderBalances(document.body, {});
expect(screen.getByText('No expenses yet')).toBeTruthy();
expect(screen.queryByRole('list')).toBeNull();
});screen queries the whole document. within(element) scopes queries to part of it, which matters as soon as a page has two lists. Clearing document.body after each test keeps one test’s DOM from leaking into the next. (React Testing Library can do this cleanup for you; lesson four of this section shows when.)
getBy, queryBy, findBy
Each query comes in three flavours, plus an All version of each:
| Prefix | No match | Several matches | Use it for |
|---|---|---|---|
getBy |
throws | throws | elements that must be there |
queryBy |
returns null |
throws | asserting something is absent |
findBy |
rejects after waiting | rejects | elements that appear later |
getBy throwing on several matches is a feature: if your query is ambiguous, the test tells you instead of silently picking the first.
When a query can’t find your element
Role queries fail loudly, and the failure is useful. When getByRole('button', { name: 'Settle up' }) finds nothing, Testing Library prints every accessible role in the container with the names it computed, so you can see that the button is actually named “Settle up all debts”, or that your clickable div has no role at all. Call screen.debug() to print the current DOM when you need more context. Accessible names come from a handful of places, in roughly this order: aria-labelledby, aria-label, an associated <label>, then the element’s own text (or alt for images). If none of them give the name you expect, a screen reader user hears the same confusing thing your test sees.
In the playground
The playground has no npm packages, so the exercises in this section start with about forty lines of stand-ins: getByRole, getAllByRole, queryByRole, getByText and queryByText, with the same names and behaviour for the cases you need. Like the plain (non-screen) exports of @testing-library/dom, they take the container as their first argument: getByRole(container, 'list'). They read the role from the tag (or a role attribute) and the name from aria-label, a <label> or the text. Real Testing Library computes the accessibility tree much more thoroughly, so in a project, install it.
Next lesson: clicking and typing like a user.
Key takeaways
- Query by what users perceive (role and accessible name, label, visible text), not by CSS classes or DOM structure, so restyling doesn’t break tests.
- The priority order is:
getByRole, thengetByLabelText,getByPlaceholderText,getByText,getByDisplayValue, then alt text and title, andgetByTestIdlast. getBythrows when it finds zero or several matches,queryByreturnsnull(use it to assert absence), andfindBywaits for an element to appear.- If a role query can’t find your element, the markup is often not accessible yet; fix the markup rather than switching to a test id.