Frontend / Testing / 03_react_testing_library.md

React Testing Library — Query Priority, Role-Based Queries

Updated 5 interview angles 7 min read source
On this page15
  1. Three query variants — getBy, queryBy, findBy — when each?
  2. Query priority — what’s the order and why?
  3. Show a representative test.
  4. screen vs container from render()?
  5. How do you query inside a specific element?
  6. How do you handle multiple matching elements?
  7. Common roles you’ll query?
  8. name option — how does it compute the accessible name?
  9. Querying form fields.
  10. Testing with React Router?
  11. Testing with React Query / TanStack Query?
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

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.

tsx
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:

  1. *ByRole — accessible to everyone, including assistive tech. The default.
  2. *ByLabelText — for form fields, since users see labels.
  3. *ByPlaceholderText — when labels aren’t present (uglier, but sometimes necessary).
  4. *ByText — non-interactive text content.
  5. *ByDisplayValue — form values.
  6. *ByAltText — images.
  7. *ByTitle — last resort for tooltips.
  8. *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.

tsx
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.

tsx
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).

How do you query inside a specific element?

within(element):

tsx
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):

tsx
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:

tsx
<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:

tsx
import { MemoryRouter } from "react-router-dom";

render(
  <MemoryRouter initialEntries={["/users/1"]}>
    <App />
  </MemoryRouter>,
);

For data routers (v6.4+), use RouterProvider with a memory router:

tsx
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:

tsx
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.

tsx
// 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 with findBy*("text", {}, { timeout: 5000 }) for genuinely slow async.
  • waitForElementToBeRemoved for the opposite — “wait until this disappears.” Useful for “wait for spinner to go away.”
  • screen.debug() prints current DOM — invaluable for debugging failing queries. Pass screen.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 by getByDisplayValue or container.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().

Further reading

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, queryBy and findBy?” - getBy throws if absent, so it asserts “this exists now”. queryBy returns null, so it is the only correct one for “this must not exist”. findBy is async and waits, for “this appears eventually”. Mixing them up is the most common mistake in RTL - using getBy to 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 render that 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, and logRoles lists what roles are actually available. That is almost always faster than guessing at the selector.