Probar stores de Zustand
Receta
Prueba stores de Zustand restableciendo el state entre tests, usando getState() y setState() para aserciones directas, y simulando stores en tests de componentes.
// stores/counter-store.ts
import { create } from "zustand";
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
reset: () => set({ count: 0 }),
}));// __tests__/counter-store.test.ts
import { useCounterStore } from "@/stores/counter-store";
// Restablecer el store antes de cada test
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});
describe("CounterStore", () => {
it("starts at zero", () => {
expect(useCounterStore.getState().count).toBe(0);
});
it("increments", () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});
it("decrements", () => {
useCounterStore.setState({ count: 5 });
useCounterStore.getState().decrement();
expect(useCounterStore.getState().count).toBe(4);
});
it("resets to zero", () => {
useCounterStore.setState({ count: 42 });
useCounterStore.getState().reset();
expect(useCounterStore.getState().count).toBe(0);
});
});Ejemplo funcional
// stores/todo-store.ts
import { create } from "zustand";
interface Todo {
id: string;
text: string;
done: boolean;
}
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
getActiveTodos: () => Todo[];
getCompletedCount: () => number;
}
export const useTodoStore = create<TodoStore>((set, get) => ({
todos: [],
addTodo: (text) =>
set((s) => ({
todos: [...s.todos, { id: crypto.randomUUID(), text, done: false }],
})),
toggleTodo: (id) =>
set((s) => ({
todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
removeTodo: (id) =>
set((s) => ({ todos: s.todos.filter((t) => t.id !== id) })),
getActiveTodos: () => get().todos.filter((t) => !t.done),
getCompletedCount: () => get().todos.filter((t) => t.done).length,
}));// __tests__/todo-store.test.ts
import { useTodoStore } from "@/stores/todo-store";
import { act } from "@testing-library/react";
const initialState = useTodoStore.getState();
beforeEach(() => {
useTodoStore.setState(initialState, true); // true = replace
});
describe("TodoStore", () => {
it("adds a todo", () => {
act(() => {
useTodoStore.getState().addTodo("Comprar leche");
});
const { todos } = useTodoStore.getState();
expect(todos).toHaveLength(1);
expect(todos[0].text).toBe("Comprar leche");
expect(todos[0].done).toBe(false);
});
it("toggles a todo", () => {
// Precargar el state
useTodoStore.setState({
todos: [{ id: "1", text: "Prueba", done: false }],
});
act(() => {
useTodoStore.getState().toggleTodo("1");
});
expect(useTodoStore.getState().todos[0].done).toBe(true);
});
it("removes a todo", () => {
useTodoStore.setState({
todos: [
{ id: "1", text: "A", done: false },
{ id: "2", text: "B", done: true },
],
});
act(() => {
useTodoStore.getState().removeTodo("1");
});
expect(useTodoStore.getState().todos).toHaveLength(1);
expect(useTodoStore.getState().todos[0].id).toBe("2");
});
it("computes active todos", () => {
useTodoStore.setState({
todos: [
{ id: "1", text: "A", done: false },
{ id: "2", text: "B", done: true },
{ id: "3", text: "C", done: false },
],
});
expect(useTodoStore.getState().getActiveTodos()).toHaveLength(2);
expect(useTodoStore.getState().getCompletedCount()).toBe(1);
});
});// __tests__/todo-component.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { useTodoStore } from "@/stores/todo-store";
import { TodoList } from "@/components/todo-list";
// Restablecer antes de cada test
beforeEach(() => {
useTodoStore.setState({ todos: [] }, true);
});
describe("TodoList component", () => {
it("renders todos from the store", () => {
useTodoStore.setState({
todos: [
{ id: "1", text: "Escribir tests", done: false },
{ id: "2", text: "Publicar funcionalidad", done: true },
],
});
render(<TodoList />);
expect(screen.getByText("Escribir tests")).toBeInTheDocument();
expect(screen.getByText("Publicar funcionalidad")).toBeInTheDocument();
});
it("adds a todo via the form", async () => {
render(<TodoList />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add/i });
fireEvent.change(input, { target: { value: "Nuevo todo" } });
fireEvent.click(button);
expect(useTodoStore.getState().todos).toHaveLength(1);
expect(useTodoStore.getState().todos[0].text).toBe("Nuevo todo");
});
});Análisis profundo
Cómo funciona
- Los stores de Zustand exponen
getState()ysetState()en el propio hook, lo que permite inspeccionar y manipular el state directamente sin renderizar componentes. setState(state, replace)conreplace: truereemplaza todo el state en lugar de fusionarlo. Esto es esencial para restablecer el state limpiamente en los tests.- Las acciones del store son funciones simples accesibles mediante
getState(). Llámalas directamente en los tests. - En tests de componentes, se usa el store real por defecto. Precarga el state con
setStateantes de renderizar. - Como los stores son singletons, el state persiste entre tests a menos que se restablezca explícitamente.
Variaciones
Utilidad global de restablecimiento:
// test-utils/reset-stores.ts
import { useCounterStore } from "@/stores/counter-store";
import { useTodoStore } from "@/stores/todo-store";
const stores = [
{ store: useCounterStore, initial: { count: 0 } },
{ store: useTodoStore, initial: { todos: [] } },
];
export function resetAllStores() {
stores.forEach(({ store, initial }) => {
(store as any).setState(initial, true);
});
}
// En setupTests.ts
beforeEach(() => resetAllStores());Simular un store por completo:
// __tests__/header.test.tsx
import { vi } from "vitest";
vi.mock("@/stores/auth-store", () => ({
useAuthStore: vi.fn((selector) =>
selector({
user: { name: "Test User", role: "admin" },
token: "fake-token",
isAuthenticated: () => true,
logout: vi.fn(),
})
),
}));
import { render, screen } from "@testing-library/react";
import { Header } from "@/components/header";
it("shows admin panel link for admin users", () => {
render(<Header />);
expect(screen.getByText("Panel de administración")).toBeInTheDocument();
});Probar acciones async:
import { useTodoStore } from "@/stores/todo-store";
// Simular fetch
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([{ id: "1", text: "Desde la API", done: false }]),
} as Response)
);
it("fetches todos from API", async () => {
await useTodoStore.getState().fetchTodos();
expect(fetch).toHaveBeenCalledWith("/api/todos");
expect(useTodoStore.getState().todos).toHaveLength(1);
expect(useTodoStore.getState().todos[0].text).toBe("Desde la API");
});Probar suscripciones:
it("calls subscriber on state change", () => {
const listener = vi.fn();
const unsub = useCounterStore.subscribe(listener);
useCounterStore.getState().increment();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ count: 1 }),
expect.objectContaining({ count: 0 })
);
unsub();
});Notas de TypeScript
getState()devuelve el state tipado completo, incluidas las acciones.setStateaceptaPartial<State>o(s: State) => Partial<State>.- Simula los selectores del store proporcionando una función que coincida con la firma del selector.
// Mock con tipado seguro
const mockState: ReturnType<typeof useAuthStore.getState> = {
user: { name: "Test", email: "test@test.com", role: "admin" },
token: "fake",
login: vi.fn(),
logout: vi.fn(),
isAuthenticated: () => true,
};Trampas comunes
- Los stores de Zustand son singletons en el proceso de test. Si no restableces el state entre tests, el state de un test se filtra al siguiente.
setState({}, true)conreplace: trueelimina todas las propiedades, incluidas las acciones. Pasa el state inicial completo, incluidas las referencias a las acciones, o simplemente restablece las propiedades de datos.- Simular stores con
vi.mockes a nivel de módulo y se aplica a todos los tests del archivo. Usavi.fn().mockReturnValue()por test para distintos states simulados. - Puede ser necesario
act()de@testing-library/reactcuando los cambios del store provocan actualizaciones de state de React en componentes renderizados. - Las acciones async requieren
awaiten los tests. Usaawait store.getState().asyncAction()owaitForde testing-library. - Si usas el middleware
persist, los tests pueden intentar acceder a localStorage. Simúlalo o usa un adaptador de almacenamiento en memoria en los tests.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| getState/setState directo | No requiere renderizado, tests unitarios rápidos | No prueba la integración con componentes |
| Tests de componentes con store real | Prueba la integración completa | Más lento, más configuración |
| Store simulado (vi.mock) | Aísla el componente de la lógica del store | El mock puede divergir del store real |
| createStore por test | Aislamiento total, sin necesidad de restablecer | Más código repetitivo |
Preguntas frecuentes
¿Cómo restableces un store de Zustand entre tests?
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});- Llama a
setStatecon el state inicial antes de cada test. - Usa
setState(initialState, true)conreplace: truepara un restablecimiento completo.
¿Por qué debes restablecer los stores entre tests?
- Los stores de Zustand son singletons en el proceso de test.
- Sin restablecer, el state de un test se filtra al siguiente y provoca tests inestables.
¿Cómo pruebas acciones del store sin renderizar un componente?
it("increments", () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});- Usa
getState()para llamar a las acciones y leer el state directamente.
¿Cómo precargas el state del store antes de un test de componente?
- Llama a
setStateantes derender()para precargar el store con datos de test. - El componente leerá el state precargado cuando se monte.
¿Cómo simulas un store de Zustand completo con Vitest?
vi.mock("@/stores/auth-store", () => ({
useAuthStore: vi.fn((selector) =>
selector({
user: { name: "Test User" },
logout: vi.fn(),
})
),
}));- Usa
vi.mocka nivel de módulo. El mock se aplica a todos los tests del archivo.
Trampa: ¿Qué ocurre si usas setState({}, true) con replace: true y un objeto vacío?
- Elimina todas las propiedades, incluidas las acciones.
- Pasa siempre el state inicial completo (incluidas las referencias a las acciones) cuando uses
replace: true. - O simplemente restablece las propiedades de datos sin
replacepara mantener las acciones intactas.
Trampa: ¿Necesitas act() al probar cambios del store en componentes renderizados?
- Sí. Los cambios del store provocan actualizaciones de state de React, así que envuélvelos en
act()de@testing-library/react. - Para tests directos del store (sin renderizado),
act()no siempre es necesario.
¿Cómo pruebas acciones async?
it("fetches todos", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([{ id: "1", text: "Test" }]),
} as Response)
);
await useTodoStore.getState().fetchTodos();
expect(useTodoStore.getState().todos).toHaveLength(1);
});- Simula
fetchy luego usaawaiten la acción async directamente.
¿Cómo pruebas suscripciones del store?
it("calls subscriber on change", () => {
const listener = vi.fn();
const unsub = useCounterStore.subscribe(listener);
useCounterStore.getState().increment();
expect(listener).toHaveBeenCalledTimes(1);
unsub();
});¿Cómo tipas un state simulado del store en TypeScript?
const mockState: ReturnType<typeof useAuthStore.getState> = {
user: { name: "Test", email: "t@t.com", role: "admin" },
token: "fake",
login: vi.fn(),
logout: vi.fn(),
isAuthenticated: () => true,
};- Usa
ReturnType<typeof store.getState>para un mock con tipado seguro.
¿Qué ocurre si el store usa el middleware persist y los tests intentan acceder a localStorage?
- Los tests pueden fallar o producir un state inesperado si se accede a localStorage.
- Simula localStorage o usa un adaptador de almacenamiento en memoria en la configuración de tus tests.