Testing Zustand Stores
Recipe
Test Zustand stores by resetting state between tests, using getState() and setState() for direct assertions, and mocking stores in component tests.
// 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";
// Reset store before each 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);
});
});Working Example
// 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("Buy milk");
});
const { todos } = useTodoStore.getState();
expect(todos).toHaveLength(1);
expect(todos[0].text).toBe("Buy milk");
expect(todos[0].done).toBe(false);
});
it("toggles a todo", () => {
// Seed state
useTodoStore.setState({
todos: [{ id: "1", text: "Test", 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";
// Reset before each test
beforeEach(() => {
useTodoStore.setState({ todos: [] }, true);
});
describe("TodoList component", () => {
it("renders todos from the store", () => {
useTodoStore.setState({
todos: [
{ id: "1", text: "Write tests", done: false },
{ id: "2", text: "Ship feature", done: true },
],
});
render(<TodoList />);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(screen.getByText("Ship feature")).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: "New todo" } });
fireEvent.click(button);
expect(useTodoStore.getState().todos).toHaveLength(1);
expect(useTodoStore.getState().todos[0].text).toBe("New todo");
});
});Deep Dive
How It Works
- Zustand stores expose
getState()andsetState()on the hook itself, enabling direct state inspection and manipulation without rendering components. setState(state, replace)withreplace: truereplaces the entire state instead of merging. This is essential for clean test resets.- Store actions are plain functions accessible via
getState(). Call them directly in tests. - In component tests, the real store is used by default. Pre-set state with
setStatebefore rendering. - Since stores are singletons, state persists across tests unless explicitly reset.
Variations
Global reset utility:
// 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);
});
}
// In setupTests.ts
beforeEach(() => resetAllStores());Mocking a store entirely:
// __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("Admin Panel")).toBeInTheDocument();
});Testing async actions:
import { useTodoStore } from "@/stores/todo-store";
// Mock fetch
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([{ id: "1", text: "From 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("From API");
});Testing subscriptions:
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();
});TypeScript Notes
getState()returns the full typed state including actions.setStateacceptsPartial<State>or(s: State) => Partial<State>.- Mock store selectors by providing a function that matches the selector signature.
// Type-safe mock
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,
};Gotchas
- Zustand stores are singletons in the test process. If you do not reset state between tests, state from one test leaks into the next.
setState({}, true)withreplace: trueremoves all properties including actions. Pass the full initial state including action references, or just reset the data properties.- Mocking stores with
vi.mockis module-level and applies to all tests in the file. Usevi.fn().mockReturnValue()per test for different mock states. act()from@testing-library/reactmay be needed when store changes trigger React state updates in rendered components.- Async actions require awaiting in tests. Use
await store.getState().asyncAction()orwaitForfrom testing-library. - If you use
persistmiddleware, tests may try to access localStorage. Mock it or use an in-memory storage adapter in tests.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Direct getState/setState | No rendering needed, fast unit tests | Does not test component integration |
| Component tests with real store | Tests full integration | Slower, more setup |
| Mocked store (vi.mock) | Isolates component from store logic | Mock can diverge from real store |
| createStore per test | Full isolation, no reset needed | More boilerplate |
FAQs
How do you reset a Zustand store between tests?
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});- Call
setStatewith the initial state before each test. - Use
setState(initialState, true)withreplace: truefor a full reset.
Why must you reset stores between tests?
- Zustand stores are singletons in the test process.
- Without resetting, state from one test leaks into the next, causing flaky tests.
How do you test store actions without rendering a component?
it("increments", () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});- Use
getState()to call actions and read state directly.
How do you pre-set store state before a component test?
- Call
setStatebeforerender()to seed the store with test data. - The component will read from the pre-set state when it mounts.
How do you mock an entire Zustand store with Vitest?
vi.mock("@/stores/auth-store", () => ({
useAuthStore: vi.fn((selector) =>
selector({
user: { name: "Test User" },
logout: vi.fn(),
})
),
}));- Use
vi.mockat the module level. The mock applies to all tests in the file.
Gotcha: What happens if you use setState({}, true) with replace: true and an empty object?
- It removes all properties including actions.
- Always pass the full initial state (including action references) when using
replace: true. - Or just reset data properties without
replaceto keep actions intact.
Gotcha: Do you need act() when testing store changes in rendered components?
- Yes. Store changes trigger React state updates, so wrap them in
act()from@testing-library/react. - For direct store tests (no rendering),
act()is not always required.
How do you test async actions?
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);
});- Mock
fetch, thenawaitthe async action directly.
How do you test store subscriptions?
it("calls subscriber on change", () => {
const listener = vi.fn();
const unsub = useCounterStore.subscribe(listener);
useCounterStore.getState().increment();
expect(listener).toHaveBeenCalledTimes(1);
unsub();
});How do you type a mock store state in 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,
};- Use
ReturnType<typeof store.getState>for a type-safe mock.
What if the store uses persist middleware and tests try to access localStorage?
- Tests may fail or produce unexpected state if localStorage is accessed.
- Mock localStorage or use an in-memory storage adapter in your test setup.