Vitest para React: Component, Hook, e Integration Testing
Dominá Vitest para testing en React: component tests con Testing Library, hook tests con renderHook, integration tests, mocking, snapshot testing y ejecución paralela.
Introducción
Vitest es un framework de testing rápido, nativo de Vite que se integra directamente con proyectos de React. Provee APIs compatibles con Jest, soporte nativo de ESM y hot module reloading instantáneo para tests. Combinado con React Testing Library, habilita testing a nivel de componente que se enfoca en el comportamiento del usuario en vez de detalles de implementación. A continuación: component tests, hook tests, integration tests, estrategias de mocking y configuración de CI para proyectos de React usando Vitest.
Setup
Instalación
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Configuración
// vitest.config.ts — Configuración de Vitest para 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 después de cada test
afterEach(() => {
cleanup();
});
// Mock matchMedia (no disponible en 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
Component test básico
// 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 con 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 con 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
Mockeando 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" });
});
});
Mockeando 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",
}),
})
);
});
});
Configuración de CI
GitHub Actions
# .github/workflows/test.yml — Vitest en 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.
-
Testeá comportamiento, no implementación — query by role, text, o test-id, no por CSS class
-
Usá
userEventsobrefireEvent— simula interacciones reales de user más accurate -
Mockeá en el boundary del module — mockeá API clients, no internal functions
-
Usá
screenqueries — evita guardargetByTextreferences que break on re-render -
Testeá accessibility — usá
getByRolepara verificar que elementos son accessible -
Cleanupeá después de cada test —
cleanup()en setup previene DOM leakage -
Usá
waitForpara async assertions — evita flaky timing issues -
Nombrá test files
*.test.tsx— convención para easy discovery -
Mantené tests independientes — no shared state entre tests
-
Corré con
--coverageen CI — capturá coverage regressions
Common Mistakes
- Testear implementation details: testear state values en vez de rendered output. Cuando refactorizás, los tests break aunque el behavior no cambió.
- Usar
fireEventen vez deuserEvent:fireEventdispatcha raw DOM events;userEventsimula real user behavior (focus, typing, clicking). - No await async operations: tests pasan cuando no deberían porque las assertions corren antes de que async updates complete. Usá
waitFor. - Over-mocking: mockear todo hace que los tests testeen los mocks, no el code. Mockeá solo en boundaries.
- Snapshot testing everything: snapshots capturan cambios pero no verifican correctness. Usá sparingly para visual components.
Preguntas frecuentes
¿Qué es Vitest?
Un framework de testing nativo de Vite con APIs compatibles con Jest. Corre tests en el mismo transform pipeline que tu app, proveyendo ejecución rápida y soporte nativo de ESM.
¿Cómo se diferencia Vitest de Jest?
Vitest usa el transform pipeline de Vite, así que no hay config separada para TypeScript, JSX, o ESM. Es más rápido para proyectos de Vite y soporta HMR para tests. Jest requiere más configuración y corre en Node con transform plugins.
¿Debería testear componentes de React con implementation details?
No. Testeá lo que el user ve y hace. Usá getByRole, getByText, y userEvent para interactuar con componentes como lo haría un user. Esto hace los tests resilientes a refactoring.
¿Cómo mockeo API calls en Vitest?
Usá vi.mock("@/api/client", () => ({ ... })) para mockear modules enteros. Para partial mocking, usá importOriginal para spread real exports y overridear functions específicas.
¿Cómo testeo custom hooks?
Usá renderHook de @testing-library/react. Renderiza el hook en un test component y te da acceso al return value del hook via result.current.
Recursos Relacionados
Desarrollo Guiado por Pruebas (TDD)
Aprende TDD paso a paso: escribe un test que falle, hazlo pasar, refactoriza. Red-Green-Refactor con ejemplos reales en Python, JavaScript y Java.
GuideGuía de Estrategia de Testing
Una guía práctica para construir una estrategia de testing en capas con unit, integration y end-to-end tests.
GuideOptimización de Performance en React
Optimiza apps de React para velocidad. Cubre memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features y Core Web Vitals.
RecipeVitest Snapshot Testing para React
Cómo usar Vitest snapshot testing para detectar cambios no intencionados en la UI de componentes React, incluyendo inline snapshots y flujos de actualización.
RecipeSnapshot Testing de Componentes React con Jest
Como usar snapshot testing de Jest para detectar regresiones de UI no intencionales en componentes React y prevenir bugs visuales en produccion
GuideAI Agents en Producción
Construir AI agents en produccion. Cubre arquitecturas de agents, tool use, planning, memory, multi-agent systems, patrones ReAct, function calling, human-in-the-loop, safety y deployment para agents autonomos confiables.