Pruebas de Context y gestión de state
Prueba componentes que dependen de React Context, stores de Zustand u otros providers de state global.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { render, screen } from "@testing-library/react";
// Custom render with providers
function renderWithProviders(
ui: React.ReactElement,
{ theme = "light" }: { theme?: string } = {}
) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={theme}>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
),
});
}
// Use in tests
renderWithProviders(<Dashboard />, { theme: "dark" });
// Reset Zustand store between tests
import { useCartStore } from "@/stores/cart";
beforeEach(() => {
useCartStore.setState({ items: [], total: 0 });
});Cuándo usarlo: Cuando los componentes leen de React Context o un store de Zustand y necesitas controlar los valores proporcionados en los tests.
Ejemplo funcional
// src/context/theme-context.tsx
"use client";
import { createContext, useContext, useState, useCallback } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({
children,
initialTheme = "light",
}: {
children: React.ReactNode;
initialTheme?: Theme;
}) {
const [theme, setTheme] = useState<Theme>(initialTheme);
const toggleTheme = useCallback(
() => setTheme((t) => (t === "light" ? "dark" : "light")),
[]
);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}// src/components/theme-toggle.tsx
"use client";
import { useTheme } from "@/context/theme-context";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === "light" ? "Switch to dark" : "Switch to light"}
</button>
);
}// src/components/theme-toggle.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { ThemeProvider } from "@/context/theme-context";
import { ThemeToggle } from "./theme-toggle";
function renderWithTheme(ui: React.ReactElement, initialTheme: "light" | "dark" = "light") {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={initialTheme}>{children}</ThemeProvider>
),
});
}
describe("ThemeToggle", () => {
const user = userEvent.setup();
it("shows current theme", () => {
renderWithTheme(<ThemeToggle />);
expect(
screen.getByRole("button", { name: /toggle theme/i })
).toHaveTextContent("Switch to dark");
});
it("starts in dark mode when configured", () => {
renderWithTheme(<ThemeToggle />, "dark");
expect(screen.getByRole("button")).toHaveTextContent("Switch to light");
});
it("toggles theme on click", async () => {
renderWithTheme(<ThemeToggle />);
const button = screen.getByRole("button", { name: /toggle theme/i });
expect(button).toHaveTextContent("Switch to dark");
await user.click(button);
expect(button).toHaveTextContent("Switch to light");
await user.click(button);
expect(button).toHaveTextContent("Switch to dark");
});
it("throws when used outside provider", () => {
// Suppress console.error for expected error
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<ThemeToggle />)).toThrow(
"useTheme must be used within ThemeProvider"
);
spy.mockRestore();
});
});// src/stores/cart.ts
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
total: () => number;
clearCart: () => void;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity: Math.max(0, quantity) } : i
),
})),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
clearCart: () => set({ items: [] }),
}));// src/stores/cart.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useCartStore } from "./cart";
describe("CartStore", () => {
beforeEach(() => {
// Reset store to initial state between tests
useCartStore.setState({ items: [] });
});
it("adds an item", () => {
useCartStore.getState().addItem({ id: "1", name: "Widget", price: 10 });
const { items } = useCartStore.getState();
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
id: "1",
name: "Widget",
price: 10,
quantity: 1,
});
});
it("increments quantity for duplicate items", () => {
const { addItem } = useCartStore.getState();
addItem({ id: "1", name: "Widget", price: 10 });
addItem({ id: "1", name: "Widget", price: 10 });
const { items } = useCartStore.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(2);
});
it("removes an item", () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
useCartStore.getState().removeItem("1");
expect(useCartStore.getState().items).toHaveLength(0);
});
it("updates quantity", () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
useCartStore.getState().updateQuantity("1", 5);
expect(useCartStore.getState().items[0].quantity).toBe(5);
});
it("calculates total", () => {
useCartStore.setState({
items: [
{ id: "1", name: "Widget", price: 10, quantity: 2 },
{ id: "2", name: "Gadget", price: 25, quantity: 1 },
],
});
expect(useCartStore.getState().total()).toBe(45);
});
it("clears the cart", () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
useCartStore.getState().clearCart();
expect(useCartStore.getState().items).toHaveLength(0);
});
});// src/components/cart-summary.tsx
"use client";
import { useCartStore } from "@/stores/cart";
export function CartSummary() {
const items = useCartStore((s) => s.items);
const total = useCartStore((s) => s.total);
const removeItem = useCartStore((s) => s.removeItem);
if (items.length === 0) {
return <p>Your cart is empty.</p>;
}
return (
<div>
<h2>Cart ({items.length} items)</h2>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} x{item.quantity} - ${(item.price * item.quantity).toFixed(2)}
<button
onClick={() => removeItem(item.id)}
aria-label={`Remove ${item.name}`}
>
Remove
</button>
</li>
))}
</ul>
<p>Total: ${total().toFixed(2)}</p>
</div>
);
}// src/components/cart-summary.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, beforeEach } from "vitest";
import { useCartStore } from "@/stores/cart";
import { CartSummary } from "./cart-summary";
describe("CartSummary", () => {
const user = userEvent.setup();
beforeEach(() => {
useCartStore.setState({ items: [] });
});
it("shows empty message when cart is empty", () => {
render(<CartSummary />);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
});
it("renders cart items", () => {
useCartStore.setState({
items: [
{ id: "1", name: "Widget", price: 10, quantity: 2 },
{ id: "2", name: "Gadget", price: 25, quantity: 1 },
],
});
render(<CartSummary />);
expect(screen.getByText(/widget x2/i)).toBeInTheDocument();
expect(screen.getByText(/\$20\.00/)).toBeInTheDocument();
expect(screen.getByText(/gadget x1/i)).toBeInTheDocument();
expect(screen.getByText(/total: \$45\.00/i)).toBeInTheDocument();
});
it("removes item on button click", async () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
render(<CartSummary />);
await user.click(screen.getByRole("button", { name: /remove widget/i }));
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
});
});Lo que demuestra esto:
- Wrapper de render personalizado para componentes que dependen de context
- Pruebas de context providers y consumidores juntos
- Unit tests de stores de Zustand con
getState()ysetState() - Restablecimiento del state de Zustand entre tests
- Pruebas de componentes que leen de Zustand
Análisis profundo
Cómo funciona
- La opción
wrapperenrender()envuelve el componente de prueba en providers sin necesidad de hacerlo en cada test - Los context providers crean un nuevo ámbito — los tests ven los valores del provider del test, no los de la app
- Los stores de Zustand son singletons, así que el state persiste entre tests en el mismo archivo a menos que se restablezca explícitamente
useCartStore.setState()establece el state del store directamente sin disparar acciones, útil para la configuración de testsuseCartStore.getState()lee el state actual de forma síncrona, útil para unit tests a nivel de store
Variaciones
Archivo de utilidades de test reutilizable:
// src/test/utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { ThemeProvider } from "@/context/theme-context";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
}
export function renderWithProviders(
ui: React.ReactElement,
{ theme = "light", ...options }: CustomRenderOptions = {}
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
</QueryClientProvider>
),
...options,
});
}
// Re-export everything from Testing Library
export * from "@testing-library/react";
export { default as userEvent } from "@testing-library/user-event";// In test files, import from your utils instead
import { renderWithProviders, screen, userEvent } from "@/test/utils";Probar Zustand con middleware (persist, devtools):
// Stores with persist middleware need localStorage mocking
beforeEach(() => {
localStorage.clear();
useCartStore.setState({ items: [] });
});Notas de TypeScript
// Typing custom render options
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
// Typing wrapper components
function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}Errores comunes
-
Fugas de state de Zustand entre tests — Los stores de Zustand son singletons a nivel de módulo. El state establecido en un test persiste al siguiente. Solución: Llama a
useCartStore.setState({ items: [] })enbeforeEach. -
Falta el wrapper del provider — Los componentes que usan
useContextlanzan un error si no se encuentra un provider. Solución: Usa siempre un render personalizado que envuelva con los providers requeridos. -
Probar context en aislamiento vs integración — Probar el provider y el consumidor por separado puede pasar por alto bugs de integración. Solución: Pruébalos juntos con el provider real siempre que sea posible.
-
Fugas de QueryClient entre tests — La caché de React Query persiste si reutilizas el mismo QueryClient. Solución: Crea un
QueryClientnuevo en el wrapper para cada test, conretry: falsepara evitar timeouts en los tests. -
Las suscripciones de Zustand no se actualizan en los tests — Si modificas el store fuera de React (p. ej.,
getState().addItem()), los componentes pueden no re-renderizar. Solución: Usaact()alrededor de las modificaciones del store que deban provocar re-renderizados, o establece el state antes de renderizar.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
| Simular el hook de context | Quieres probar un componente en aislamiento sin el provider real | Quieres probar la lógica del provider |
Unit tests con getState() de Zustand | Quieres probar la lógica del store sin renderizar componentes | Necesitas verificar reacciones de la UI ante cambios de state |
| Tests de integración | Quieres probar varios componentes que comparten state juntos | Los unit tests aislados y rápidos son suficientes |
| Inyección de dependencias vía props | El componente puede aceptar valores como props en lugar de leer context | El componente está muy anidado y el prop drilling es poco práctico |
Preguntas frecuentes
¿Cómo creo una función de render personalizada que envuelva componentes en providers?
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>
),
});
}¿Por qué el state de Zustand se filtra entre tests?
Los stores de Zustand son singletons a nivel de módulo. El state establecido en un test persiste al siguiente en el mismo archivo. Restablece siempre el state en beforeEach:
beforeEach(() => {
useCartStore.setState({ items: [] });
});¿Cómo pruebo un store de Zustand sin renderizar un componente?
Usa getState() y setState() directamente:
useCartStore.getState().addItem({ id: "1", name: "W", price: 10 });
expect(useCartStore.getState().items).toHaveLength(1);Trampa: ¿Qué pasa si renderizo un componente que usa useContext sin un provider?
Lanza un error. Envuelve siempre los componentes que dependen de context en su provider requerido, ya sea directamente o mediante un wrapper de render personalizado.
¿Cómo pruebo que un context provider y un consumidor funcionan juntos?
Renderiza el consumidor dentro del provider real e interactúa con él:
renderWithTheme(<ThemeToggle />);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("Switch to light");¿Cómo evito que la caché de React Query se filtre entre tests?
Crea un QueryClient nuevo en el wrapper para cada test con retry: false:
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});¿Cómo pruebo un componente que lee de un store de Zustand?
Establece el state del store antes de renderizar:
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 2 }],
});
render(<CartSummary />);
expect(screen.getByText(/widget x2/i)).toBeInTheDocument();Trampa: ¿Por qué mi componente no re-renderiza cuando modifico el state de Zustand fuera de React?
Modificar el store mediante getState().addItem() fuera de un render de React puede no provocar re-renderizados. Usa act() alrededor de las modificaciones del store, o establece el state antes de renderizar.
¿Cómo tipar las opciones de render personalizado en TypeScript?
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}¿Debo probar el context provider y el consumidor por separado o juntos?
Pruébalos juntos con el provider real siempre que sea posible. Probarlos por separado puede pasar por alto bugs de integración. Simula el hook de context solo si necesitas aislamiento real.
¿Cómo manejo stores de Zustand con middleware persist en los tests?
Limpia localStorage antes de cada test:
beforeEach(() => {
localStorage.clear();
useCartStore.setState({ items: [] });
});¿Puedo reexportar utilidades de Testing Library desde un archivo de utils de test personalizado?
Sí:
// src/test/utils.tsx
export * from "@testing-library/react";
export { default as userEvent } from "@testing-library/user-event";
export { renderWithProviders };Luego importa desde @/test/utils en todos los archivos de test.
Relacionado
- Pruebas de hooks personalizados — probar hooks con context mediante un wrapper de renderHook
- Pruebas de componentes — renderizado de componentes e interacciones
- Simulación en tests — simular módulos y context
- Fundamentos de React Testing Library — conceptos básicos de render y consultas