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

Testing

ReactTestingQualityIntermediate to Advanced🎯Free Tools
Introduction

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.

terminal
Bash
1# Setup with Vitest (recommended for new projects)
2npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
3
4# Setup with Jest (still widely used)
5npm install -D jest @testing-library/react @testing-library/jest-dom
6npm install -D @types/jest ts-jest jest-environment-jsdom
7
8# For component rendering
9npm install -D @testing-library/user-event
10
11# For API mocking
12npm install -D msw
Testing Philosophy

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.

testing-philosophy.jsx
JSX
1// ❌ Bad: testing implementation details
2function 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
8test("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
18test("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
33test("button has accessible name", () => {
34 render(<Counter />);
35 const button = screen.getByRole("button");
36 expect(button).toHaveAccessibleName(/count/i);
37});

best practice

Test user behavior, not implementation. Use queries that users would use (getByRole, getByLabelText, getByText) rather than test IDs (getByTestId). Only use test IDs as a last resort when no accessible query works.
Component Tests

Component tests verify that components render correctly and respond to user interactions. Here are the essential patterns:

component-tests.jsx
JSX
1import { render, screen, within } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { describe, it, expect, vi } from "vitest";
4
5// Test: renders correctly
6describe("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});
Query Priority

React Testing Library provides many query methods. Follow this priority order — use the first one that works:

query-priority.jsx
JSX
1// Query Priority (highest to lowest)
2// 1. getByRole — accessible role (best for accessibility)
3screen.getByRole("button", { name: /submit/i });
4screen.getByRole("textbox", { name: /email/i });
5screen.getByRole("heading", { name: /profile/i });
6
7// 2. getByLabelText — form elements with labels
8screen.getByLabelText(/email address/i);
9
10// 3. getByPlaceholderText — inputs with placeholders
11screen.getByPlaceholderText(/search/i);
12
13// 4. getByText — visible text content
14screen.getByText(/welcome back/i);
15
16// 5. getByDisplayValue — current form input values
17screen.getByDisplayValue("hello@example.com");
18
19// 6. getByAltText — images
20screen.getByAltText(/user avatar/i);
21
22// 7. getByTitle — elements with title attribute
23screen.getByTitle(/close dialog/i);
24
25// 8. getByTestId — last resort (when no accessible query works)
26screen.getByTestId("user-card-123");
27
28// Multiple elements
29screen.getAllByRole("listitem"); // returns array
30screen.queryAllByRole("listitem"); // returns [] instead of throwing
31
32// findBy (async — waits for element)
33await screen.findByText("Loaded!");
34
35// within — scope queries to a container
36const card = screen.getByTestId("user-card");
37const name = within(card).getByText("Jane Doe");
38const email = within(card).getByText(/@example.com/);
Testing Custom Hooks

Custom hooks are tested by rendering them inside a test component. Use renderHook from Testing Library for a cleaner API.

hooks-testing.jsx
JSX
1import { renderHook, act } from "@testing-library/react";
2
3// Test useLocalStorage hook
4describe("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
33describe("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
62import { http, HttpResponse } from "msw";
63import { setupServer } from "msw/node";
64
65const server = setupServer(
66 http.get("/api/users", () => {
67 return HttpResponse.json([{ id: 1, name: "Jane" }]);
68 })
69);
70
71beforeAll(() => server.listen());
72afterEach(() => server.resetHandlers());
73afterAll(() => server.close());
74
75describe("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

Test custom hooks by testing the behavior they expose, not their internal mechanics. If a hook manages state, test the state transitions. If it makes API calls, mock the API and test the loading/error/data states.
Integration & E2E Tests

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.

integration-tests.jsx
JSX
1// Integration test: multiple components working together
2describe("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
49describe("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

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.

snapshot-tests.jsx
JSX
1import { render } from "@testing-library/react";
2import { expect, it } from "vitest";
3
4// Snapshot test — captures rendered output
5it("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
13it("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

Snapshot tests are controversial. They often become a burden — developers update snapshots without reviewing changes. Prefer behavioral tests that assert specific elements and values. Use snapshots only for small, stable components where you genuinely want to detect unintended UI changes.
Mocking Strategies

Mocking isolates units of code and simulates external dependencies. Use the right strategy for each situation:

mocking.jsx
JSX
1import { vi } from "vitest";
2
3// 1. Mock entire modules
4vi.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
12vi.mock("./utils", () => ({
13 ...vi.importActual("./utils"),
14 formatDate: vi.fn().mockReturnValue("Jan 1, 2026"),
15}));
16
17// 3. Mock fetch with MSW (recommended)
18import { http, HttpResponse } from "msw";
19import { setupServer } from "msw/node";
20
21const 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
31beforeAll(() => server.listen());
32afterEach(() => server.resetHandlers());
33afterAll(() => server.close());
34
35// 4. Mock components
36vi.mock("./components/Analytics", () => ({
37 default: () => <div data-testid="analytics-mock">Analytics Mock</div>,
38}));
39
40// 5. Spy on function calls
41const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
42// ... test ...
43expect(consoleSpy).not.toHaveBeenCalled();
44consoleSpy.mockRestore();
🔥

pro tip

MSW (Mock Service Worker) is the recommended approach for API mocking. It intercepts network requests at the network level, making tests realistic without touching the actual API. It works in both tests and development.
Test Organization

Organize tests to be easy to find, understand, and maintain. Follow these conventions:

test-organization.txt
TEXT
1// File structure — colocate tests with components
2src/
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
15describe("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});