|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/react
$cat docs/react-testing-library.md
updated Recently·40 min read·published

React Testing Library

TestingReactIntermediate
Introduction

React Testing Library (RTL) is a lightweight testing utility that encourages testing components from the user's perspective. Instead of testing implementation details (state, props, internal methods), RTL focuses on what the user sees and interacts with.

Built on top of @testing-library/dom, RTL works with Jest or Vitest and provides utilities for querying rendered output, firing events, and awaiting async changes.

Setup

Install the required packages for testing React components with RTL.

terminal
Bash
1# Core testing library
2npm install -D @testing-library/react @testing-library/jest-dom
3npm install -D @testing-library/user-event
4
5# Vitest (recommended) + React testing utilities
6npm install -D vitest @vitejs/plugin-react jsdom
7
8# Jest setup
9npm install -D jest @types/jest ts-jest jest-environment-jsdom
10npm install -D @testing-library/react @testing-library/jest-dom
11npm install -D @testing-library/user-event
12
13# Optional: axe-core for accessibility tests
14npm install -D @axe-core/playwright jest-axe
test-setup.ts
TypeScript
1// src/test-setup.ts
2import "@testing-library/jest-dom";
3
4// Vitest globals setup (vitest.config.ts)
5// test: { globals: true, environment: "jsdom", setupFiles: ["./src/test-setup.ts"] }
6
7// For Jest, add to jest.config.js:
8// setupFilesAfterSetup: ["./src/test-setup.ts"]

info

The @testing-library/jest-dom import adds custom matchers like toBeInTheDocument(), toHaveTextContent(), and toHaveAttribute() to expect. These make tests more expressive and readable.
Render & Screen Queries

The render function renders a React component into a virtual DOM. screen provides global query methods to find elements.

Greeting.test.tsx
TSX
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { describe, it, expect } from "vitest";
4import Greeting from "./Greeting";
5
6describe("Greeting", () => {
7 it("renders the greeting text", () => {
8 render(<Greeting name="Alice" />);
9
10 // screen queries — no need to destructure render result
11 expect(
12 screen.getByText(/hello, alice/i)
13 ).toBeInTheDocument();
14 });
15
16 it("renders with a default name", () => {
17 render(<Greeting />);
18
19 expect(screen.getByRole("heading")).toHaveTextContent(
20 "Hello, Guest"
21 );
22 });
23});

Query Types

RTL provides three categories of queries: getBy (synchronous, throws if not found), findBy (async, retries until found or timeout), and queryBy (synchronous, returns null if not found).

QueryReturnsThrowsUse Case
getByRoleElementNot found / MultipleAccessible query — preferred
getByTextElementNot found / MultipleText content lookup
getByLabelTextElementNot found / MultipleForm inputs with labels
getByPlaceholderTextElementNot found / MultiplePlaceholder-based lookup
getByTestIdElementNot found / MultipleFallback (use last resort)
findByRolePromiseTimeout / MultipleAsync elements (appear after delay)
queryByTextElementMultipleCheck element does NOT exist
getAllByRoleArrayNot foundMultiple matching elements
LoginForm.test.tsx
TSX
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import LoginForm from "./LoginForm";
4
5describe("LoginForm", () => {
6 it("shows validation errors", () => {
7 render(<LoginForm />);
8
9 // Query by role (preferred — accessible)
10 const submitBtn = screen.getByRole("button", {
11 name: /submit/i,
12 });
13
14 // Query by label text (form inputs)
15 const emailInput = screen.getByLabelText(/email/i);
16 const passwordInput = screen.getByLabelText(/password/i);
17
18 // Query by placeholder
19 // const emailInput = screen.getByPlaceholderText("Enter email");
20
21 expect(submitBtn).toBeInTheDocument();
22 expect(emailInput).toBeInTheDocument();
23
24 // queryBy returns null when element not found (does not throw)
25 expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
26
27 // Click submit without filling fields
28 userEvent.click(submitBtn);
29
30 // Now error should appear
31 expect(screen.getByText(/email is required/i)).toBeInTheDocument();
32 });
33
34 it("shows success message after valid submission", async () => {
35 render(<LoginForm />);
36
37 // Type into inputs
38 await userEvent.type(
39 screen.getByLabelText(/email/i),
40 "user@example.com"
41 );
42 await userEvent.type(
43 screen.getByLabelText(/password/i),
44 "secure123"
45 );
46
47 // Submit the form
48 await userEvent.click(
49 screen.getByRole("button", { name: /submit/i })
50 );
51
52 // Wait for success message (async)
53 expect(
54 await screen.findByText(/login successful/i)
55 ).toBeInTheDocument();
56 });
57});

