{"id":"l-tj-3-1","courseId":"testing-javascript","lang":"en","kind":"lesson","summary":"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.","html":"<p>Here is a test I found in a codebase last year: <code dir=\"ltr\">document.querySelector('.card &gt; div:nth-child(2) span.amount-red')</code>. 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 <em>says</em>.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">The guiding principle</h2>\n<p>Testing Library’s core idea fits in one sentence: <em>the more your tests resemble the way your software is used, the more confidence they can give you.</em> Users don’t see class names or <code dir=\"ltr\">nth-child</code>. They see a button labelled “Add expense”, a field labelled “Amount”, a list of balances. Screen reader users get exactly that information through the <strong>accessibility tree</strong>: each element’s role (button, list, textbox) and accessible name (its label or text).</p>\n<p>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.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">The priority order</h2>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 640 250\" role=\"img\" aria-labelledby=\"l-tj-3-1-t1\"><defs><marker id=\"arrow-l-tj-3-1-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-tj-3-1-t1\">Three tiers stacked: accessible to everyone (ByRole, ByLabelText, ByPlaceholderText, ByText, ByDisplayValue), semantic (ByAltText, ByTitle), and test ids last.</title>\n  <rect class=\"d-box-success\" x=\"20\" y=\"20\" width=\"600\" height=\"80\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"50\">1. Accessible to everyone</text>\n  <text class=\"d-code\" x=\"40\" y=\"80\">ByRole · ByLabelText · ByPlaceholderText · ByText · ByDisplayValue</text>\n  <rect class=\"d-box-accent\" x=\"20\" y=\"115\" width=\"600\" height=\"56\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"148\">2. Semantic</text>\n  <text class=\"d-code\" x=\"220\" y=\"148\">ByAltText · ByTitle</text>\n  <rect class=\"d-box-warn\" x=\"20\" y=\"186\" width=\"600\" height=\"50\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"216\">3. Last resort</text>\n  <text class=\"d-code\" x=\"220\" y=\"216\">ByTestId</text>\n</svg><figcaption>Query priority: start at the top, go down only when you must</figcaption></figure>\n<p><code dir=\"ltr\">getByRole</code> with a <code dir=\"ltr\">name</code> option covers most cases: buttons, links, headings, form fields, lists, dialogs, alerts. <code dir=\"ltr\">getByLabelText</code> is the natural choice for form fields. <code dir=\"ltr\">getByText</code> 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.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">A DOM test with Testing Library</h2>\n<p>Splitwise-lite renders balances as a plain list. With <code dir=\"ltr\">jsdom</code> as the environment and <code dir=\"ltr\">@testing-library/dom</code> installed, a test looks like this:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/render-balances.test.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// @vitest-environment jsdom</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { afterEach, test, expect } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'vitest'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { screen, within } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/dom'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { renderBalances } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './render-balances.js'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">afterEach</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.body.innerHTML </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ''</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'lists each person with what they owe or are owed'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  renderBalances</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, { Ben: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, Ana: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1200</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, Cai: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> list</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> items</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> within</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(list).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getAllByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'listitem'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(items.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">map</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">item</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> item.textContent)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Ana is owed $12.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Ben owes $5.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Cai is settled up'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ]);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'shows an empty state instead of an empty list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  renderBalances</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, {});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByText</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'No expenses yet'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeTruthy</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">queryByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeNull</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p><code dir=\"ltr\">screen</code> queries the whole document. <code dir=\"ltr\">within(element)</code> scopes queries to part of it, which matters as soon as a page has two lists. Clearing <code dir=\"ltr\">document.body</code> 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.)</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">getBy, queryBy, findBy</h2>\n<p>Each query comes in three flavours, plus an <code dir=\"ltr\">All</code> version of each:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>Prefix</th>\n<th>No match</th>\n<th>Several matches</th>\n<th>Use it for</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code dir=\"ltr\">getBy</code></td>\n<td>throws</td>\n<td>throws</td>\n<td>elements that must be there</td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">queryBy</code></td>\n<td>returns <code dir=\"ltr\">null</code></td>\n<td>throws</td>\n<td>asserting something is <strong>absent</strong></td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">findBy</code></td>\n<td>rejects after waiting</td>\n<td>rejects</td>\n<td>elements that appear <strong>later</strong></td>\n</tr>\n</tbody>\n</table></div><p><code dir=\"ltr\">getBy</code> throwing on several matches is a feature: if your query is ambiguous, the test tells you instead of silently picking the first.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Nicer assertions with jest-dom</span></p>\n<p>Add <code dir=\"ltr\">import '@testing-library/jest-dom/vitest'</code> to a setup file and you get DOM matchers such as <code dir=\"ltr\">toBeInTheDocument()</code>, <code dir=\"ltr\">toHaveTextContent()</code>, <code dir=\"ltr\">toBeVisible()</code>, <code dir=\"ltr\">toBeDisabled()</code> and <code dir=\"ltr\">toHaveAccessibleName()</code>. Their failure messages print the relevant DOM, which beats reading <code dir=\"ltr\">expected null not to be null</code>.</p>\n</aside>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Switching to a test id when a role query fails</span></p>\n<p>When <code dir=\"ltr\">getByRole('textbox', { name: 'Amount' })</code> can’t find the input, the usual reason is that the input really has no accessible name: the word “Amount” sits beside it but isn’t a <code dir=\"ltr\">&lt;label&gt;</code>. Adding a test id makes the test pass and leaves the form broken for screen reader users. Fix the markup; the test was right.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">When a query can’t find your element</h2>\n<p>Role queries fail loudly, and the failure is useful. When <code dir=\"ltr\">getByRole('button', { name: 'Settle up' })</code> 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 <code dir=\"ltr\">div</code> has no role at all. Call <code dir=\"ltr\">screen.debug()</code> to print the current DOM when you need more context. Accessible names come from a handful of places, in roughly this order: <code dir=\"ltr\">aria-labelledby</code>, <code dir=\"ltr\">aria-label</code>, an associated <code dir=\"ltr\">&lt;label&gt;</code>, then the element’s own text (or <code dir=\"ltr\">alt</code> for images). If none of them give the name you expect, a screen reader user hears the same confusing thing your test sees.</p>\n<h2 id=\"sec-6\" tabindex=\"-1\">In the playground</h2>\n<p>The playground has no npm packages, so the exercises in this section start with about forty lines of stand-ins: <code dir=\"ltr\">getByRole</code>, <code dir=\"ltr\">getAllByRole</code>, <code dir=\"ltr\">queryByRole</code>, <code dir=\"ltr\">getByText</code> and <code dir=\"ltr\">queryByText</code>, with the same names and behaviour for the cases you need. Like the plain (non-<code dir=\"ltr\">screen</code>) exports of <code dir=\"ltr\">@testing-library/dom</code>, they take the container as their first argument: <code dir=\"ltr\">getByRole(container, 'list')</code>. They read the role from the tag (or a <code dir=\"ltr\">role</code> attribute) and the name from <code dir=\"ltr\">aria-label</code>, a <code dir=\"ltr\">&lt;label&gt;</code> or the text. Real Testing Library computes the accessibility tree much more thoroughly, so in a project, install it.</p>\n<p>Next lesson: clicking and typing like a user.</p>\n","toc":[{"id":"sec-1","text":"The guiding principle","level":2},{"id":"sec-2","text":"The priority order","level":2},{"id":"sec-3","text":"A DOM test with Testing Library","level":2},{"id":"sec-4","text":"getBy, queryBy, findBy","level":2},{"id":"sec-5","text":"When a query can’t find your element","level":2},{"id":"sec-6","text":"In the playground","level":2}],"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: <code dir=\"ltr\">getByRole</code>, then <code dir=\"ltr\">getByLabelText</code>, <code dir=\"ltr\">getByPlaceholderText</code>, <code dir=\"ltr\">getByText</code>, <code dir=\"ltr\">getByDisplayValue</code>, then alt text and title, and <code dir=\"ltr\">getByTestId</code> last.","<code dir=\"ltr\">getBy</code> throws when it finds zero or several matches, <code dir=\"ltr\">queryBy</code> returns <code dir=\"ltr\">null</code> (use it to assert absence), and <code dir=\"ltr\">findBy</code> 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":[{"title":"Testing Library — About Queries","url":"https://testing-library.com/docs/queries/about"},{"title":"Testing Library — ByRole","url":"https://testing-library.com/docs/queries/byrole"},{"title":"MDN — ARIA roles","url":"https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles"}],"quiz":[{"q":"<p>The “Settle up” button is rendered as <code dir=\"ltr\">&lt;button class=&quot;btn btn-primary&quot; data-testid=&quot;settle&quot;&gt;Settle up&lt;/button&gt;</code>. Which query should a test use?</p>","options":[{"text":"<code dir=\"ltr\">container.querySelector('.btn-primary')</code>","why":"Class names are styling decisions; renaming them during a redesign would break the test though the button still works."},{"text":"<code dir=\"ltr\">screen.getByTestId('settle')</code>","why":"It works and survives restyling, but it’s the last resort in the priority list because users can’t see test ids, so it proves nothing about accessibility."},{"text":"<code dir=\"ltr\">screen.getByRole('button', { name: 'Settle up' })</code>","why":"Correct. It finds the element the way a screen reader user would, and it fails if the button loses its role or its accessible name."}],"answer":2},{"q":"<p>You want to assert that the empty-state message is gone after an expense is added. Which query fits?</p>","options":[{"text":"<code dir=\"ltr\">expect(screen.queryByText('No expenses yet')).toBeNull()</code>","why":"Correct. <code dir=\"ltr\">queryBy</code> returns <code dir=\"ltr\">null</code> when nothing matches, which is exactly what you want to assert."},{"text":"<code dir=\"ltr\">expect(screen.getByText('No expenses yet')).toBeNull()</code>","why":"<code dir=\"ltr\">getBy</code> throws when nothing matches, so the test errors before <code dir=\"ltr\">toBeNull</code> runs; it can never pass."},{"text":"<code dir=\"ltr\">expect(screen.findByText('No expenses yet')).toBeNull()</code>","why":"<code dir=\"ltr\">findBy</code> returns a promise (which is never <code dir=\"ltr\">null</code>) and waits for the element to appear, the opposite of what you want."}],"answer":0},{"q":"<p><code dir=\"ltr\">screen.getByRole('textbox', { name: 'Amount' })</code> 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?</p>","options":[{"text":"Add <code dir=\"ltr\">data-testid=&quot;amount&quot;</code> and query with <code dir=\"ltr\">getByTestId</code>.","why":"That makes the test pass but leaves the real problem; screen reader users still hear an unlabelled field."},{"text":"Associate the text with the input using a <code dir=\"ltr\">&lt;label&gt;</code> (wrapping it or with <code dir=\"ltr\">for</code>/<code dir=\"ltr\">id</code>).","why":"Correct. Text that merely sits nearby is not a label. Once it’s associated, the input gets an accessible name and the query, and your users, find it."},{"text":"Use <code dir=\"ltr\">getByText('Amount')</code> and then read <code dir=\"ltr\">.nextSibling</code>.","why":"Walking siblings couples the test to the exact DOM structure, and the input is still unlabelled."}],"answer":1},{"q":"<p>Which pair of queries is closest to how a sighted mouse user and a screen reader user would find a list of balances?</p>","options":[{"text":"<code dir=\"ltr\">document.querySelector('ul')</code> and <code dir=\"ltr\">.children</code>","why":"These follow DOM structure; adding a wrapper or a second list would break or confuse the test."},{"text":"<code dir=\"ltr\">getByTestId('balances')</code> and <code dir=\"ltr\">getAllByTestId('row')</code>","why":"Test ids are invisible to users and the last resort in the priority list."},{"text":"<code dir=\"ltr\">getByRole('list', { name: 'Balances' })</code> and <code dir=\"ltr\">within(list).getAllByRole('listitem')</code>","why":"Correct. The list’s role and label are what assistive tech announces, and <code dir=\"ltr\">within</code> scopes the item query to that list."}],"answer":2}],"exercise":{"kind":"web","mode":"test","subject":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n","mutants":[{"id":"swapped-wording","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} owes ${formatMoney(cents)}`\n      : cents < 0 ? `${name} is owed ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"},{"id":"hides-settled","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).filter((n) => balances[n] !== 0).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"},{"id":"minus-sign","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"},{"id":"no-empty-state","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (false) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"}],"starter":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('renderBalances', () => {\n  test('renders a list labelled Balances', () => {\n    renderBalances(container, { Ana: 1200, Ben: -1200 });\n\n    expect(getByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n  });\n\n  // Add tests that read what the user reads: the text of each item,\n  // a settled-up person, and the empty state.\n});\n"},"solution":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('renderBalances', () => {\n  test('renders a list labelled Balances', () => {\n    renderBalances(container, { Ana: 1200, Ben: -1200 });\n\n    expect(getByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n  });\n\n  test('says who owes and who is owed, sorted by name', () => {\n    renderBalances(container, { Ben: -500, Cai: 0, Ana: 500 });\n\n    const items = getAllByRole(container, 'listitem');\n    expect(items.map((item) => item.textContent)).toEqual([\n      'Ana is owed $5.00',\n      'Ben owes $5.00',\n      'Cai is settled up',\n    ]);\n  });\n\n  test('shows an empty state instead of an empty list', () => {\n    renderBalances(container, {});\n\n    expect(getByText(container, 'No expenses yet')).toBeTruthy();\n    expect(queryByRole(container, 'list')).toBeNull();\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-swapped-wording","suite":"swapped-wording"},{"id":"catches-hides-settled","suite":"hides-settled"},{"id":"catches-minus-sign","suite":"minus-sign"},{"id":"catches-no-empty-state","suite":"no-empty-state"}],"title":"Query the balances like a user","prompt":"<p><code dir=\"ltr\">renderBalances(container, balances)</code> renders a list labelled <strong>Balances</strong>: <code dir=\"ltr\">&quot;Ana is owed $12.00&quot;</code>, <code dir=\"ltr\">&quot;Ben owes $5.00&quot;</code>, <code dir=\"ltr\">&quot;Cai is settled up&quot;</code>, sorted by name. With no people it shows <code dir=\"ltr\">No expenses yet</code> and no list.</p>\n<p>The starter begins with small stand-ins for Testing Library’s queries (<code dir=\"ltr\">getByRole</code>, <code dir=\"ltr\">getAllByRole</code>, <code dir=\"ltr\">queryByRole</code>, <code dir=\"ltr\">getByText</code>, <code dir=\"ltr\">queryByText</code>; each takes the container as its first argument) and creates a fresh <code dir=\"ltr\">container</code> for every test. Add tests until your suite catches four hidden bugs: swapped <strong>owes</strong>/<strong>is owed</strong> wording, a version that <strong>hides</strong> settled-up people, one that prints a <strong>minus sign</strong> (“owes -$5.00”), and one with <strong>no empty state</strong>.</p>\n","hints":["Read what the user reads: <code dir=\"ltr\">getAllByRole(container, 'listitem').map((li) =&gt; li.textContent)</code> gives you every line of text.","Use one balance of each kind (positive, negative, zero) so a single <code dir=\"ltr\">toEqual</code> on the texts covers the wording, the sign and the settled person.","For the empty state, call <code dir=\"ltr\">renderBalances(container, {})</code>, then <code dir=\"ltr\">getByText(container, 'No expenses yet')</code> and <code dir=\"ltr\">expect(queryByRole(container, 'list')).toBeNull()</code>."],"explanation":"<p>The strongest assertion here compares the visible text of every item with one balance of each kind. That single <code dir=\"ltr\">toEqual</code> catches the swapped wording, the stray minus sign and the missing settled-up line, because each changes what a user would read. The empty-state test uses <code dir=\"ltr\">getByText</code> for what must be there and <code dir=\"ltr\">queryByRole(...)</code> with <code dir=\"ltr\">toBeNull()</code> for what must not, the standard <code dir=\"ltr\">getBy</code>/<code dir=\"ltr\">queryBy</code> split. None of the tests depends on tag nesting or classes: you could render the balances as a <code dir=\"ltr\">&lt;div role=&quot;list&quot;&gt;</code> with <code dir=\"ltr\">role=&quot;listitem&quot;</code> rows, or restyle everything, and they would still pass.</p>\n","checkLabels":{"passes":"All your tests pass against the real renderBalances","catches-swapped-wording":"Catches the version that swaps “owes” and “is owed”","catches-hides-settled":"Catches the version that hides settled-up people","catches-minus-sign":"Catches the version that prints “owes -$5.00”","catches-no-empty-state":"Catches the version with no empty state"}},"words":730,"readMinutes":3}