React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

testing-libraryqueriesuserEventrenderscreenaccessibility

React Testing Library Fundamentals

Test your components the way users interact with them -- by querying accessible elements, not implementation details.

Recipe

Quick-reference recipe card -- copy-paste ready.

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
// 1. Render the component
render(<LoginForm onSubmit={handleSubmit} />);
 
// 2. Query elements (by priority)
screen.getByRole("textbox", { name: /email/i });       // Best: accessible role
screen.getByLabelText(/password/i);                     // Good: label association
screen.getByText(/submit/i);                            // OK: visible text
screen.getByTestId("login-form");                       // Last resort: test ID
 
// 3. Interact with userEvent (not fireEvent)
const user = userEvent.setup();
await user.type(screen.getByRole("textbox", { name: /email/i }), "alice@example.com");
await user.click(screen.getByRole("button", { name: /submit/i }));
 
// 4. Assert outcomes
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
 
// 5. Async queries
const message = await screen.findByText(/success/i); // waits up to 1s
await waitFor(() => expect(handleSubmit).toHaveBeenCalled());

When to reach for this: Every time you write a component test. Testing Library is the standard for testing React components from a user's perspective.

Working Example

// src/components/login-form.tsx
"use client";
 
import { useState } from "react";
 
interface LoginFormProps {
  onSubmit: (data: { email: string; password: string }) => Promise<void>;
}
 
export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);
    try {
      await onSubmit({ email, password });
    } catch (err) {
      setError(err instanceof Error ? err.message : "Login failed");
    } finally {
      setLoading(false);
    }
  }
 
  return (
    <form onSubmit={handleSubmit} aria-label="Login">
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? "Logging in..." : "Log in"}
      </button>
    </form>
  );
}
// src/components/login-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
import { LoginForm } from "./login-form";
 
describe("LoginForm", () => {
  const user = userEvent.setup();
 
  it("submits email and password", async () => {
    const handleSubmit = vi.fn().mockResolvedValue(undefined);
    render(<LoginForm onSubmit={handleSubmit} />);
 
    await user.type(screen.getByLabelText(/email/i), "alice@example.com");
    await user.type(screen.getByLabelText(/password/i), "password123");
    await user.click(screen.getByRole("button", { name: /log in/i }));
 
    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        email: "alice@example.com",
        password: "password123",
      });
    });
  });
 
  it("shows error on failed submission", async () => {
    const handleSubmit = vi.fn().mockRejectedValue(new Error("Invalid credentials"));
    render(<LoginForm onSubmit={handleSubmit} />);
 
    await user.type(screen.getByLabelText(/email/i), "alice@example.com");
    await user.type(screen.getByLabelText(/password/i), "wrong");
    await user.click(screen.getByRole("button", { name: /log in/i }));
 
    expect(await screen.findByRole("alert")).toHaveTextContent("Invalid credentials");
  });
 
  it("disables button while loading", async () => {
    const handleSubmit = vi.fn(() => new Promise(() => {})); // never resolves
    render(<LoginForm onSubmit={handleSubmit} />);
 
    await user.type(screen.getByLabelText(/email/i), "alice@example.com");
    await user.type(screen.getByLabelText(/password/i), "password123");
    await user.click(screen.getByRole("button", { name: /log in/i }));
 
    expect(screen.getByRole("button")).toBeDisabled();
    expect(screen.getByRole("button")).toHaveTextContent("Logging in...");
  });
});

What this demonstrates:

  • Querying by accessible labels and roles
  • userEvent.setup() for realistic typing and clicking
  • Testing success, error, and loading states
  • waitFor and findBy for async assertions

Deep Dive

How It Works

  • render() mounts your component into a jsdom document body and returns utilities for cleanup (automatic with modern Testing Library)
  • screen is a convenience object that holds all queries bound to document.body -- no need to destructure from render()
  • Queries come in three variants: getBy (throws if not found), queryBy (returns null if not found), findBy (waits and retries)
  • userEvent simulates full user interaction sequences including focus, keydown, keypress, keyup, input, and change events -- fireEvent only dispatches a single event
  • waitFor retries its callback until it passes or times out (default 1000ms) -- use it for assertions that depend on async state updates

Query Priority

PriorityQueryUse When
1getByRoleElement has an implicit or explicit ARIA role
2getByLabelTextForm fields with associated labels
3getByPlaceholderTextNo label available (prefer labels)
4getByTextNon-interactive elements with visible text
5getByDisplayValueFilled-in form fields
6getByAltTextImages with alt text
7getByTitleElements with title attribute
8getByTestIdLast resort when no semantic query works