best practice

Query priority: getByRole > getByLabelText > getByText > getByTestId. Prefer queries that mirror how users find elements — by role, label, or visible text. getByTestId should be a last resort for elements with no accessible identity.
userEvent vs fireEvent

RTL provides two approaches for simulating user interactions: fireEvent (low-level, dispatches a single DOM event) and userEvent (high-level, simulates full user interaction sequences).

FeaturefireEventuserEvent
Event dispatchSingle eventFull sequence (focus, keydown, input, keyup, blur)
Keyboard inputManualCharacter-by-character with key events
Checkbox/RadioClick + change eventFull click + state toggle
File uploadManualBuilt-in upload simulation
Async by defaultNoYes (returns Promise)
Counter.test.tsx
TSX
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { fireEvent } from "@testing-library/react";
4import Counter from "./Counter";
5
6describe("Counter interactions", () => {
7 it("fireEvent — low level (simpler but less realistic)", () => {
8 render(<Counter />);
9
10 const button = screen.getByRole("button", { name: /increment/i });
11 fireEvent.click(button);
12
13 expect(screen.getByText("1")).toBeInTheDocument();
14 });
15
16 it("userEvent — high level (recommended)", async () => {
17 const user = userEvent.setup();
18 render(<Counter />);
19
20 const button = screen.getByRole("button", { name: /increment/i });
21
22 await user.click(button);
23 await user.click(button);
24 await user.click(button);
25
26 expect(screen.getByText("3")).toBeInTheDocument();
27 });
28
29 it("userEvent keyboard interactions", async () => {
30 const user = userEvent.setup();
31 render(<Counter />);
32
33 const input = screen.getByRole("spinbutton");
34 await user.clear(input);
35 await user.type(input, "42");
36
37 expect(input).toHaveValue(42);
38 });
39
40 it("userEvent tab navigation", async () => {
41 const user = userEvent.setup();
42 render(
43 <form>
44 <input aria-label="First" />
45 <input aria-label="Second" />
46 <input aria-label="Third" />
47 </form>
48 );
49
50 await user.tab();
51 expect(screen.getByLabelText("First")).toHaveFocus();
52
53 await user.tab();
54 expect(screen.getByLabelText("Second")).toHaveFocus();
55
56 await user.tab({ shift: true });
57 expect(screen.getByLabelText("First")).toHaveFocus();
58 });
59
60 it("userEvent type with keyboard events", async () => {
61 const user = userEvent.setup();
62 render(<input aria-label="name" />);
63
64 await user.keyboard("{Shift>}Hello{/Shift}");
65
66 // Dispatches: keydown(Shift) -> keydown(H) -> keyup(H) -> keyup(Shift)
67 // with all intermediate events
68 expect(screen.getByLabelText("name")).toHaveValue("HELLO");
69 });
70});
🔥

pro tip

Always use userEvent.setup() to create a user instance. This is the v14+ API that properly simulates the full interaction sequence. Never use the default import userEvent.click() without setup in modern code — it is deprecated.
Testing Async Behavior

Modern React apps are highly asynchronous. RTL provides waitFor and findBy queries to handle elements that appear or change after async operations.

