React Testing Library — Query Priority, Role-Based Queries
TL;DR
React Testing Library (RTL) is the default testing library for React components. Its core insight: query the DOM the way a user (and assistive technology) does — by role, by label, by visible text. Queries are prioritized: role > label > placeholder > text > display-value > alt > title > test ID. Following the priority makes tests survive refactors and doubles as an a11y check — if you can’t query by role, screen-reader users can’t find it either.
Interview Q&A
Q: Three query variants — getBy, queryBy, findBy — when each?
A:
| Throws if not found? | Returns | When | |
|---|---|---|---|
getBy* |
yes | element | assertion-positive — “this should exist now” |
queryBy* |
no — returns null |
element or null | assertion-negative — “this should NOT exist” |
findBy* |
yes (after timeout) | Promise |
async — “this should exist eventually” |
expect(screen.getByRole("heading", { name: /welcome/i })).toBeInTheDocument();
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
expect(await screen.findByText(/loaded/i)).toBeInTheDocument();
Each also has plural forms (getAllBy*, queryAllBy*, findAllBy*) returning arrays. Using the wrong variant is the most common RTL mistake.
Q: Query priority — what’s the order and why?
A: From Testing Library’s official priority:
*ByRole— accessible to everyone, including assistive tech. The default.*ByLabelText— for form fields, since users see labels.*ByPlaceholderText— when labels aren’t present (uglier, but sometimes necessary).*ByText— non-interactive text content.*ByDisplayValue— form values.*ByAltText— images.*ByTitle— last resort for tooltips.*ByTestId— escape hatch when nothing semantic works.
The principle: if you have to use getByTestId, your component probably isn’t accessible. Fix the component (add a label, use a <button> instead of a <div onClick>) and use a higher-priority query.
Q: Show a representative test.
A:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import LoginForm from "./LoginForm";
describe("LoginForm", () => {
it("submits credentials and shows welcome", async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Query by role + name
await user.type(screen.getByLabelText(/email/i), "ada@example.com");
await user.type(screen.getByLabelText(/password/i), "secret");
await user.click(screen.getByRole("button", { name: /sign in/i }));
// Async assertion
expect(await screen.findByText(/welcome, ada/i)).toBeInTheDocument();
});
});
What this verifies:
- The form is accessible (label-text queries work → screen readers find the fields).
- User flow works end-to-end.
- Async state transition completes.
If a refactor changes useState to useReducer, switches from <form onSubmit> to a button handler, or swaps the rendering library — this test still passes because the behavior is unchanged.
Q: screen vs container from render()?
A: screen is bound to document.body — the global root. container is the wrapper element render() mounted into.
const { container } = render(<App />);
screen.getByText(/hello/i); // queries document.body
within(container).getByText(/hello/i); // queries inside the render container
Use screen by default. Use within(container) or container.querySelector only when you need to scope to a specific subtree (rare).
Q: How do you query inside a specific element?
A: within(element):
const article = screen.getByRole("article");
const heading = within(article).getByRole("heading", { name: /title/i });
const author = within(article).getByText(/by ada/i);
Useful for tables, lists, repeated UI — scope each query to a specific row.
Q: How do you handle multiple matching elements?
A: getAllBy* returns an array (throws if zero); queryAllBy* returns an array (empty if zero):
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(3);
If your test only wants one specific element but there are many, narrow the query — getByRole("button", { name: /save/i }) instead of grabbing all buttons.
Q: Common roles you’ll query?
A: Just-the-common-cases for senior fluency:
| Element | Default role |
|---|---|
<button>, <input type="button"> |
button |
<a href> |
link |
<h1>–<h6> |
heading |
<input type="text"> (most types) |
textbox |
<input type="checkbox"> |
checkbox |
<input type="radio"> |
radio |
<input type="search"> |
searchbox |
<select> |
combobox (yes — confusing) |
<ul> / <ol> |
list |
<li> |
listitem |
<table> |
table |
<tr> |
row |
<td> |
cell |
<img alt> |
img |
<form aria-label> |
form |
<dialog> |
dialog |
<nav> |
navigation |
<main> |
main |
<aside> |
complementary |
getByRole accepts the role + an options object: { name, level, checked, expanded, current, hidden }. name matches the accessible name (text content, aria-label, etc.).
Q: name option — how does it compute the accessible name?
A: ARIA’s algorithm: aria-labelledby → aria-label → <label for> (for form controls) → text content → title. Testing Library uses the same algorithm. So <button>Save</button>, <button aria-label="Save">×</button>, and <button><span>Save</span></button> all match { name: /save/i }.
The regex (/save/i) is the common form — case-insensitive substring match. Strings also work but require exact equality.
Q: Querying form fields.
A: Prefer getByLabelText:
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Test
screen.getByLabelText(/email/i);
This validates the label association (a real a11y requirement) in passing. If the test fails, the label is missing or mis-associated — a real bug.
For checkboxes/radios, getByRole("checkbox", { name: /agree/i }).
Q: Testing with React Router?
A: Mount with MemoryRouter or RouterProvider:
import { MemoryRouter } from "react-router-dom";
render(
<MemoryRouter initialEntries={["/users/1"]}>
<App />
</MemoryRouter>,
);
For data routers (v6.4+), use RouterProvider with a memory router:
const router = createMemoryRouter([
{ path: "/users/:id", element: <UserPage /> },
], { initialEntries: ["/users/1"] });
render(<RouterProvider router={router} />);
Q: Testing with React Query / TanStack Query?
A: Wrap in a QueryClientProvider per test:
function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, // disable retries in tests
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
A custom render utility consolidates the providers (Theme, Router, Query, etc.) so individual tests stay focused.
// test-utils.tsx
function customRender(ui: React.ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * from "@testing-library/react";
export { customRender as render };
Then import { render, screen } from "@/test-utils" everywhere.
Gotchas / edge cases
getBy*failure messages are excellent — they include a snapshot of what is in the DOM and what was searched for. Read them.findBy*defaults to 1000ms timeout — usually enough; bump withfindBy*("text", {}, { timeout: 5000 })for genuinely slow async.waitForElementToBeRemovedfor the opposite — “wait until this disappears.” Useful for “wait for spinner to go away.”screen.debug()prints current DOM — invaluable for debugging failing queries. Passscreen.debug(element, Infinity)to print full tree.- Implicit roles depend on the markup —
<div role="button">works for queries but consider semantic<button>instead. <input type="hidden">has no role; query bygetByDisplayValueorcontainer.querySelector.- Cleaning up between tests — RTL auto-cleans in modern setups (since v9 + Vitest setup); verify in your config.
- Querying styled components — the rendered HTML is what matters, not the JSX source. Inspect with
screen.debug().
What a senior is expected to say
- “Query by role first. If I can’t reach an element by role, the component isn’t accessible — fix the component, not the test.”
- “
getBy*for ‘must exist now’,queryBy*for ‘must not exist’,findBy*for ‘must exist eventually’. Mixing these up is the most common mistake.” - “Tests assert behavior — render → user.action → expect screen.getByRole. No spying on
setState, no peeking at internal state.” - “Custom render utility consolidates providers (Query, Router, Theme). Individual tests stay focused on the component.”
- “
screen.debug()prints the rendered DOM — the first tool when a query fails.”
Cross-references
- user-event vs fireEvent: 04_user_event_vs_fireevent.md
- MSW for network mocking in tests: 05_msw_network_mocking.md
- Async testing patterns: 06_async_ui_testing.md
- A11y testing: 10_a11y_and_visual_regression.md
Further reading
- Testing Library — Query Priority: https://testing-library.com/docs/queries/about/#priority
- Testing Library —
*ByRole: https://testing-library.com/docs/queries/byrole - Testing Library — Common mistakes: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library