Query Variants

VariantNo Match1 MatchMultipleAsync
getBythrowsreturnsthrowsno
queryBynullreturnsthrowsno
findBythrowsreturnsthrowsyes
getAllBythrowsarrayarrayno
queryAllBy[]arrayarrayno
findAllBythrowsarrayarrayyes

TypeScript Notes

// The render result is typed -- you can access container and other utilities
const { container, rerender, unmount } = render(<MyComponent />);
 
// Custom render with wrapper for providers
function renderWithProviders(ui: React.ReactElement) {
  return render(ui, {
    wrapper: ({ children }) => (
      <ThemeProvider>{children}</ThemeProvider>
    ),
  });
}

Gotchas

  • Using fireEvent instead of userEvent -- fireEvent.change does not simulate real typing (no focus, no keystrokes). Fix: Always use userEvent.setup() and its methods (type, click, selectOptions).

  • Querying by test ID when a role query works -- Test IDs couple tests to implementation. Fix: Check the ARIA roles list -- most elements have implicit roles.

  • Wrapping in act() manually -- Modern Testing Library wraps render, userEvent, and waitFor in act() for you. Fix: Remove manual act() calls unless you are triggering state updates outside of Testing Library utilities.

  • Not awaiting userEvent calls -- userEvent methods are async in v14+. Fix: Always await every user.type(), user.click(), etc.

  • getBy for elements that may not exist -- getBy throws if the element is missing, which crashes the test. Fix: Use queryBy when asserting something is NOT in the document: expect(screen.queryByText("Error")).not.toBeInTheDocument().

Alternatives

AlternativeUse WhenDon't Use When
EnzymeLegacy React class component tests (not recommended for new code)You use React 18+ or function components
Playwright Component TestingYou need real browser rendering for visual or layout testsYou want fast unit tests in Node
Storybook interaction testingYou already have stories and want to test interactions within themYou need a full test suite with mocking and coverage

FAQs

Why should I use userEvent instead of fireEvent?
  • fireEvent.change dispatches a single event and skips focus, keydown, keypress, and keyup.
  • userEvent simulates the full interaction sequence a real user would trigger.
  • Always use userEvent.setup() and await its methods.
What is the query priority order in Testing Library?
  1. getByRole -- accessible ARIA role (best)
  2. getByLabelText -- form field labels
  3. getByPlaceholderText -- input placeholders
  4. getByText -- visible text content
  5. getByTestId -- last resort
What is the difference between getBy, queryBy, and findBy?
  • getBy throws if the element is not found (synchronous).
  • queryBy returns null if not found -- use for asserting absence.
  • findBy waits and retries until found or timeout (async).
How do I assert that an element is NOT in the document?
expect(screen.queryByText("Error")).not.toBeInTheDocument();

Use queryBy (not getBy) because getBy throws when the element is missing.

Gotcha: Why do I get warnings about act() when using Testing Library?

Modern Testing Library wraps render, userEvent, and waitFor in act() automatically. Remove manual act() calls unless you trigger state updates outside Testing Library utilities.

How do I wait for async state changes after an interaction?

Use waitFor or findBy:

await waitFor(() => {
  expect(handleSubmit).toHaveBeenCalled();
});
// or
const message = await screen.findByText(/success/i);
Gotcha: What happens if I forget to await a userEvent call?

In userEvent v14+, all methods are async. Without await, the test may pass before the interaction completes, leading to false positives or flaky failures.

How do I create a custom render function with providers?
function renderWithProviders(ui: React.ReactElement) {
  return render(ui, {
    wrapper: ({ children }) => (
      <ThemeProvider>{children}</ThemeProvider>
    ),
  });
}
What does screen give me that destructuring from render() does not?

screen is bound to document.body and provides all queries without needing to destructure. It keeps test code cleaner and more consistent.

How do I type the render result in TypeScript?
const { container, rerender, unmount } = render(<MyComponent />);

The return type is automatically inferred. container is HTMLElement, rerender accepts ReactElement, and unmount returns void.

What is the default timeout for findBy and waitFor?

Both default to 1000ms. You can customize the timeout:

await screen.findByText(/data/i, {}, { timeout: 3000 });
When should I use getByRole with a name option?

When multiple elements share the same role. The name option matches the accessible name (label text, aria-label, or button text):

screen.getByRole("button", { name: /submit/i });