Testing gives you confidence to ship changes without breaking things. Here is how to set up Vitest with React Testing Library.
Install Dependencies
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react
Configure Vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.{ts,tsx}", "src/**/index.ts"],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
Test a Simple Component
// components/Badge.tsx
interface BadgeProps {
variant?: "default" | "success" | "warning" | "error";
children: React.ReactNode;
}
export function Badge({ variant = "default", children }: BadgeProps) {
const colors = {
default: "bg-gray-100 text-gray-800",
success: "bg-green-100 text-green-800",
warning: "bg-yellow-100 text-yellow-800",
error: "bg-red-100 text-red-800",
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[variant]}`}>
{children}
</span>
);
}
// components/Badge.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Badge } from "./Badge";
describe("Badge", () => {
it("renders children text", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("applies default variant styles", () => {
render(<Badge>Default</Badge>);
const badge = screen.getByText("Default");
expect(badge).toHaveClass("bg-gray-100");
});
it("applies success variant styles", () => {
render(<Badge variant="success">Online</Badge>);
expect(screen.getByText("Online")).toHaveClass("bg-green-100");
});
});
Test User Interactions
// components/Counter.tsx
"use client";
import { useState } from "react";
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<div>
<span data-testid="count">{count}</span>
<button onClick={() => setCount((c) => c - 1)}>Decrement</button>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// components/Counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { Counter } from "./Counter";
describe("Counter", () => {
it("starts at initial value", () => {
render(<Counter initial={5} />);
expect(screen.getByTestId("count")).toHaveTextContent("5");
});
it("increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByTestId("count")).toHaveTextContent("1");
});
it("decrements on click", async () => {
const user = userEvent.setup();
render(<Counter initial={3} />);
await user.click(screen.getByRole("button", { name: "Decrement" }));
expect(screen.getByTestId("count")).toHaveTextContent("2");
});
it("resets to zero", async () => {
const user = userEvent.setup();
render(<Counter initial={10} />);
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByTestId("count")).toHaveTextContent("0");
});
});
Test Async Components
// components/UserProfile.tsx
"use client";
import { useEffect, useState } from "react";
interface User {
name: string;
email: string;
}
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error("User not found");
return res.json();
})
.then(setUser)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div role="status">Loading...</div>;
if (error) return <div role="alert">{error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// components/UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserProfile } from "./UserProfile";
describe("UserProfile", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("shows loading state initially", () => {
vi.spyOn(global, "fetch").mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(<UserProfile userId="1" />);
expect(screen.getByRole("status")).toHaveTextContent("Loading...");
});
it("renders user data on success", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ name: "Jane Doe", email: "jane@example.com" }), {
status: 200,
})
);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText("Jane Doe")).toBeInTheDocument();
expect(screen.getByText("jane@example.com")).toBeInTheDocument();
});
});
it("shows error on failure", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 404 })
);
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent("User not found");
});
});
});
Test Forms
// components/LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("validates required fields", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits with valid data", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
});
});
it("shows error for invalid email", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), "invalid");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
});
Test Custom Hooks
// hooks/useDebounce.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useDebounce } from "./useDebounce";
describe("useDebounce", () => {
it("returns initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("debounces value changes", () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
rerender({ value: "world", delay: 500 });
expect(result.current).toBe("hello"); // Not yet updated
act(() => vi.advanceTimersByTime(500));
expect(result.current).toBe("world"); // Updated after delay
vi.useRealTimers();
});
it("resets timer on rapid changes", () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "a" } }
);
rerender({ value: "ab" });
act(() => vi.advanceTimersByTime(300));
rerender({ value: "abc" });
act(() => vi.advanceTimersByTime(300));
expect(result.current).toBe("a"); // Still debouncing
act(() => vi.advanceTimersByTime(200));
expect(result.current).toBe("abc"); // Final value
vi.useRealTimers();
});
});
Run Tests
// package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Need Help Building a Test Suite?
We set up testing infrastructure and write comprehensive tests for web applications. Contact us to discuss testing for your project.