Vitest for React: Component, Hook, and Integration Testing
Master Vitest for React testing: component tests with Testing Library, hook tests with renderHook, integration tests, mocking, snapshot testing, and parallel execution.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
Vitest is a fast, Vite-native testing framework that integrates directly with React projects. It provides Jest-compatible APIs, native ESM support, and instant hot module reloading for tests. Combined with React Testing Library, it enables component-level testing that focuses on user behavior rather than implementation details. The following walks through component tests, hook tests, integration tests, mocking strategies, and CI configuration for React projects using Vitest.
Setup
Installation
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Configuration
// vitest.config.ts — Vitest configuration for React
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
css: true,
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.d.ts", "src/main.tsx"],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
});
Setup file
// tests/setup.ts — Global test setup
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
// Clean up DOM after each test
afterEach(() => {
cleanup();
});
// Mock matchMedia (not available in jsdom)
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});
Component Testing
Basic component test
// tests/components/Button.test.tsx — Basic component test
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@/components/Button";
describe("Button", () => {
it("renders with correct text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
});
it("calls onClick when clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});
it("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
it("applies variant classes", () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole("button")).toHaveClass("btn-primary");
rerender(<Button variant="danger">Danger</Button>);
expect(screen.getByRole("button")).toHaveClass("btn-danger");
});
});
Testing forms
// tests/components/LoginForm.test.tsx — Form testing
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "@/components/LoginForm";
describe("LoginForm", () => {
it("submits with valid credentials", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123",
});
});
});
it("shows validation errors for empty fields", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("validates email format", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
});
});
Testing async components
// tests/components/UserProfile.test.tsx — Async component testing
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "@/components/UserProfile";
vi.mock("@/api/users", () => ({
fetchUser: vi.fn(),
}));
import { fetchUser } from "@/api/users";
describe("UserProfile", () => {
it("shows loading state then user data", async () => {
vi.mocked(fetchUser).mockResolvedValue({
id: 1,
name: "Alice",
email: "alice@example.com",
});
render(<UserProfile userId={1} />);
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Data loaded
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
});
it("shows error message on fetch failure", async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error("Network error"));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
});
Hook Testing
Testing custom hooks with renderHook
// tests/hooks/useCounter.test.ts — Hook testing
import { describe, it, expect, act } from "vitest";
import { renderHook } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("initializes with default value", () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it("initializes with custom value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments count", () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.increment());
expect(result.current.count).toBe(2);
});
it("decrements count", () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
it("resets count", () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
expect(result.current.count).toBe(11);
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});
});
Testing hooks with dependencies
// tests/hooks/useDebounce.test.ts — Debounce hook testing
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "@/hooks/useDebounce";
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("updates value after delay", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
rerender({ value: "world", delay: 500 });
expect(result.current).toBe("hello"); // Still old value
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe("world"); // Now updated
});
});
Mocking
Mocking modules
// tests/mocks/api.test.ts — Module mocking
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/api/client", () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
import { apiClient } from "@/api/client";
import { UserService } from "@/services/UserService";
describe("UserService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches users", async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: [{ id: 1, name: "Alice" }],
});
const users = await UserService.getAll();
expect(users).toHaveLength(1);
expect(apiClient.get).toHaveBeenCalledWith("/users");
});
it("creates user", async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: 2, name: "Bob" },
});
const user = await UserService.create({ name: "Bob" });
expect(user.id).toBe(2);
expect(apiClient.post).toHaveBeenCalledWith("/users", { name: "Bob" });
});
});
Mocking context providers
// tests/components/ThemeAwareComponent.test.tsx — Context mocking
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ThemeContext, Theme } from "@/contexts/ThemeContext";
import { ThemeAwareComponent } from "@/components/ThemeAwareComponent";
function renderWithTheme(ui: React.ReactNode, theme: Theme = "light") {
return render(
<ThemeContext.Provider value={{ theme, toggleTheme: vi.fn() }}>
{ui}
</ThemeContext.Provider>
);
}
describe("ThemeAwareComponent", () => {
it("renders light theme styles", () => {
renderWithTheme(<ThemeAwareComponent />, "light");
expect(screen.getByTestId("themed")).toHaveClass("light");
});
it("renders dark theme styles", () => {
renderWithTheme(<ThemeAwareComponent />, "dark");
expect(screen.getByTestId("themed")).toHaveClass("dark");
});
});
Partial mocking
// tests/mocks/partial.test.ts — Partial module mocking
import { describe, it, expect, vi } from "vitest";
vi.mock("@/utils/format", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/format")>();
return {
...actual,
formatDate: vi.fn(() => "2026-01-01"),
};
});
import { formatDate, formatCurrency } from "@/utils/format";
describe("partial mock", () => {
it("uses mocked formatDate", () => {
expect(formatDate(new Date())).toBe("2026-01-01");
});
it("uses real formatCurrency", () => {
expect(formatCurrency(10.5)).toBe("$10.50");
});
});
Snapshot Testing
// tests/components/Card.snapshot.test.tsx — Snapshot testing
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { Card } from "@/components/Card";
describe("Card snapshots", () => {
it("matches snapshot with content", () => {
const { container } = render(
<Card title="Test Title" description="Test Description" />
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with image", () => {
const { container } = render(
<Card title="With Image" image="/test.jpg" />
);
expect(container.firstChild).toMatchSnapshot();
});
});
// Inline snapshots
it("matches inline snapshot", () => {
const { container } = render(<Card title="Inline" />);
expect(container.firstChild).toMatchInlineSnapshot(`
<div class="card">
<h3>Inline</h3>
</div>
`);
});
Integration Testing
// tests/integration/checkout.test.tsx — Integration test
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutFlow } from "@/pages/CheckoutFlow";
vi.mock("@/api/orders", () => ({
createOrder: vi.fn(),
calculateShipping: vi.fn(),
}));
import { createOrder, calculateShipping } from "@/api/orders";
describe("Checkout flow integration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(calculateShipping).mockResolvedValue({ cost: 5.99 });
vi.mocked(createOrder).mockResolvedValue({ id: "order-123", status: "confirmed" });
});
it("completes full checkout flow", async () => {
const user = userEvent.setup();
render(<CheckoutFlow items={[{ id: 1, name: "Widget", price: 10, qty: 2 }]} />);
// Step 1: Review cart
expect(screen.getByText("Widget")).toBeInTheDocument();
expect(screen.getByText("$20.00")).toBeInTheDocument();
// Step 2: Enter shipping
await user.click(screen.getByRole("button", { name: /continue to shipping/i }));
await user.type(screen.getByLabelText(/address/i), "123 Main St");
await user.type(screen.getByLabelText(/city/i), "New York");
await user.type(screen.getByLabelText(/zip/i), "10001");
// Wait for shipping calculation
await waitFor(() => {
expect(screen.getByText("$5.99")).toBeInTheDocument();
});
// Step 3: Place order
await user.click(screen.getByRole("button", { name: /place order/i }));
await waitFor(() => {
expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("order-123")).toBeInTheDocument();
});
expect(createOrder).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: 1, qty: 2 }),
]),
shipping: expect.objectContaining({
address: "123 Main St",
city: "New York",
zip: "10001",
}),
})
);
});
});
CI Configuration
GitHub Actions
# .github/workflows/test.yml — Vitest in CI
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage/coverage-final.json
Package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch"
}
}
Best Practices
-
For a deeper guide, see Vitest Snapshot Testing for React.
-
Test behavior, not implementation — query by role, text, or test-id, not by CSS class
-
Use
userEventoverfireEvent— it simulates real user interactions more accurately -
Mock at the module boundary — mock API clients, not internal functions
-
Use
screenqueries — avoids storinggetByTextreferences that break on re-render -
Test accessibility — use
getByRoleto verify elements are accessible -
Clean up after each test —
cleanup()in setup prevents DOM leakage -
Use
waitForfor async assertions — avoids flaky timing issues -
Name test files
*.test.tsx— convention for easy discovery -
Keep tests independent — no shared state between tests
-
Run with
--coveragein CI — catch coverage regressions
Common Mistakes
- Testing implementation details: testing state values instead of rendered output. When you refactor, tests break even though behavior is unchanged.
- Using
fireEventinstead ofuserEvent:fireEventdispatches raw DOM events;userEventsimulates real user behavior (focus, typing, clicking). - Not awaiting async operations: tests pass when they shouldn’t because assertions run before async updates complete. Use
waitFor. - Over-mocking: mocking everything makes tests test the mocks, not the code. Mock at boundaries only.
- Snapshot testing everything: snapshots catch changes but don’t verify correctness. Use sparingly for visual components.
FAQ
What is Vitest?
A Vite-native testing framework with Jest-compatible APIs. It runs tests in the same transform pipeline as your app, providing fast execution and native ESM support.
How is Vitest different from Jest?
Vitest uses Vite’s transform pipeline, so there’s no separate config for TypeScript, JSX, or ESM. It’s faster for Vite projects and supports HMR for tests. Jest requires more configuration and runs in Node with transform plugins.
Should I test React components with implementation details?
No. Test what the user sees and does. Use getByRole, getByText, and userEvent to interact with components as a user would. This makes tests resilient to refactoring.
How do I mock API calls in Vitest?
Use vi.mock("@/api/client", () => ({ ... })) to mock entire modules. For partial mocking, use importOriginal to spread real exports and override specific functions.
How do I test custom hooks?
Use renderHook from @testing-library/react. It renders the hook in a test component and gives you access to the hook’s return value via result.current.
Related Resources
Test-Driven Development (TDD) — A Practical Workflow
Learn TDD step by step: write a failing test, make it pass, refactor. Red-Green-Refactor with real examples in Python, JavaScript, and Java.
GuideSoftware Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.
GuideComplete Guide to React Performance Optimization
Optimize React apps for speed. Covers memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features, and Core Web Vitals.
RecipeVitest Snapshot Testing for React
How to use Vitest snapshot testing to catch unintended UI changes in React components, including inline snapshots and snapshot updating workflows.
RecipeSnapshot Testing React Components with Jest
How to use Jest snapshot testing to catch unintended UI regressions in React components and prevent visual bugs from reaching production
GuideComplete Guide to AI Agents in Production
Build production AI agents. Covers agent architectures, tool use, planning, memory, multi-agent systems, ReAct patterns, function calling, human-in-the-loop, safety, and deployment patterns for reliable autonomous agents.