React Testing Library
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.
Install the required packages for testing React components with RTL.
| 1 | # Core testing library |
| 2 | npm install -D @testing-library/react @testing-library/jest-dom |
| 3 | npm install -D @testing-library/user-event |
| 4 | |
| 5 | # Vitest (recommended) + React testing utilities |
| 6 | npm install -D vitest @vitejs/plugin-react jsdom |
| 7 | |
| 8 | # Jest setup |
| 9 | npm install -D jest @types/jest ts-jest jest-environment-jsdom |
| 10 | npm install -D @testing-library/react @testing-library/jest-dom |
| 11 | npm install -D @testing-library/user-event |
| 12 | |
| 13 | # Optional: axe-core for accessibility tests |
| 14 | npm install -D @axe-core/playwright jest-axe |
| 1 | // src/test-setup.ts |
| 2 | import "@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 render function renders a React component into a virtual DOM. screen provides global query methods to find elements.
| 1 | import { render, screen } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { describe, it, expect } from "vitest"; |
| 4 | import Greeting from "./Greeting"; |
| 5 | |
| 6 | describe("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).
| Query | Returns | Throws | Use Case |
|---|---|---|---|
| getByRole | Element | Not found / Multiple | Accessible query — preferred |
| getByText | Element | Not found / Multiple | Text content lookup |
| getByLabelText | Element | Not found / Multiple | Form inputs with labels |
| getByPlaceholderText | Element | Not found / Multiple | Placeholder-based lookup |
| getByTestId | Element | Not found / Multiple | Fallback (use last resort) |
| findByRole | Promise | Timeout / Multiple | Async elements (appear after delay) |
| queryByText | Element | Multiple | Check element does NOT exist |
| getAllByRole | Array | Not found | Multiple matching elements |
| 1 | import { render, screen } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import LoginForm from "./LoginForm"; |
| 4 | |
| 5 | describe("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
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).
| Feature | fireEvent | userEvent |
|---|---|---|
| Event dispatch | Single event | Full sequence (focus, keydown, input, keyup, blur) |
| Keyboard input | Manual | Character-by-character with key events |
| Checkbox/Radio | Click + change event | Full click + state toggle |
| File upload | Manual | Built-in upload simulation |
| Async by default | No | Yes (returns Promise) |
| 1 | import { render, screen } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { fireEvent } from "@testing-library/react"; |
| 4 | import Counter from "./Counter"; |
| 5 | |
| 6 | describe("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
Modern React apps are highly asynchronous. RTL provides waitFor and findBy queries to handle elements that appear or change after async operations.
| 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import UserProfile from "./UserProfile"; |
| 4 | |
| 5 | describe("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 |
| 52 | import { renderHook, act } from "@testing-library/react"; |
| 53 | import useCounter from "./useCounter"; |
| 54 | |
| 55 | describe("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
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.
| 1 | import { render } from "@testing-library/react"; |
| 2 | import { axe, toHaveNoViolations } from "jest-axe"; |
| 3 | |
| 4 | expect.extend(toHaveNoViolations); |
| 5 | |
| 6 | import Navbar from "./Navbar"; |
| 7 | import Modal from "./Modal"; |
| 8 | |
| 9 | describe("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: |
| 39 | function 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
Real apps wrap components in providers (Router, Theme, Redux, React Query). Create a custom render function to wrap components with all required providers.
| 1 | import { render, RenderOptions } from "@testing-library/react"; |
| 2 | import { MemoryRouter } from "react-router-dom"; |
| 3 | import { ThemeProvider } from "../contexts/ThemeContext"; |
| 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 5 | import { Provider as ReduxProvider } from "react-redux"; |
| 6 | import { store } from "../store"; |
| 7 | import { ReactElement } from "react"; |
| 8 | |
| 9 | // Custom wrapper with all providers |
| 10 | function 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 |
| 34 | function renderWithProviders( |
| 35 | ui: ReactElement, |
| 36 | options?: Omit<RenderOptions, "wrapper"> |
| 37 | ) { |
| 38 | return render(ui, { wrapper: AllProviders, ...options }); |
| 39 | } |
| 40 | |
| 41 | // Re-export everything |
| 42 | export * from "@testing-library/react"; |
| 43 | export { renderWithProviders }; |
| 44 | |
| 45 | // Usage in tests |
| 46 | import { renderWithProviders, screen } from "../test-utils"; |
| 47 | |
| 48 | describe("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
Use renderHook from RTL to test custom hooks in isolation without rendering a component.
| 1 | import { renderHook, act, waitFor } from "@testing-library/react"; |
| 2 | import useLocalStorage from "./useLocalStorage"; |
| 3 | import useDebounce from "./useDebounce"; |
| 4 | import useMediaQuery from "./useMediaQuery"; |
| 5 | |
| 6 | describe("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 | |
| 35 | describe("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 | |
| 61 | describe("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