EduFlow
Testing JavaScript Apps
0%

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.

  1. 1The guiding principle
  2. 2The priority order
  3. 3A DOM test with Testing Library
  4. 4getBy, queryBy, findBy
  5. 5When a query can’t find your element
  6. 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

Three tiers stacked: accessible to everyone (ByRole, ByLabelText, ByPlaceholderText, ByText, ByDisplayValue), semantic (ByAltText, ByTitle), and test ids last. 1. Accessible to everyone ByRole · ByLabelText · ByPlaceholderText · ByText · ByDisplayValue 2. Semantic ByAltText · ByTitle 3. Last resort ByTestId
Query priority: start at the top, go down only when you must

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:

src/render-balances.test.js
// @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, then getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, then alt text and title, and getByTestId last.
  • getBy throws when it finds zero or several matches, queryBy returns null (use it to assert absence), and findBy waits 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

The “Settle up” button is rendered as <button class="btn btn-primary" data-testid="settle">Settle up</button>. Which query should a test use?

Question 2 of 4

You want to assert that the empty-state message is gone after an expense is added. Which query fits?

Question 3 of 4

screen.getByRole('textbox', { name: 'Amount' }) fails with “Unable to find an accessible element with the role textbox and name Amount”. The page shows the word Amount next to the input. What’s the most likely fix?

Question 4 of 4

Which pair of queries is closest to how a sighted mouse user and a screen reader user would find a list of balances?

Practice

Code playground

Query the balances like a user

renderBalances(container, balances) renders a list labelled Balances: "Ana is owed $12.00", "Ben owes $5.00", "Cai is settled up", sorted by name. With no people it shows No expenses yet and no list.

The starter begins with small stand-ins for Testing Library’s queries (getByRole, getAllByRole, queryByRole, getByText, queryByText; each takes the container as its first argument) and creates a fresh container for every test. Add tests until your suite catches four hidden bugs: swapped owes/is owed wording, a version that hides settled-up people, one that prints a minus sign (“owes -$5.00”), and one with no empty state.

function formatMoney(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}

// Renders balances (in cents) into container.
// - A list labelled "Balances", one item per person, sorted by name:
//     positive -> "Ana is owed $12.00"
//     negative -> "Ben owes $5.00"
//     zero     -> "Cai is settled up"
// - With no people at all: a paragraph "No expenses yet" and no list.
function renderBalances(container, balances) {
  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));
  if (names.length === 0) {
    const empty = document.createElement('p');
    empty.textContent = 'No expenses yet';
    container.replaceChildren(empty);
    return;
  }
  const list = document.createElement('ul');
  list.setAttribute('aria-label', 'Balances');
  for (const name of names) {
    const cents = balances[name];
    const item = document.createElement('li');
    item.textContent =
      cents > 0 ? `${name} is owed ${formatMoney(cents)}`
      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`
      : `${name} is settled up`;
    list.append(item);
  }
  container.replaceChildren(list);
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real renderBalances
  • Catches the version that swaps “owes” and “is owed”
  • Catches the version that hides settled-up people
  • Catches the version that prints “owes -$5.00”
  • Catches the version with no empty state