Pruebas de Context y gestión de state
Prueba componentes que dependen de React Context, stores de Zustand u otros providers de state global.
Busca en todas las páginas de la documentación
Prueba componentes que dependen de React Context, stores de Zustand u otros providers de state global.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
getState() y setState()wrapper en render() envuelve el componente de prueba en providers sin necesidad de hacerlo en cada testuseCartStore.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 storeArchivo 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: [] });
});// 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>;
}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: [] }) en beforeEach.
Falta el wrapper del provider - Los componentes que usan useContext lanzan 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 QueryClient nuevo en el wrapper para cada test, con retry: false para 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: Usa act() alrededor de las modificaciones del store que deban provocar re-renderizados, o establece el state antes de renderizar.
| 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 |
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>
),
});
}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: [] });
});Usa getState() y setState() directamente:
useCartStore.getState().addItem({ id: "1", name: "W", price: 10 });
expect(useCartStore.getState().items).toHaveLength(1);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.
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");Crea un QueryClient nuevo en el wrapper para cada test con retry: false:
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});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();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.
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}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.
Limpia localStorage antes de cada test:
beforeEach(() => {
localStorage.clear();
useCartStore.setState({ items: [] });
});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.
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