Testing React with Vitest & Testing Library
Testing React with Vitest & Testing Library
Tests give you confidence to refactor, catch regressions before users do, and document how your components are supposed to behave. This lesson covers the modern React testing stack: Vitest as the test runner and React Testing Library as the component testing tool.
The Testing Philosophy
React Testing Library has a guiding principle: test what users see and do, not implementation details. A user doesn't care which state variable is set — they care whether the button appears, whether clicking it shows a message, whether the form validates correctly.
// ❌ Testing implementation (brittle — breaks on refactor)
expect(wrapper.state("isOpen")).toBe(true);
expect(component.find("DropdownMenu").props().isOpen).toBe(true);
// ✅ Testing behavior (resilient — focuses on what users experience)
expect(screen.getByRole("listbox")).toBeInTheDocument();
Setup
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from "vitest/config";
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";
// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
Your First Test
// src/components/Button.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
import { describe, it, expect, vi } from "vitest";
describe("Button", () => {
it("renders the label", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("calls onClick when clicked", async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Submit</Button>);
await userEvent.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Save</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("shows loading state", () => {
render(<Button loading>Submit</Button>);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeDisabled();
});
});
Testing Forms
// src/components/LoginForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
import { vi } from "vitest";
describe("LoginForm", () => {
it("shows validation errors for empty fields", async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(screen.getByText("Email is required")).toBeInTheDocument();
expect(screen.getByText("Password is required")).toBeInTheDocument();
});
it("shows error for invalid email", async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.type(screen.getByLabelText("Email"), "not-an-email");
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(screen.getByText("Valid email required")).toBeInTheDocument();
});
it("calls onSubmit with credentials", async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText("Email"), "alice@example.com");
await userEvent.type(screen.getByLabelText("Password"), "password123");
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(handleSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "password123",
});
});
it("disables submit button while submitting", async () => {
const handleSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)));
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText("Email"), "alice@example.com");
await userEvent.type(screen.getByLabelText("Password"), "password123");
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(screen.getByRole("button", { name: /Signing in/i })).toBeDisabled();
});
});
Testing Async Components
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
import { vi } from "vitest";
// Mock fetch
vi.mock("@/lib/api", () => ({
fetchUser: vi.fn(),
}));
import { fetchUser } from "@/lib/api";
describe("UserProfile", () => {
it("shows loading state initially", () => {
vi.mocked(fetchUser).mockResolvedValue({ name: "Alice", email: "alice@example.com" });
render(<UserProfile userId="1" />);
expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
});
it("displays user data after loading", async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: "Alice", email: "alice@example.com" });
render(<UserProfile userId="1" />);
await screen.findByText("Alice"); // findBy* waits for element to appear
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
it("shows error message when fetch fails", async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error("Not found"));
render(<UserProfile userId="1" />);
await screen.findByText(/failed to load/i);
});
});
Testing with Context
When components use React Context, wrap them in the providers:
// src/test/helpers.tsx
import { render } from "@testing-library/react";
import { AuthProvider } from "@/context/auth";
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
{children}
</AuthProvider>
);
}
function customRender(ui: React.ReactElement, options = {}) {
return render(ui, { wrapper: AllProviders, ...options });
}
export { customRender as render };
// Usage in tests
import { render } from "@/test/helpers";
Common Queries Reference
// getBy* — throws if not found (use for elements that must exist)
screen.getByRole("button", { name: "Submit" })
screen.getByLabelText("Email")
screen.getByPlaceholderText("Enter email")
screen.getByText("Hello World")
screen.getByTestId("submit-btn")
// queryBy* — returns null if not found (use for elements that might not exist)
const errorMsg = screen.queryByText("Error!");
expect(errorMsg).not.toBeInTheDocument();
// findBy* — async, waits for element to appear
const result = await screen.findByText("Loaded!");
// getAllBy* / queryAllBy* / findAllBy* — multiple elements
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(5);
What NOT to Test
- Internal implementation details (state variable names, private methods)
- Third-party library behavior (Shadcn, React Router)
- Styling specifics (exact pixel values, Tailwind classes)
- Simple prop passing (if your component just renders a prop, trust TypeScript)
Focus tests on: business logic, user interactions, edge cases, async behavior, and the most critical user flows.
Next lesson: Deploy to Vercel production — CI/CD setup, environment variables, and monitoring.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises