Testing
Testing React applications ensures code correctness, prevents regressions, and enables confident refactoring. The React testing ecosystem has matured significantly — React Testing Library (RTL) is the standard for component testing, and Vitest has emerged as the fast, modern alternative to Jest.
The core philosophy: test behavior, not implementation. Write tests that resemble how users interact with your application. This makes tests resilient to refactoring — you can change internal implementation without breaking tests.
| 1 | # Setup with Vitest (recommended for new projects) |
| 2 | npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom |
| 3 | |
| 4 | # Setup with Jest (still widely used) |
| 5 | npm install -D jest @testing-library/react @testing-library/jest-dom |
| 6 | npm install -D @types/jest ts-jest jest-environment-jsdom |
| 7 | |
| 8 | # For component rendering |
| 9 | npm install -D @testing-library/user-event |
| 10 | |
| 11 | # For API mocking |
| 12 | npm install -D msw |
React Testing Library follows the principle: "The more your tests resemble the way your software is used, the more confidence they can give you." Test what the user sees and does, not internal component state.
| 1 | // ❌ Bad: testing implementation details |
| 2 | function Counter() { |
| 3 | const [count, setCount] = useState(0); |
| 4 | return <button onClick={() => setCount(c => c + 1)}>{count}</button>; |
| 5 | } |
| 6 | |
| 7 | // Bad test — knows about internal state |
| 8 | test("increments count state", () => { |
| 9 | render(<Counter />); |
| 10 | // Queries internal state (not user-visible) |
| 11 | expect(screen.getByText("0")).toBeInTheDocument(); |
| 12 | fireEvent.click(screen.getByRole("button")); |
| 13 | // Tests state directly (not behavior) |
| 14 | expect(screen.getByText("1")).toBeInTheDocument(); |
| 15 | }); |
| 16 | |
| 17 | // ✅ Good: testing user behavior |
| 18 | test("shows incremented count after clicking button", async () => { |
| 19 | const user = userEvent.setup(); |
| 20 | render(<Counter />); |
| 21 | |
| 22 | // Find the button like a user would |
| 23 | const button = screen.getByRole("button", { name: /count: 0/i }); |
| 24 | |
| 25 | // Click like a user would |
| 26 | await user.click(button); |
| 27 | |
| 28 | // Assert the visible result |
| 29 | expect(screen.getByRole("button")).toHaveTextContent("1"); |
| 30 | }); |
| 31 | |
| 32 | // ✅ Good: testing accessibility |
| 33 | test("button has accessible name", () => { |
| 34 | render(<Counter />); |
| 35 | const button = screen.getByRole("button"); |
| 36 | expect(button).toHaveAccessibleName(/count/i); |
| 37 | }); |
best practice
Component tests verify that components render correctly and respond to user interactions. Here are the essential patterns:
| 1 | import { render, screen, within } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { describe, it, expect, vi } from "vitest"; |
| 4 | |
| 5 | // Test: renders correctly |
| 6 | describe("UserCard", () => { |
| 7 | it("displays user name and email", () => { |
| 8 | render(<UserCard user={{ name: "Jane Doe", email: "jane@example.com" }} />); |
| 9 | |
| 10 | expect(screen.getByText("Jane Doe")).toBeInTheDocument(); |
| 11 | expect(screen.getByText("jane@example.com")).toBeInTheDocument(); |
| 12 | }); |
| 13 | |
| 14 | it("hides email when showEmail is false", () => { |
| 15 | render( |
| 16 | <UserCard |
| 17 | user={{ name: "Jane Doe", email: "jane@example.com" }} |
| 18 | showEmail={false} |
| 19 | /> |
| 20 | ); |
| 21 | |
| 22 | expect(screen.getByText("Jane Doe")).toBeInTheDocument(); |
| 23 | expect(screen.queryByText("jane@example.com")).not.toBeInTheDocument(); |
| 24 | }); |
| 25 | |
| 26 | // Test user interaction |
| 27 | it("calls onDelete when delete button is clicked", async () => { |
| 28 | const user = userEvent.setup(); |
| 29 | const onDelete = vi.fn(); |
| 30 | render( |
| 31 | <UserCard |
| 32 | user={{ id: 1, name: "Jane Doe" }} |
| 33 | onDelete={onDelete} |
| 34 | /> |
| 35 | ); |
| 36 | |
| 37 | await user.click(screen.getByRole("button", { name: /delete/i })); |
| 38 | expect(onDelete).toHaveBeenCalledWith(1); |
| 39 | }); |
| 40 | |
| 41 | // Test async behavior |
| 42 | it("shows loading state then renders data", async () => { |
| 43 | render(<UserProfile userId={1} />); |
| 44 | |
| 45 | expect(screen.getByText(/loading/i)).toBeInTheDocument(); |
| 46 | |
| 47 | await screen.findByText("Jane Doe"); // waits for element to appear |
| 48 | expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); |
| 49 | }); |
| 50 | |
| 51 | // Test form submission |
| 52 | it("submits form with correct data", async () => { |
| 53 | const user = userEvent.setup(); |
| 54 | const onSubmit = vi.fn(); |
| 55 | render(<ContactForm onSubmit={onSubmit} />); |
| 56 | |
| 57 | await user.type(screen.getByLabelText(/name/i), "John Smith"); |
| 58 | await user.type(screen.getByLabelText(/email/i), "john@example.com"); |
| 59 | await user.click(screen.getByRole("button", { name: /submit/i })); |
| 60 | |
| 61 | expect(onSubmit).toHaveBeenCalledWith({ |
| 62 | name: "John Smith", |
| 63 | email: "john@example.com", |
| 64 | }); |
| 65 | }); |
| 66 | }); |
React Testing Library provides many query methods. Follow this priority order — use the first one that works:
| 1 | // Query Priority (highest to lowest) |
| 2 | // 1. getByRole — accessible role (best for accessibility) |
| 3 | screen.getByRole("button", { name: /submit/i }); |
| 4 | screen.getByRole("textbox", { name: /email/i }); |
| 5 | screen.getByRole("heading", { name: /profile/i }); |
| 6 | |
| 7 | // 2. getByLabelText — form elements with labels |
| 8 | screen.getByLabelText(/email address/i); |
| 9 | |
| 10 | // 3. getByPlaceholderText — inputs with placeholders |
| 11 | screen.getByPlaceholderText(/search/i); |
| 12 | |
| 13 | // 4. getByText — visible text content |
| 14 | screen.getByText(/welcome back/i); |
| 15 | |
| 16 | // 5. getByDisplayValue — current form input values |
| 17 | screen.getByDisplayValue("hello@example.com"); |
| 18 | |
| 19 | // 6. getByAltText — images |
| 20 | screen.getByAltText(/user avatar/i); |
| 21 | |
| 22 | // 7. getByTitle — elements with title attribute |
| 23 | screen.getByTitle(/close dialog/i); |
| 24 | |
| 25 | // 8. getByTestId — last resort (when no accessible query works) |
| 26 | screen.getByTestId("user-card-123"); |
| 27 | |
| 28 | // Multiple elements |
| 29 | screen.getAllByRole("listitem"); // returns array |
| 30 | screen.queryAllByRole("listitem"); // returns [] instead of throwing |
| 31 | |
| 32 | // findBy (async — waits for element) |
| 33 | await screen.findByText("Loaded!"); |
| 34 | |
| 35 | // within — scope queries to a container |
| 36 | const card = screen.getByTestId("user-card"); |
| 37 | const name = within(card).getByText("Jane Doe"); |
| 38 | const email = within(card).getByText(/@example.com/); |
Custom hooks are tested by rendering them inside a test component. Use renderHook from Testing Library for a cleaner API.
| 1 | import { renderHook, act } from "@testing-library/react"; |
| 2 | |
| 3 | // Test useLocalStorage hook |
| 4 | describe("useLocalStorage", () => { |
| 5 | beforeEach(() => { |
| 6 | localStorage.clear(); |
| 7 | }); |
| 8 | |
| 9 | it("returns initial value when no stored value exists", () => { |
| 10 | const { result } = renderHook(() => useLocalStorage("key", "default")); |
| 11 | expect(result.current[0]).toBe("default"); |
| 12 | }); |
| 13 | |
| 14 | it("returns stored value when it exists", () => { |
| 15 | localStorage.setItem("key", JSON.stringify("stored")); |
| 16 | const { result } = renderHook(() => useLocalStorage("key", "default")); |
| 17 | expect(result.current[0]).toBe("stored"); |
| 18 | }); |
| 19 | |
| 20 | it("updates localStorage when value changes", () => { |
| 21 | const { result } = renderHook(() => useLocalStorage("key", "default")); |
| 22 | |
| 23 | act(() => { |
| 24 | result.current[1]("new value"); |
| 25 | }); |
| 26 | |
| 27 | expect(result.current[0]).toBe("new value"); |
| 28 | expect(JSON.parse(localStorage.getItem("key"))).toBe("new value"); |
| 29 | }); |
| 30 | }); |
| 31 | |
| 32 | // Test useDebounce hook |
| 33 | describe("useDebounce", () => { |
| 34 | beforeEach(() => { |
| 35 | vi.useFakeTimers(); |
| 36 | }); |
| 37 | |
| 38 | afterEach(() => { |
| 39 | vi.useRealTimers(); |
| 40 | }); |
| 41 | |
| 42 | it("debounces value changes", () => { |
| 43 | const { result, rerender } = renderHook( |
| 44 | ({ value, delay }) => useDebounce(value, delay), |
| 45 | { initialProps: { value: "initial", delay: 500 } } |
| 46 | ); |
| 47 | |
| 48 | // Initial value |
| 49 | expect(result.current).toBe("initial"); |
| 50 | |
| 51 | // Change value |
| 52 | rerender({ value: "updated", delay: 500 }); |
| 53 | expect(result.current).toBe("initial"); // not yet updated |
| 54 | |
| 55 | // Advance time |
| 56 | act(() => vi.advanceTimersByTime(500)); |
| 57 | expect(result.current).toBe("updated"); // now updated |
| 58 | }); |
| 59 | }); |
| 60 | |
| 61 | // Test useFetch hook with MSW |
| 62 | import { http, HttpResponse } from "msw"; |
| 63 | import { setupServer } from "msw/node"; |
| 64 | |
| 65 | const server = setupServer( |
| 66 | http.get("/api/users", () => { |
| 67 | return HttpResponse.json([{ id: 1, name: "Jane" }]); |
| 68 | }) |
| 69 | ); |
| 70 | |
| 71 | beforeAll(() => server.listen()); |
| 72 | afterEach(() => server.resetHandlers()); |
| 73 | afterAll(() => server.close()); |
| 74 | |
| 75 | describe("useFetch", () => { |
| 76 | it("fetches and returns data", async () => { |
| 77 | const { result } = renderHook(() => useFetch("/api/users")); |
| 78 | |
| 79 | expect(result.current.loading).toBe(true); |
| 80 | |
| 81 | await waitFor(() => { |
| 82 | expect(result.current.loading).toBe(false); |
| 83 | }); |
| 84 | |
| 85 | expect(result.current.data).toEqual([{ id: 1, name: "Jane" }]); |
| 86 | expect(result.current.error).toBeNull(); |
| 87 | }); |
| 88 | |
| 89 | it("handles errors", async () => { |
| 90 | server.use( |
| 91 | http.get("/api/users", () => { |
| 92 | return new HttpResponse(null, { status: 500 }); |
| 93 | }) |
| 94 | ); |
| 95 | |
| 96 | const { result } = renderHook(() => useFetch("/api/users")); |
| 97 | |
| 98 | await waitFor(() => { |
| 99 | expect(result.current.loading).toBe(false); |
| 100 | }); |
| 101 | |
| 102 | expect(result.current.error).toBeTruthy(); |
| 103 | }); |
| 104 | }); |
info
Integration tests verify multiple components working together. E2E tests (Playwright, Cypress) test the entire application in a real browser. Together with unit tests, they form a testing pyramid.
| 1 | // Integration test: multiple components working together |
| 2 | describe("TodoApp integration", () => { |
| 3 | it("adds, toggles, and removes todos", async () => { |
| 4 | const user = userEvent.setup(); |
| 5 | render(<TodoApp />); |
| 6 | |
| 7 | // Add a todo |
| 8 | const input = screen.getByRole("textbox", { name: /new todo/i }); |
| 9 | await user.type(input, "Buy groceries"); |
| 10 | await user.click(screen.getByRole("button", { name: /add/i })); |
| 11 | |
| 12 | expect(screen.getByText("Buy groceries")).toBeInTheDocument(); |
| 13 | |
| 14 | // Toggle todo |
| 15 | await user.click(screen.getByText("Buy groceries")); |
| 16 | expect(screen.getByText("Buy groceries")).toHaveClass("done"); |
| 17 | |
| 18 | // Remove todo |
| 19 | await user.click(screen.getByRole("button", { name: /delete/i })); |
| 20 | expect(screen.queryByText("Buy groceries")).not.toBeInTheDocument(); |
| 21 | }); |
| 22 | |
| 23 | it("filters todos", async () => { |
| 24 | const user = userEvent.setup(); |
| 25 | render(<TodoApp />); |
| 26 | |
| 27 | // Add multiple todos |
| 28 | await user.type(screen.getByRole("textbox"), "Task 1"); |
| 29 | await user.click(screen.getByRole("button", { name: /add/i })); |
| 30 | await user.type(screen.getByRole("textbox"), "Task 2"); |
| 31 | await user.click(screen.getByRole("button", { name: /add/i })); |
| 32 | |
| 33 | // Toggle one to completed |
| 34 | await user.click(screen.getByText("Task 1")); |
| 35 | |
| 36 | // Filter to active |
| 37 | await user.click(screen.getByRole("button", { name: /active/i })); |
| 38 | expect(screen.queryByText("Task 1")).not.toBeInTheDocument(); |
| 39 | expect(screen.getByText("Task 2")).toBeInTheDocument(); |
| 40 | |
| 41 | // Filter to completed |
| 42 | await user.click(screen.getByRole("button", { name: /completed/i })); |
| 43 | expect(screen.getByText("Task 1")).toBeInTheDocument(); |
| 44 | expect(screen.queryByText("Task 2")).not.toBeInTheDocument(); |
| 45 | }); |
| 46 | }); |
| 47 | |
| 48 | // API integration test with MSW |
| 49 | describe("UserList with API", () => { |
| 50 | it("renders users from API", async () => { |
| 51 | server.use( |
| 52 | http.get("/api/users", () => { |
| 53 | return HttpResponse.json([ |
| 54 | { id: 1, name: "Alice" }, |
| 55 | { id: 2, name: "Bob" }, |
| 56 | ]); |
| 57 | }) |
| 58 | ); |
| 59 | |
| 60 | render(<UserList />); |
| 61 | |
| 62 | expect(screen.getByText(/loading/i)).toBeInTheDocument(); |
| 63 | |
| 64 | await screen.findByText("Alice"); |
| 65 | expect(screen.getByText("Bob")).toBeInTheDocument(); |
| 66 | expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); |
| 67 | }); |
| 68 | }); |
Snapshot tests capture the rendered output of a component and compare it against a stored snapshot. They catch unexpected UI changes. Use them sparingly — they're brittle and often test implementation details.
| 1 | import { render } from "@testing-library/react"; |
| 2 | import { expect, it } from "vitest"; |
| 3 | |
| 4 | // Snapshot test — captures rendered output |
| 5 | it("renders correctly", () => { |
| 6 | const { container } = render( |
| 7 | <UserCard user={{ name: "Jane", email: "jane@test.com" }} /> |
| 8 | ); |
| 9 | expect(container).toMatchSnapshot(); |
| 10 | }); |
| 11 | |
| 12 | // Inline snapshot — stores snapshot in the test file |
| 13 | it("renders loading state", () => { |
| 14 | const { container } = render(<UserProfile loading />); |
| 15 | expect(container).toMatchInlineSnapshot(` |
| 16 | <div> |
| 17 | <div class="spinner" /> |
| 18 | </div> |
| 19 | `); |
| 20 | }); |
| 21 | |
| 22 | // When to use snapshots: |
| 23 | // ✅ Good: catching unexpected UI changes in stable components |
| 24 | // ✅ Good: documenting component output |
| 25 | // ❌ Bad: testing dynamic content (dates, IDs) |
| 26 | // ❌ Bad: large snapshots (entire page renders) |
| 27 | // ❌ Bad: when you have good behavioral tests already |
warning
Mocking isolates units of code and simulates external dependencies. Use the right strategy for each situation:
| 1 | import { vi } from "vitest"; |
| 2 | |
| 3 | // 1. Mock entire modules |
| 4 | vi.mock("./api", () => ({ |
| 5 | fetchUsers: vi.fn().mockResolvedValue([ |
| 6 | { id: 1, name: "Mock User" }, |
| 7 | ]), |
| 8 | createUser: vi.fn().mockResolvedValue({ id: 2, name: "Created" }), |
| 9 | })); |
| 10 | |
| 11 | // 2. Mock specific functions |
| 12 | vi.mock("./utils", () => ({ |
| 13 | ...vi.importActual("./utils"), |
| 14 | formatDate: vi.fn().mockReturnValue("Jan 1, 2026"), |
| 15 | })); |
| 16 | |
| 17 | // 3. Mock fetch with MSW (recommended) |
| 18 | import { http, HttpResponse } from "msw"; |
| 19 | import { setupServer } from "msw/node"; |
| 20 | |
| 21 | const server = setupServer( |
| 22 | http.get("/api/users", () => { |
| 23 | return HttpResponse.json([{ id: 1, name: "Test User" }]); |
| 24 | }), |
| 25 | http.post("/api/users", async ({ request }) => { |
| 26 | const body = await request.json(); |
| 27 | return HttpResponse.json({ id: 3, ...body }, { status: 201 }); |
| 28 | }) |
| 29 | ); |
| 30 | |
| 31 | beforeAll(() => server.listen()); |
| 32 | afterEach(() => server.resetHandlers()); |
| 33 | afterAll(() => server.close()); |
| 34 | |
| 35 | // 4. Mock components |
| 36 | vi.mock("./components/Analytics", () => ({ |
| 37 | default: () => <div data-testid="analytics-mock">Analytics Mock</div>, |
| 38 | })); |
| 39 | |
| 40 | // 5. Spy on function calls |
| 41 | const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 42 | // ... test ... |
| 43 | expect(consoleSpy).not.toHaveBeenCalled(); |
| 44 | consoleSpy.mockRestore(); |
pro tip
Organize tests to be easy to find, understand, and maintain. Follow these conventions:
| 1 | // File structure — colocate tests with components |
| 2 | src/ |
| 3 | components/ |
| 4 | Button/ |
| 5 | Button.tsx |
| 6 | Button.test.tsx ← co-located test |
| 7 | Button.module.css |
| 8 | UserCard/ |
| 9 | UserCard.tsx |
| 10 | UserCard.test.tsx |
| 11 | useUserCard.ts |
| 12 | useUserCard.test.ts |
| 13 | |
| 14 | // Test file structure |
| 15 | describe("ComponentName", () => { |
| 16 | describe("rendering", () => { |
| 17 | it("renders with default props", () => {}); |
| 18 | it("renders with custom props", () => {}); |
| 19 | it("renders loading state", () => {}); |
| 20 | }); |
| 21 | |
| 22 | describe("interaction", () => { |
| 23 | it("handles click events", () => {}); |
| 24 | it("handles form submission", () => {}); |
| 25 | }); |
| 26 | |
| 27 | describe("edge cases", () => { |
| 28 | it("handles empty data", () => {}); |
| 29 | it("handles error states", () => {}); |
| 30 | }); |
| 31 | }); |