Skip to content

React Testing Library Reference: Queries, userEvent, Async, MSW & Accessibility

React Testing Library tests components the way users interact with them — through the DOM, not through component internals. The guiding principle: test what the user sees and does, not how your component is structured. This means no testing state directly, no testing which functions were called inside a component — only what ends up rendered and what happens when users interact with it. The biggest gotcha: async state updates require await waitFor() or await findBy*().

1. Queries — Finding Elements

getBy*, queryBy*, findBy*, priority order, and when to use each
// npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";

// Query priority (use in this order — higher = more accessible):
// 1. getByRole      — by ARIA role (button, heading, textbox, listitem...)
// 2. getByLabelText — form field by its label
// 3. getByPlaceholderText — input by placeholder
// 4. getByText      — visible text content
// 5. getByDisplayValue — current form value
// 6. getByAltText   — img alt attribute
// 7. getByTitle     — title attribute
// 8. getByTestId    — data-testid (last resort)

render(<LoginForm />);

// getBy* — throws if not found (use for elements that must exist):
const submit   = screen.getByRole("button", { name: /sign in/i });
const email    = screen.getByLabelText(/email/i);
const heading  = screen.getByRole("heading", { name: "Dashboard", level: 1 });
const listItem = screen.getByRole("listitem", { name: /alice/i });

// queryBy* — returns null if not found (use to assert absence):
const error = screen.queryByRole("alert");
expect(error).not.toBeInTheDocument();

// findBy* — async, waits for element to appear (use for async rendering):
const result = await screen.findByText(/success/i);   // waits up to 1s by default

// *All* variants — multiple elements:
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);

2. User Events & Interactions

userEvent vs fireEvent, typing, clicking, selecting, and keyboard navigation
import userEvent from "@testing-library/user-event";

// userEvent simulates real browser events (prefer over fireEvent):
// - fires pointer, mouse, keyboard events in sequence
// - handles focus, blur, change events correctly

const user = userEvent.setup();   // v14+: setup() before rendering

it("submits the login form", async () => {
  render(<LoginForm onSubmit={mockSubmit} />);

  const user = userEvent.setup();
  await user.type(screen.getByLabelText(/email/i), "alice@example.com");
  await user.type(screen.getByLabelText(/password/i), "secret123");
  await user.click(screen.getByRole("button", { name: /sign in/i }));

  expect(mockSubmit).toHaveBeenCalledWith({
    email: "alice@example.com",
    password: "secret123",
  });
});

// Common userEvent methods:
await user.type(input, "hello world");      // types character by character
await user.clear(input);                    // clears input value
await user.click(element);                  // click
await user.dblClick(element);               // double click
await user.hover(element);                  // mouse over
await user.unhover(element);                // mouse out
await user.tab();                           // press Tab key
await user.keyboard("{Enter}");             // press a key
await user.selectOptions(select, "admin");  // <select> option
await user.upload(input, file);             // file input

// fireEvent — low level, for edge cases userEvent can't handle:
import { fireEvent } from "@testing-library/react";
fireEvent.change(input, { target: { value: "new value" } });
fireEvent.submit(form);

3. Async Testing & waitFor

waitFor(), findBy queries, act() wrapper, and testing loading/error states
import { render, screen, waitFor } from "@testing-library/react";

// waitFor — retry assertion until it passes (or times out):
it("shows success message after submit", async () => {
  render(<ContactForm />);
  await userEvent.setup().click(screen.getByRole("button", { name: /submit/i }));

  // waitFor retries until assertion passes or 1s timeout:
  await waitFor(() => {
    expect(screen.getByText(/sent successfully/i)).toBeInTheDocument();
  });
});

// findBy* — shorthand for waitFor + getBy:
await screen.findByText(/sent successfully/i);   // equivalent to above

// Test loading state:
it("shows loading spinner then data", async () => {
  render(<UserList />);
  expect(screen.getByRole("progressbar")).toBeInTheDocument();  // loading
  await screen.findByText("Alice");                              // data loaded
  expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});

// Test error state:
it("shows error message on fetch failure", async () => {
  server.use(http.get("/api/users", () => HttpResponse.error()));  // MSW
  render(<UserList />);
  await screen.findByRole("alert");
  expect(screen.getByRole("alert")).toHaveTextContent(/failed to load/i);
});

// Avoid act() warning — always await user events and async assertions:
// WRONG (causes act() warning):
userEvent.click(button);   // not awaited
expect(result).toBeInTheDocument();

// CORRECT:
await user.click(button);
await screen.findByText("result");

4. Custom Render, Providers & MSW

Custom render with context providers, testing with React Router, and MSW API mocking
// Custom render — wrap with providers:
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";

function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } }  // no retries in tests
  });
  return render(
    <QueryClientProvider client={queryClient}>
      <MemoryRouter>
        {ui}
      </MemoryRouter>
    </QueryClientProvider>,
    options
  );
}

// Use instead of render():
renderWithProviders(<UserProfile userId="1" />);

// MSW (Mock Service Worker) — intercept real HTTP in tests:
// npm install -D msw
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("/api/users", () =>
    HttpResponse.json([{ id: 1, name: "Alice" }])
  ),
  http.post("/api/users", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 2, ...body }, { status: 201 });
  })
);

beforeAll(()  => server.listen());
afterEach(()  => server.resetHandlers());   // reset per-test overrides
afterAll(()   => server.close());

// Override per test:
server.use(http.get("/api/users", () => HttpResponse.error()));

5. Accessibility Queries & Best Practices

Role-based queries, accessible form fields, common mistakes, and debug tools
// Accessible queries enforce good markup:
// getByRole("button") only matches elements with role=button (or <button>)
// If your markup lacks proper roles, RTL queries help you find it

// Common ARIA roles:
// button, link, heading, textbox, checkbox, radio, combobox, listbox
// dialog, alertdialog, alert, status, progressbar, tab, tabpanel
// list, listitem, table, row, cell, columnheader

// Role with name (accessible name from label, aria-label, or text content):
getByRole("button", { name: "Delete" });              // matches: <button>Delete</button>
getByRole("button", { name: /delete/i });             // case-insensitive
getByRole("heading", { level: 2 });                   // h2 specifically
getByRole("checkbox", { checked: true });             // checked state
getByRole("option", { selected: true });              // selected option

// Testing forms the right way:
// Use getByLabelText — ensures the label IS connected to the input
<label htmlFor="email">Email</label>       // RTL finds input by this label text
<input id="email" type="email" />

// Debug: print the DOM when a query fails:
screen.debug();                    // prints entire rendered DOM
screen.debug(screen.getByRole("form"));  // prints specific element

// within() — scope queries to a container:
import { within } from "@testing-library/react";
const dialog = screen.getByRole("dialog");
const deleteBtn = within(dialog).getByRole("button", { name: /delete/i });
// Avoids finding the wrong button when multiple exist on page

// Common mistakes:
// ❌ getByTestId — tests implementation, not user-visible behavior
// ❌ .container.querySelector(".my-class") — internal selector
// ✅ getByRole / getByLabelText — what the user sees and interacts with

Track Node.js and testing toolchain releases at ReleaseRun. Related: Jest Reference | Vitest Reference | Playwright Reference | React EOL Tracker

🔍 Free tool: npm Package Health Checker — check @testing-library packages for known CVEs, EOL status, and whether they’re actively maintained.

Founded

2023 in London, UK

Contact

hello@releaserun.com