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

Install

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

vite.config.ts:

typescript
/// <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:

typescript
import "@testing-library/jest-dom/vitest";

package.json:

json
"test": "vitest",
"test:run": "vitest run"
bash
npm run test
npm run test:run

Compare Testing Vue Components and Testing FastAPI.

First Component Test

src/components/HelloBanner.tsx:

tsx
type Props = { title: string };
 
export function HelloBanner({ title }: Props) {
  return <h1>{title}</h1>;
}

src/components/HelloBanner.test.tsx:

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:

  • render mounts into jsdom
  • Query by role and accessible name—preferred over CSS selectors
  • toBeInTheDocument() from jest-dom matchers

User Events

tsx
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

tsx
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:

tsx
import { MemoryRouter } from "react-router-dom";
 
render(
  <MemoryRouter initialEntries={["/posts"]}>
    <PostList />
  </MemoryRouter>
);

Auth or theme:

tsx
render(
  <ThemeProvider>
    <Dashboard />
  </ThemeProvider>
);

Mock fetch

Zustand Store Tests

Reset store between tests:

tsx
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

TestExample
Renders with propsHeading text
User flowSubmit form → success message
Edge casesEmpty list, error from API
Hooks / storeuseCounter, 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?

yaml
- run: npm run test:run

Link Git CI.

Next chapter?

Build, Preview, and Deploy.