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.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
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.
FAQ
¿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
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.