Testing React applications has gotten significantly better in the last couple of years. Vitest has largely replaced Jest as the test runner of choice (faster, native ESM support, Vite-compatible), and React Testing Library remains the standard for component testing with its focus on testing behavior rather than implementation.
This guide covers the practical patterns I use for testing React applications in 2026, with TypeScript examples throughout.
Setting Up Vitest
Terminal
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
},
});
src/test/setup.ts
import "@testing-library/jest-dom/vitest";
Testing Components
The core principle of React Testing Library is to test your components the way users interact with them — by text content, labels, and roles rather than CSS selectors or component internals.
TodoList.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "./TodoList";
describe("TodoList", () => {
it("adds a new todo when the form is submitted", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByPlaceholderText("Add a todo...");
const button = screen.getByRole("button", { name: /add/i });
await user.type(input, "Buy groceries");
await user.click(button);
expect(screen.getByText("Buy groceries")).toBeInTheDocument();
expect(input).toHaveValue(""); // Input cleared after submit
});
it("marks a todo as completed when clicked", async () => {
const user = userEvent.setup();
render(<TodoList initialTodos={[{ id: "1", text: "Test", completed: false }]} />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
});
Testing Async Behavior
For components that fetch data or perform async operations, use waitFor and findBy queries:
UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
import { http, HttpResponse } from "msw";
import { server } from "../test/mocks/server";
it("displays the user profile after loading", async () => {
server.use(
http.get("/api/users/1", () =>
HttpResponse.json({ name: "Gerson", email: "dev@example.com" })
)
);
render(<UserProfile userId="1" />);
// Initially shows loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
expect(await screen.findByText("Gerson")).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it("shows an error message when the API fails", async () => {
server.use(
http.get("/api/users/1", () => HttpResponse.error())
);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/failed to load/i);
});
});
Testing Custom Hooks
For hooks that encapsulate complex logic, use renderHook:
useDebounce.test.ts
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";
it("debounces the value by the specified delay", async () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 300 } }
);
expect(result.current).toBe("hello");
rerender({ value: "hello world", delay: 300 });
expect(result.current).toBe("hello"); // Not updated yet
act(() => { vi.advanceTimersByTime(300); });
expect(result.current).toBe("hello world"); // Now updated
vi.useRealTimers();
});
What to Test
Focus your testing effort where it provides the most value:
- User interactions — Form submissions, button clicks, navigation. These are the paths users take and the most likely to break during refactoring.
- Conditional rendering — Error states, empty states, loading states. These are easy to forget and common sources of bugs.
- Custom hooks with logic — Debouncing, pagination, form validation. Complex state machines benefit from isolated testing.
- Integration points — API calls, context providers, route changes. Use MSW for API mocking to test realistic data flows.
Don't test: implementation details (state values, internal methods), simple rendering of static content, or third-party library behavior.