UserProfile.test.tsx
TSX
1import { render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import UserProfile from "./UserProfile";
4
5describe("UserProfile async loading", () => {
6 it("shows loading state initially", () => {
7 render(<UserProfile userId={1} />);
8
9 expect(screen.getByText(/loading/i)).toBeInTheDocument();
10 });
11
12 it("renders user data after fetch", async () => {
13 render(<UserProfile userId={1} />);
14
15 // findBy queries return a promise that resolves when element appears
16 const name = await screen.findByText(/john doe/i);
17 expect(name).toBeInTheDocument();
18
19 // Alternative: waitFor with synchronous assertions
20 await waitFor(() => {
21 expect(screen.getByText(/john doe/i)).toBeInTheDocument();
22 });
23
24 expect(screen.getByText(/john@example.com/i)).toBeInTheDocument();
25 });
26
27 it("shows error state on failure", async () => {
28 render(<UserProfile userId={999} />);
29
30 await waitFor(() => {
31 expect(
32 screen.getByText(/failed to load/i)
33 ).toBeInTheDocument();
34 });
35 });
36
37 it("updates when userId changes", async () => {
38 const { rerender } = render(<UserProfile userId={1} />);
39
40 await screen.findByText(/john doe/i);
41
42 // Re-render with different userId
43 rerender(<UserProfile userId={2} />);
44
45 await waitFor(() => {
46 expect(screen.getByText(/jane smith/i)).toBeInTheDocument();
47 });
48 });
49});
50
51// Testing custom hooks
52import { renderHook, act } from "@testing-library/react";
53import useCounter from "./useCounter";
54
55describe("useCounter hook", () => {
56 it("should increment counter", () => {
57 const { result } = renderHook(() => useCounter());
58
59 act(() => {
60 result.current.increment();
61 });
62
63 expect(result.current.count).toBe(1);
64 });
65
66 it("should start with custom initial value", () => {
67 const { result } = renderHook(() => useCounter(10));
68
69 expect(result.current.count).toBe(10);
70 });
71});

warning

Always use findBy or waitFor when testing async behavior. Using getBy after an async operation leads to flaky tests because the element may not have rendered yet. Default timeout is 1000ms; pass { timeout: 5000 } for slower operations.
Testing Accessibility

Use jest-axe to automatically detect accessibility violations in your rendered components. This catches issues like missing ARIA attributes, insufficient color contrast, and incorrect heading hierarchy.

accessibility.test.tsx
TSX
1import { render } from "@testing-library/react";
2import { axe, toHaveNoViolations } from "jest-axe";
3
4expect.extend(toHaveNoViolations);
5
6import Navbar from "./Navbar";
7import Modal from "./Modal";
8
9describe("Accessibility tests", () => {
10 it("Navbar should have no accessibility violations", async () => {
11 const { container } = render(<Navbar />);
12
13 const results = await axe(container);
14 expect(results).toHaveNoViolations();
15 });
16
17 it("Modal should have no accessibility violations", async () => {
18 const { container } = render(<Modal isOpen={true} />);
19
20 const results = await axe(container);
21 expect(results).toHaveNoViolations();
22 });
23
24 it("Form should have proper label associations", async () => {
25 const { container } = render(<LoginForm />);
26
27 const results = await axe(container);
28
29 // Check for specific violations
30 const violations = results.violations.filter(
31 (v) => v.id === "label"
32 );
33 expect(violations).toHaveLength(0);
34 });
35});
36
37// Run axe on every component by default
38// Custom test wrapper:
39function testAccessibility(name: string, Component: React.FC) {
40 it(`${name} has no a11y violations`, async () => {
41 const { container } = render(<Component />);
42 const results = await axe(container);
43 expect(results).toHaveNoViolations();
44 });
45}

best practice

Integrate axe checks into every component test. Use a helper function to automatically run accessibility tests alongside behavior tests. This ensures a11y regressions are caught in the same test run as functional regressions. Aim for 0 violations across the test suite.
Custom Render with Providers

Real apps wrap components in providers (Router, Theme, Redux, React Query). Create a custom render function to wrap components with all required providers.

test-utils.tsx
TSX
1import { render, RenderOptions } from "@testing-library/react";
2import { MemoryRouter } from "react-router-dom";
3import { ThemeProvider } from "../contexts/ThemeContext";
4import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5import { Provider as ReduxProvider } from "react-redux";
6import { store } from "../store";
7import { ReactElement } from "react";
8
9// Custom wrapper with all providers
10function AllProviders({ children }: { children: React.ReactNode }) {
11 const queryClient = new QueryClient({
12 defaultOptions: {
13 queries: {
14 retry: false, // disable retries in tests
15 gcTime: 0, // disable garbage collection
16 },
17 },
18 });
19
20 return (
21 <ReduxProvider store={store}>
22 <QueryClientProvider client={queryClient}>
23 <MemoryRouter initialEntries={["/"]}>
24 <ThemeProvider>
25 {children}
26 </ThemeProvider>
27 </MemoryRouter>
28 </QueryClientProvider>
29 </ReduxProvider>
30 );
31}
32
33// Custom render function
34function renderWithProviders(
35 ui: ReactElement,
36 options?: Omit<RenderOptions, "wrapper">
37) {
38 return render(ui, { wrapper: AllProviders, ...options });
39}
40
41// Re-export everything
42export * from "@testing-library/react";
43export { renderWithProviders };
44
45// Usage in tests
46import { renderWithProviders, screen } from "../test-utils";
47
48describe("Dashboard", () => {
49 it("renders with all providers", () => {
50 renderWithProviders(<Dashboard />);
51
52 expect(
53 screen.getByRole("heading", { name: /dashboard/i })
54 ).toBeInTheDocument();
55 });
56
57 it("can access Redux state", () => {
58 renderWithProviders(<Dashboard />);
59
60 expect(
61 screen.getByText(/welcome, user/i)
62 ).toBeInTheDocument();
63 });
64});

info

Export the custom renderWithProviders from a shared test-utils.ts file. Import from it instead of @testing-library/react in test files. This keeps provider config centralized and consistent across all tests.
Testing Hooks

Use renderHook from RTL to test custom hooks in isolation without rendering a component.

hooks.test.tsx
TSX
1import { renderHook, act, waitFor } from "@testing-library/react";
2import useLocalStorage from "./useLocalStorage";
3import useDebounce from "./useDebounce";
4import useMediaQuery from "./useMediaQuery";
5
6describe("useLocalStorage", () => {
7 beforeEach(() => {
8 localStorage.clear();
9 });
10
11 it("returns the initial value when no stored value exists", () => {
12 const { result } = renderHook(() =>
13 useLocalStorage("key", "default")
14 );
15
16 expect(result.current[0]).toBe("default");
17 });
18
19 it("stores and retrieves a value", () => {
20 const { result } = renderHook(() =>
21 useLocalStorage("key", "default")
22 );
23
24 act(() => {
25 result.current[1]("stored value");
26 });
27
28 expect(result.current[0]).toBe("stored value");
29 expect(localStorage.getItem("key")).toBe(
30 JSON.stringify("stored value")
31 );
32 });
33});
34
35describe("useDebounce", () => {
36 it("returns initial value immediately", () => {
37 const { result } = renderHook(() => useDebounce("hello", 500));
38
39 expect(result.current).toBe("hello");
40 });
41
42 it("updates value after delay", async () => {
43 const { result, rerender } = renderHook(
44 ({ value }) => useDebounce(value, 100),
45 { initialProps: { value: "hello" } }
46 );
47
48 rerender({ value: "world" });
49
50 // Value should still be "hello" before delay
51 expect(result.current).toBe("hello");
52
53 // Wait for debounce to resolve
54 await waitFor(
55 () => expect(result.current).toBe("world"),
56 { timeout: 300 }
57 );
58 });
59});
60
61describe("useMediaQuery", () => {
62 it("returns false when media query does not match", () => {
63 window.matchMedia = vi.fn().mockImplementation((query: string) => ({
64 matches: false,
65 media: query,
66 addEventListener: vi.fn(),
67 removeEventListener: vi.fn(),
68 }));
69
70 const { result } = renderHook(() =>
71 useMediaQuery("(min-width: 768px)")
72 );
73
74 expect(result.current).toBe(false);
75 });
76
77 it("responds to query changes", () => {
78 const listeners: Record<string, Function> = {};
79 const addEventListener = vi.fn((event, handler) => {
80 listeners[event] = handler;
81 });
82
83 window.matchMedia = vi.fn().mockImplementation((query: string) => ({
84 matches: false,
85 media: query,
86 addEventListener,
87 removeEventListener: vi.fn(),
88 }));
89
90 const { result } = renderHook(() =>
91 useMediaQuery("(min-width: 768px)")
92 );
93
94 expect(result.current).toBe(false);
95
96 // Simulate the media query matching
97 act(() => {
98 listeners.change({ matches: true });
99 });
100
101 expect(result.current).toBe(true);
102 });
103});

best practice

Always wrap state-updating hook calls in act(). This ensures all state updates and effects are flushed before assertions. renderHook automatically wraps the initial render in act, but subsequent updates via result.current setters or callbacks need manual act wrapping.
$Blueprint — Engineering Documentation·Section ID: RTL-01·Revision: 1.0