React Testing Library — Query Priority, Role-Based Queries
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.
Three query variants — getBy, queryBy, findBy — when each?
| If not found | When | |
|---|---|---|
getBy* |
throws | assertion-positive: “this should exist now” |
queryBy* |
returns null |
assertion-negative: “this should NOT exist” |
findBy* |
its Promise rejects after a timeout | async: “this should exist eventually” |
When the element is there, getBy* and queryBy* return it and findBy*
resolves to it.
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.
Query priority — what’s the order and why?
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.
Show a representative test.
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.
screen vs container from render()?
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 containerUse screen by default. Use within(container) or container.querySelector only when you need to scope to a specific subtree (rare).
How do you query inside a specific element?
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.
How do you handle multiple matching elements?
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.
Common roles you’ll query?
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.).
name option — how does it compute the accessible name?
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.
Querying form fields.
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 }).
Testing with React Router?
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} />);Testing with React Query / TanStack Query?
Wrap in a QueryClientProvider per test:
function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
// disable retries in tests
defaultOptions: { queries: { retry: false } },
});
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
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().
Related
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
Interview angle 5
- “How should you query for elements?” - by role first, then by label or
text. If an element cannot be reached by role, that is information: the
component is not accessible, and the right fix is the component rather than a
data-testid. - “What is the difference between
getBy,queryByandfindBy?” -getBythrows if absent, so it asserts “this exists now”.queryByreturns null, so it is the only correct one for “this must not exist”.findByis async and waits, for “this appears eventually”. Mixing them up is the most common mistake in RTL - usinggetByto assert absence throws instead of failing cleanly. - “What does a good test look like?” - render, act as the user, assert on
what is on screen. No spying on
setState, no reaching into internal state, no asserting that a mock was called when you could assert on the rendered result instead. - “How do you avoid repeating provider setup?” - a custom
renderthat wraps the component in the query client, router and theme providers, exported from a test-utils module. Individual tests then say nothing about infrastructure. - “A query fails and you cannot see why.” -
screen.debug()prints the rendered DOM, andlogRoleslists what roles are actually available. That is almost always faster than guessing at the selector.