Zustand 12 ⚛️ Testing Stores — Writing unit tests for actions & selectors
A Zustand store is a plain JavaScript object with functions, which makes it simpler to test than a React component. The store does not need a provider, does not need to be rendered, and does not depend on the React lifecycle. The test calls the store’s actions directly, reads the state with getState(), and asserts on the resulting values. The previous chapters covered the store’s behavior in the application; this chapter covers the test harness that exercises the actions and the selectors in isolation.
The test has three parts: a fresh store for every test, the action calls that mutate the state, and the assertions on getState() and the selectors. The fresh store is the isolation mechanism, because a store created at module scope is shared across the tests and the state from one test leaks into the next. The setState method is the mechanism for resetting the store, and the getState method is the mechanism for reading the state after the action.
Key point: The store is created by the create function at module scope, and the test imports that store. To isolate the tests, the beforeEach hook calls useStore.setState(initialState, true) to reset the state before every test. The true argument replaces the state instead of merging, which removes the actions and the state from the previous test. The alternative is to export a createStore factory function and call it in each test, which is the cleaner approach for the larger stores.
Why testing stores requires explicit isolation
The shared-state problem.
The create function returns a hook, and the store behind the hook is created once when the module is imported. Every test in the file shares the same store, and the state from the first test is visible to the second. The beforeEach reset is the mechanism that restores the store to its initial state, and the true argument to setState is the mechanism that replaces the state instead of merging it . Without the reset, the tests are order-dependent, and the failures are difficult to diagnose.
The action-testing problem.
The actions are functions in the store, and they are called with useStore.getState().increment() or with the action reference from the store. The test calls the action, reads the state with getState(), and asserts on the result. The action’s return value is not the state; it is whatever the function returns, which is usually undefined. The state is the observable, and the getState() call is the mechanism that reads it .
The selector-testing problem.
The selectors are plain functions, and they can be tested without the store if they are extracted. The selectors that are defined inline in the useStore call must be tested through the hook or through the getState() call. The extracted selectors are the easier test target, because they are pure functions that take the state and return the value . The test calls the selector with a mocked state, and the assertion checks the return value.
a. Testing the actions
The action test calls the action, reads the state, and asserts on the result. The store is reset before every test.
// stores/counter.ts
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
const initialState = { count: 0 }
export const useCounterStore = create<CounterState>()((set) => ({
...initialState,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set(initialState),
}))
The store has the increment, decrement, and reset actions, and the initialState is exported so that the test can use it in the reset. The test file imports the store and the initialState.
// stores/counter.test.ts
import { useCounterStore } from './counter'
describe('counter store', () => {
beforeEach(() => {
// Reset the store before each test
useCounterStore.setState({ count: 0 })
})
test('increments the count', () => {
useCounterStore.getState().increment()
expect(useCounterStore.getState().count).toBe(1)
})
test('decrements the count', () => {
useCounterStore.getState().decrement()
expect(useCounterStore.getState().count).toBe(-1)
})
test('resets the count', () => {
useCounterStore.getState().increment()
useCounterStore.getState().increment()
useCounterStore.getState().reset()
expect(useCounterStore.getState().count).toBe(0)
})
})
The beforeEach calls useCounterStore.setState({ count: 0 }) to reset the count before every test. The getState() call reads the current state, and the increment() call is the action. The assertions check the count after the action . The three tests are independent because the reset runs before each one.
The setState with the true argument is the alternative that replaces the entire state, which is the correct choice when the state has more fields than the count.
beforeEach(() => {
useCounterStore.setState(initialState, true)
})
The setState(initialState, true) replaces the state with the initialState, and it removes the actions that were in the previous state. The store’s create call must re-add the actions, or the actions are lost. The reset with the true argument is the correct choice when the initialState includes the actions, which is the pattern that the store’s create call uses.
b. Testing the selectors
The selectors are the functions that read a slice of the state. The extracted selectors are the easiest to test, because they are pure functions.
// stores/todos.ts
import { create } from 'zustand'
interface Todo {
id: number
title: string
done: boolean
}
interface TodoState {
todos: Todo[]
filter: 'all' | 'active' | 'completed'
addTodo: (title: string) => void
setFilter: (filter: TodoState['filter']) => void
}
export const useTodoStore = create<TodoState>()((set) => ({
todos: [],
filter: 'all',
addTodo: (title) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), title, done: false }],
})),
setFilter: (filter) => set({ filter }),
}))
// Extracted selector: pure function
export const selectFilteredTodos = (state: TodoState) => {
switch (state.filter) {
case 'active':
return state.todos.filter((todo) => !todo.done)
case 'completed':
return state.todos.filter((todo) => todo.done)
default:
return state.todos
}
}
The selectFilteredTodos selector takes the state and returns the filtered todos. It is a pure function, and the test calls it with a mocked state.
// stores/todos.test.ts
import { selectFilteredTodos } from './todos'
describe('selectFilteredTodos', () => {
const todos = [
{ id: 1, title: 'A', done: false },
{ id: 2, title: 'B', done: true },
{ id: 3, title: 'C', done: false },
]
test('returns all todos when the filter is all', () => {
const state = { todos, filter: 'all' } as TodoState
expect(selectFilteredTodos(state)).toHaveLength(3)
})
test('returns only the active todos', () => {
const state = { todos, filter: 'active' } as TodoState
const result = selectFilteredTodos(state)
expect(result).toHaveLength(2)
expect(result.every((todo) => !todo.done)).toBe(true)
})
test('returns only the completed todos', () => {
const state = { todos, filter: 'completed' } as TodoState
const result = selectFilteredTodos(state)
expect(result).toHaveLength(1)
expect(result[0].done).toBe(true)
})
})
The test creates a mocked state with the todos and the filter, and it calls the selector with the state. The assertions check the length and the properties of the result . The test does not need the store, because the selector is a pure function.
The inline selectors are tested through the store’s getState call.
test('reads the count through the selector', () => {
useCounterStore.setState({ count: 5 })
const count = useCounterStore.getState().count
expect(count).toBe(5)
})
The getState call returns the state, and the property access is the equivalent of the inline selector . The test is the same as the action test, except that the selector is the property read.
c. Testing the store with a factory function
The cleaner approach for the larger stores is to export a factory function that creates the store, and to call the factory in each test. The factory returns a new store, and the tests are isolated by construction.
// stores/counter.ts
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
}
export const createCounterStore = () =>
create<CounterState>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// The default store for the application
export const useCounterStore = createCounterStore()
The createCounterStore function returns a new store, and the useCounterStore is the application’s store. The test calls the factory to get a fresh store for each test.
// stores/counter.test.ts
import { createCounterStore } from './counter'
describe('counter store', () => {
test('increments the count', () => {
// Fresh store per test
const store = createCounterStore()
store.getState().increment()
expect(store.getState().count).toBe(1)
})
test('resets the count', () => {
const store = createCounterStore()
store.getState().increment()
store.getState().increment()
expect(store.getState().count).toBe(2)
})
})
The factory function returns a new store for each test, and the test calls the action and reads the state . The tests are isolated because each one has its own store, and the beforeEach reset is not needed.
The factory pattern is the recommended approach for the stores that need the isolation, because it eliminates the shared-state problem entirely. The createCounterStore function is also useful in the tests that need to set up the state before the test, because the test can create the store, call the actions, and then render the component.
Complete Example Session
// ============================================
// PART 1: THE STORE FACTORY
// ============================================
import { create } from 'zustand'
interface Todo {
id: number
title: string
done: boolean
}
interface TodoState {
todos: Todo[]
filter: 'all' | 'active' | 'completed'
addTodo: (title: string) => void
toggleTodo: (id: number) => void
setFilter: (filter: TodoState['filter']) => void
}
export const createTodoStore = () =>
create<TodoState>()((set) => ({
todos: [],
filter: 'all',
addTodo: (title) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), title, done: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
})),
setFilter: (filter) => set({ filter }),
}))
// ============================================
// PART 2: THE SELECTOR
// ============================================
export const selectFilteredTodos = (state: TodoState) => {
switch (state.filter) {
case 'active':
return state.todos.filter((todo) => !todo.done)
case 'completed':
return state.todos.filter((todo) => todo.done)
default:
return state.todos
}
}
// ============================================
// PART 3: THE ACTION TESTS
// ============================================
import { createTodoStore } from './todos'
describe('todo actions', () => {
test('adds a todo', () => {
const store = createTodoStore()
store.getState().addTodo('Buy milk')
expect(store.getState().todos).toHaveLength(1)
expect(store.getState().todos[0].title).toBe('Buy milk')
})
test('toggles a todo', () => {
const store = createTodoStore()
store.getState().addTodo('Buy milk')
const id = store.getState().todos[0].id
store.getState().toggleTodo(id)
expect(store.getState().todos[0].done).toBe(true)
})
})
// ============================================
// PART 4: THE SELECTOR TESTS
// ============================================
describe('selectFilteredTodos', () => {
const todos = [
{ id: 1, title: 'A', done: false },
{ id: 2, title: 'B', done: true },
]
test('returns all todos', () => {
const state = { todos, filter: 'all' } as TodoState
expect(selectFilteredTodos(state)).toHaveLength(2)
})
test('returns active todos', () => {
const state = { todos, filter: 'active' } as TodoState
expect(selectFilteredTodos(state)).toHaveLength(1)
})
})
// ============================================
// PART 5: THE BEFORE-EACH RESET
// ============================================
describe('with the shared store', () => {
const useStore = createTodoStore()
beforeEach(() => {
useStore.setState({ todos: [], filter: 'all' }, true)
})
test('adds a todo', () => {
useStore.getState().addTodo('Test')
expect(useStore.getState().todos).toHaveLength(1)
})
})
The five parts demonstrate the testing surface: the store factory, the extracted selector, the action tests, the selector tests, and the beforeEach reset. The critical distinction is between Parts 3 and 5: the factory creates a fresh store per test, and the beforeEach reset restores the shared store to its initial state.
Quick Reference
Test Setup
| Pattern | Purpose |
|---|---|
createStore() factory | Fresh store per test |
beforeEach reset | Restore the shared store |
setState(initial, true) | Replace the entire state |
getState() | Read the state after the action |
Action Testing
| Step | Code |
|---|---|
| Get the action | store.getState().action() |
| Read the state | store.getState().field |
| Assert | expect(state).toBe(expected) |
Selector Testing
| Step | Code |
|---|---|
| Extract the selector | export const selectX = (state) => ... |
| Mock the state | const state = { ... } as State |
| Call the selector | selectX(state) |
| Assert | expect(result).toHaveLength(2) |
Reset Methods
| Method | Behavior |
|---|---|
setState(partial) | Merge the partial into the state |
setState(initial, true) | Replace the entire state |
| Factory function | New store per test |
Best Practices
✅ Do This:
// Use the factory for isolation
const store = createTodoStore() # ✅
// Reset the shared store before each test
useStore.setState(initialState, true) # ✅
// Read the state with getState
expect(store.getState().count).toBe(1) # ✅
// Test the extracted selectors as pure functions
expect(selectFilteredTodos(state)).toHaveLength(2) # ✅
// Call the action through getState
store.getState().increment() # ✅
// Use the factory when the test needs to set up the state
const store = createTodoStore(); store.getState().addTodo('X') # ✅
❌ Don’t Do This:
// Rely on the test order
test('first', () => { store.getState().increment() })
test('second', () => { expect(store.getState().count).toBe(0) }) # ❌
// Reset with the merge instead of replace
useStore.setState({ count: 0 }) // Leaves other fields # ❌
// Test the inline selector through the hook without a render
useStore((s) => s.count) // Hooks cannot be called # ❌
// Assert on the action's return value
const result = store.getState().increment() // Returns undefined # ❌
// Create the store at module scope in the test file
const store = createTodoStore() // Shared across tests # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Tests are order-dependent | Shared store across tests | Fresh store per test |
| State bleeds between tests | No reset | setState(initial, true) |
| Actions are lost after the reset | Reset with merge | Reset with the full initial state |
| Selector test fails | Mocked state is incomplete | Provide the full shape |
| Cannot call the hook outside a render | The hook is not a plain function | Test the extracted selector |
Real-World Examples
1. Factory function
export const createStore = () => create<State>()((set) => ({ ... }))
2. Fresh store per test
const store = createStore()
3. Reset the shared store
beforeEach(() => useStore.setState(initialState, true))
4. Action test
store.getState().increment(); expect(store.getState().count).toBe(1)
5. Selector test
expect(selectFilteredTodos(state)).toHaveLength(2)
6. Mocked state
const state = { todos: [...], filter: 'active' } as State
7. Set up the state before the test
const store = createStore(); store.getState().addTodo('X')
8. Read the state after the action
expect(store.getState().todos[0].title).toBe('X')
9. Toggle test
store.getState().toggleTodo(id); expect(store.getState().todos[0].done).toBe(true)
10. Filter test
const state = { todos, filter: 'completed' } as State; expect(selectFilteredTodos(state)).toHaveLength(1)
Visual
The Factory Pattern
┌──────────────────────────────────────────────────────────────┐
│ THE FACTORY RETURNS A NEW STORE PER TEST │
│ │
│ export const createStore = () => create<State>()(...) │
│ │ │
│ ├── Test 1 → new store → own state │
│ ├── Test 2 → new store → own state │
│ └── Test 3 → new store → own state │
│ │
│ The tests are isolated by construction. │
│ The reset is not needed. │
└──────────────────────────────────────────────────────────────┘
The Reset Pattern
┌──────────────────────────────────────────────────────────────┐
│ THE SHARED STORE NEEDS A RESET │
│ │
│ beforeEach(() => { │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ useStore.setState(initialState, true) │ │
│ │ → replaces the entire state │ │
│ │ → removes the leftover fields │ │
│ └────────────────────────────────────────────────────────┘ │
│ }) │
│ │
│ The true argument is the replace flag. │
│ Without it, the setState merges and the old fields remain. │
└──────────────────────────────────────────────────────────────┘
The Selector as a Pure Function
┌──────────────────────────────────────────────────────────────┐
│ THE EXTRACTED SELECTOR IS TESTABLE IN ISOLATION │
│ │
│ const state = { todos: [...], filter: 'active' } as State │
│ │ │
│ ▼ │
│ selectFilteredTodos(state) │
│ │ │
│ ▼ │
│ → the filtered array │
│ │
│ No store, no hook, no render. │
│ The selector is a pure function of the state. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Test target | Actions and selectors |
| Isolation | Factory function or beforeEach reset |
| Reset method | setState(initial, true) |
| Read state | store.getState() |
| Call action | store.getState().action() |
| Selector test | Pure function with a mocked state |
| Factory | export const createStore = () => create(...) |
| Test framework | Jest, Vitest |
Key takeaways:
- The factory function is the cleanest isolation. The
createStorefunction returns a new store, and each test calls it to get its own store. The tests are isolated by construction, and thebeforeEachreset is not needed . - The
setState(initial, true)reset replaces the state. Thetrueargument is the replace flag, and it removes the fields from the previous test. The merge form leaves the old fields, and the tests become order-dependent. - The actions are tested through
getState(). Thestore.getState().increment()call runs the action, and thestore.getState().countread is the assertion target. The action’s return value isundefined, and the state is the observable. - The extracted selectors are pure functions. The
selectFilteredTodos(state)selector takes the state and returns the value, and the test calls it with a mocked state. The test does not need the store or the hook. - The inline selectors are tested through the state read. The
store.getState().countread is the equivalent of the inline selector, and the action test is the same as the selector test for the inline form. - The mocked state must have the full shape. The selector test creates a state object with the fields that the selector reads, and the type assertion
as Statetells TypeScript that the mock is valid. A missing field produces anundefinedand a failing assertion.
Remember: A Zustand store is a plain object, and the tests treat it as one. The factory function creates a fresh store per test, the getState and setState methods read and write the state, and the actions are the functions that mutate it. The extracted selectors are pure functions, and the mocked state is the input. The result is a test suite that exercises the actions and the selectors without the React lifecycle, the provider, or the render.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!