In a React Testing Library test, screen.getByRole('button', { name: 'Save' }) throws even though the Save control is visibly rendered and clickable in the browser. What markup causes that, and why is the failing query often a real defect rather than a test problem?
answer
- queries the accessibility tree, not the DOM shape
- a clickable div has no role
- hidden ancestors remove whole subtrees
- string name must match the whole name
- the failed query printed the available roles
basics
~20 sRole queries read the accessibility tree, so they miss a control that has no role (a clickable div), is hidden from that tree (aria-hidden, display:none, the hidden attribute), or whose accessible name is not the text you passed. Each of those is usually a genuine bug.
solid answer
~50 s`getByRole` does not look at tags or CSS — it resolves against the accessibility tree. Three families of cause: the element has no button role at all, typically a `<div onClick>` or a styled `<span>`; the element is excluded from the tree because it or an ancestor carries `aria-hidden="true"`, `display: none`, `visibility: hidden` or the `hidden` attribute, and `getByRole` skips those unless you pass `hidden: true`; or the role is right but the name is not — an icon-only button with no label, or a name that differs from the visible text, and remember a string `name` must match the whole normalised name, so partial text needs a regex. I read the failure output first: it prints the DOM and lists the roles that were actually found. Then I fix the component, because if the query cannot find the control, neither can a screen reader or voice control.
code
javascript · 16 linesimport { screen } from '@testing-library/dom'
document.body.innerHTML = `
<div class="btn" onclick="save()">Save</div>
<div aria-hidden="true"><button>Cancel</button></div>
<button aria-label="Save changes">Save</button>
`
// no role at all -> never found by a button query
// screen.getByRole('button', { name: 'Save' }) throws
// hidden ancestor -> excluded from the accessibility tree
// screen.getByRole('button', { name: 'Cancel' }) throws
// name is the whole normalised string, so use a regex for a partial match
screen.getByRole('button', { name: /save/i })go deeper
Know that getByRole looks at the accessibility tree, so a <div> with an onClick handler is not a button and will never be found by a button query.
Explain the three failure families — missing role, hidden from the tree including via an ancestor, and a name that does not match — and know that a string name must match the whole accessible name.
Demonstrate the diagnosis loop: read the printed roles from the failure, classify the cause, and argue for fixing the component rather than the query when a real user could not find the control either.
Own the policy question — a suite that routinely silences role failures with test ids has quietly deleted its only accessibility signal, so decide where that escape hatch is allowed and how the exception gets reviewed.
## What the query is actually asking `getByRole('button', { name: 'Save' })` asks: is there an element exposed to assistive technology as a button, whose accessible name is "Save"? That is a different question from "is there something on screen a mouse can click", and the gap between the two is exactly where this failure lives. ## Cause 1: no role The most common cause is a control that is not a control. ```javascript // no role: exposed as generic content '<div class="btn" onclick="save()">Save</div>' // role button, keyboard operable, focusable '<button type="button">Save</button>' ``` A `<div>` with a click handler has no implicit role, so no role query will ever find it. The test failure is the cheapest possible signal that the control is unreachable by keyboard and invisible to assistive technology. A rarer variant: the element *has* a role, but the wrong one, because someone wrote `role="link"` on something that behaves like a button, or a component library renders an `<a>` where the design says button. Then `getByRole('link', { name: 'Save' })` succeeds and the button query fails — and the query has just documented a semantic mismatch. ## Cause 2: hidden from the accessibility tree `ByRole` queries ignore elements excluded from the accessibility tree. Exclusion comes from `aria-hidden="true"`, `display: none`, `visibility: hidden` or the `hidden` attribute — **on the element or on any ancestor**. The ancestor case is the one that burns people: a wrapper with `aria-hidden="true"` (often added to a decorative shell, or left behind by a modal/overlay implementation) removes everything under it. The query accepts `hidden: true` to include hidden elements, but reaching for that is almost always the wrong move in a test that then goes on to click the element. If the control is hidden from assistive technology, a user of that technology cannot press it, and a test that presses it anyway is asserting a behaviour some users do not have. A related trap in JSDOM-based environments: styles that come from a real stylesheet are not applied, so an element hidden only by an external CSS class may still be visible to the query, while inline `style="display:none"` is honoured. That is a reason to be careful about drawing visibility conclusions from a component test alone. ## Cause 3: the name does not match If the role query succeeds without a `name` but fails with it, the problem is naming. Two mechanics matter for the test author. First, **a string `name` matches the full, normalised accessible name**, not a substring. `{ name: 'Save' }` will not find a control named "Save changes"; `{ name: /save/i }` will. Whitespace is normalised (trimmed and collapsed) before comparison, so line breaks in JSX are not the culprit. Second, **the accessible name may not be the visible text**. An icon-only button typically has no name at all until someone labels it. A button whose label was set from a different source can be announced as something other than what is drawn on screen — which is also a voice-control defect, since the user says the words they can see. How the name is computed from the markup is the accessibility layer's subject; what matters here is the testing consequence: a role query is only as good as the name the component exposes, and a mismatch is a finding, not an inconvenience. ## Reading the failure The error output is designed for this. A failed `ByRole` query prints the rendered DOM and enumerates the roles that *were* present, with the accessible name of each. That usually answers the question in one read: no button role at all (cause 1), the button is missing from the list entirely (cause 2), or the button is listed with a different name (cause 3). ## Why the fix belongs in the component The tempting fixes — add a `data-testid`, switch to `container.querySelector('.btn')`, pass `hidden: true` — all make the test green while leaving the product broken. The role query is the only routine check most teams run that touches the accessibility tree at all, so silencing it removes the signal. The right sequence is: read the printed roles, decide whether the markup or the query is wrong, and change the markup when the answer is "a real user could not find this either". Reserve the escape hatches for elements that genuinely have no user-facing identity.
- When is passing hidden: true to a ByRole query defensible?When the assertion is about something being present but intentionally not exposed — for example checking that a decorative or offscreen element still exists in the DOM for a later transition. If the test goes on to click or type into the element, hidden: true is a smell: you are driving a control that assistive-technology users cannot reach, so the test no longer describes the product they get.
- The role query succeeds without a name option but fails with { name: 'Save' }. What does that narrow it down to?Naming, not role or visibility. Either the accessible name differs from the visible text, or the name is longer than the string you passed — a string matches the whole normalised name, so "Save changes" fails against 'Save'. Try a regex to confirm, then decide whether the component's label or the test's expectation is the thing that should change.
- Why not just standardise on data-testid for every interactive control and avoid this class of failure entirely?Because the failure is the value. A test-id query passes whether or not the control has a role, a name or keyboard access, so the suite stops detecting a whole class of defect that nothing else in a typical pipeline catches. Test ids are the right tool for elements with no user-facing identity, not a general replacement for querying the way a user perceives the UI.
saying these in an interview costs you the question
- Adds a data-testid so the failing query goes away
- Assumes a clickable div is exposed as a button
- Says getByRole matches the CSS selector or tag name
- Passes hidden: true to work around aria-hidden and then clicks
- Thinks a string name option matches a substring