Testing React Components
Introduction
Vitest runs fast unit tests in the same Vite toolchain as your app. React Testing Library (RTL) mounts components in jsdom, simulates user interactions, and asserts accessible output. This chapter covers component tests, custom hooks, Zustand stores, and mocked fetch—without a real browser for every case.
Prerequisites
- Composition, Props, and Lifting State
- Custom Hooks
- Node.js project with Vite + React
Install
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomvite.config.ts:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",
},
});src/test/setup.ts:
import "@testing-library/jest-dom/vitest";package.json:
"test": "vitest",
"test:run": "vitest run"npm run test
npm run test:runCompare Testing Vue Components and Testing FastAPI.
First Component Test
src/components/HelloBanner.tsx:
type Props = { title: string };
export function HelloBanner({ title }: Props) {
return <h1>{title}</h1>;
}src/components/HelloBanner.test.tsx:
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { HelloBanner } from "./HelloBanner";
describe("HelloBanner", () => {
it("renders title", () => {
render(<HelloBanner title="Hello Test" />);
expect(screen.getByRole("heading", { name: "Hello Test" })).toBeInTheDocument();
});
});Code explanation:
rendermounts into jsdom- Query by role and accessible name—preferred over CSS selectors
toBeInTheDocument()from jest-dom matchers
User Events
import userEvent from "@testing-library/user-event";
it("increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: /add one/i }));
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});Testing Custom Hooks
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "../hooks/useCounter";
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});Router and Providers
Wrap components that use useNavigate or Link:
import { MemoryRouter } from "react-router-dom";
render(
<MemoryRouter initialEntries={["/posts"]}>
<PostList />
</MemoryRouter>
);Auth or theme:
render(
<ThemeProvider>
<Dashboard />
</ThemeProvider>
);Mock fetch
Zustand Store Tests
Reset store between tests:
import { useTodoStore } from "../stores/todoStore";
beforeEach(() => {
useTodoStore.setState({ items: [], filter: "all" });
});
it("adds todo", () => {
useTodoStore.getState().addTodo("Write tests");
expect(useTodoStore.getState().items).toHaveLength(1);
});What to Test
| Test | Example |
|---|---|
| Renders with props | Heading text |
| User flow | Submit form → success message |
| Edge cases | Empty list, error from API |
| Hooks / store | useCounter, Zustand actions |
Avoid testing implementation details (internal state variable names).
E2E (Concept)
Playwright or Cypress run real browsers for full flows—login → navigate → submit. Add after unit tests cover components.
FAQ
getBy vs queryBy?
getBy throws if missing; queryBy returns null—use for asserting absence.
findBy?
Async—waits for element (API loading).
Snapshot tests?
Use sparingly—prefer role/text assertions.
CI?
- run: npm run test:runLink Git CI.