frontend / testing / 03_react_testing_library.md

React Testing Library — Query Priority, Role-Based Queries

6 min read source

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:

  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.

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-labelledbyaria-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 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().

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

Further reading