React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandtestingjestvitestmocking

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() and setState() on the hook itself, enabling direct state inspection and manipulation without rendering components.
  • setState(state, replace) with replace: true replaces 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 setState before 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.
  • setState accepts Partial<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) with replace: true removes all properties including actions. Pass the full initial state including action references, or just reset the data properties.
  • Mocking stores with vi.mock is module-level and applies to all tests in the file. Use vi.fn().mockReturnValue() per test for different mock states.
  • act() from @testing-library/react may be needed when store changes trigger React state updates in rendered components.
  • Async actions require awaiting in tests. Use await store.getState().asyncAction() or waitFor from testing-library.
  • If you use persist middleware, tests may try to access localStorage. Mock it or use an in-memory storage adapter in tests.

Alternatives

ApproachProsCons
Direct getState/setStateNo rendering needed, fast unit testsDoes not test component integration
Component tests with real storeTests full integrationSlower, more setup
Mocked store (vi.mock)Isolates component from store logicMock can diverge from real store
createStore per testFull isolation, no reset neededMore boilerplate

FAQs

How do you reset a Zustand store between tests?
beforeEach(() => {
  useCounterStore.setState({ count: 0 });
});
  • Call setState with the initial state before each test.
  • Use setState(initialState, true) with replace: true for 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 setState before render() 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.mock at 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 replace to 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, then await the 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.