Skip to content
SP StackPractices
intermediate By Mathias Paulenko

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 userEvent over fireEvent — it simulates real user interactions more accurately

  • Mock at the module boundary — mock API clients, not internal functions

  • Use screen queries — avoids storing getByText references that break on re-render

  • Test accessibility — use getByRole to verify elements are accessible

  • Clean up after each test — cleanup() in setup prevents DOM leakage

  • Use waitFor for 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 --coverage in 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 fireEvent instead of userEvent: fireEvent dispatches raw DOM events; userEvent simulates 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.