Queries and Guiding Principles
1. What is the guiding principle of Testing Library, and how does it change the tests you write?
"The more your tests resemble the way your software is used, the more confidence they can give you."
In practice that means a test renders a component, finds elements by what a user sees or hears (role, accessible name, label, text), interacts the way a user would, and asserts on visible output.
It does not reach into component state, call instance methods or check which hook ran.
The payoff is refactor resistance. A test that finds the submit button by getByRole('button', {name: /save/i}) survives a rewrite from class components to hooks, a CSS module change and a move to a design system button.
A strong candidate adds the second-order benefit: if a query by role cannot find the element, the markup probably has an accessibility problem too, so the test doubles as a cheap a11y check.
2. In what order should you reach for queries?
The queries documentation sets a priority in three groups:
- Accessible to everyone:
getByRole first, then getByLabelText (best for form fields), getByPlaceholderText, getByText for non-interactive content, and getByDisplayValue.
- Semantic queries:
getByAltText and getByTitle. The docs warn that the title attribute is read inconsistently by screen readers.
- Test IDs:
getByTestId, for cases where neither role nor text can identify the element.
A weak answer says "I use test IDs because they are stable."
A strong one explains that placeholder text is a fallback because a placeholder is not a label, and names legitimate uses for test IDs: a canvas chart, a third-party widget with no roles, or a container that only exists for layout.
Setting configure({throwSuggestions: true}) makes the library throw when a better query exists, which is a useful tool in a codebase migrating off test IDs.
3. What is the difference between getBy, queryBy and findBy?
They differ in what happens with zero matches and whether they retry:
getBy* returns the element, and throws on zero or more than one match. Use it for elements that must already be there.
queryBy* returns null on zero matches, and still throws on more than one. Use it only to assert absence.
findBy* returns a promise that retries a getBy* until it passes or times out. Use it for elements that appear after async work.
The *AllBy* variants return arrays and do not throw on multiple matches. Interviewers often ask the follow-up: why does queryBy* throw on multiple matches?
Because a query that silently returned the first of several matches would hide ambiguous markup, and an absence check that passes because the wrong element was found is worse than a failure.
4. Why prefer screen over the queries returned by render?
screen exposes every query bound to document.body, so the test does not need to destructure render's return value and keep that list in sync as queries change.
It reads closer to what a user sees: the whole page, not the component's container. Portals, modals and toasts that render outside the component's container are found by screen and missed by container-bound queries.
render's result still matters for rerender, unmount and container.
The docs note that screen needs a global DOM environment, such as Jest's jsdom test environment or Vitest's environment: 'jsdom'. screen.debug() prints the DOM, and screen.logTestingPlaygroundURL() gives a link that suggests queries for the current markup.
5. How does getByRole compute the accessible name, and what does the hidden option do?
getByRole matches an element's implicit or explicit ARIA role, and the name option matches its accessible name as computed from aria-labelledby, aria-label, an associated <label>, text content or alt, depending on the element.
A button with only an SVG icon has no name unless it has an aria-label, so getByRole('button', {name: 'Close'}) fails, which is the right result.
By default, getByRole excludes elements hidden from the accessibility tree: display: none, visibility: hidden, the hidden attribute and aria-hidden="true". Passing hidden: true includes them.
Other options worth knowing are level for headings, checked, pressed, selected and expanded for state.
A senior candidate also mentions cost: getByRole walks the tree and computes styles, so on DOMs with thousands of nodes it is the slowest query, and scoping it with within keeps suites fast.
6. When do you use within?
within(element) returns the full query set bound to that element.
Use it when the same text or role appears more than once on the page: a table where each row has a "Delete" button, a list of cards each with a "Buy" link, or a dialog that repeats a label from the page behind it.
const row = screen.getByRole('row', {name: /invoice 1042/i})
await user.click(within(row).getByRole('button', {name: /delete/i}))
const dialog = await screen.findByRole('dialog', {name: /confirm delete/i})
expect(within(dialog).getByText(/cannot be undone/i)).toBeInTheDocument()
Without within, candidates fall back to getAllByRole('button')[3], which breaks the moment rows are re-sorted. That index is a good thing to flag in a code review question.
7. How do text matchers work, and what do exact and normalizer change?
Text queries accept a string, a regex or a function. A string matches the full, trimmed text with whitespace collapsed, and is case-sensitive by default. {exact: false} switches to a case-insensitive substring match.
A regex such as /order placed/i is the usual middle ground: case-insensitive and partial, but explicit about it.
The default normalizer trims and collapses whitespace; a custom normalizer can also strip non-breaking spaces or currency formatting.
A function matcher (content, element) => ... handles text split across elements, such as <p>Total: <strong>$40</strong></p>, where getByText('Total: $40') fails because no single element has that exact text content.
Strong candidates reach for a role or label query before writing a function matcher.