| | |

Zustand 12 ⚛️ Testing Stores — Writing unit tests for actions & selectors

⏱️ Estimated read: 14 min | |

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

PatternPurpose
createStore() factoryFresh store per test
beforeEach resetRestore the shared store
setState(initial, true)Replace the entire state
getState()Read the state after the action

Action Testing

StepCode
Get the actionstore.getState().action()
Read the statestore.getState().field
Assertexpect(state).toBe(expected)

Selector Testing

StepCode
Extract the selectorexport const selectX = (state) => ...
Mock the stateconst state = { ... } as State
Call the selectorselectX(state)
Assertexpect(result).toHaveLength(2)

Reset Methods

MethodBehavior
setState(partial)Merge the partial into the state
setState(initial, true)Replace the entire state
Factory functionNew 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

PitfallWhy It HappensFix
Tests are order-dependentShared store across testsFresh store per test
State bleeds between testsNo resetsetState(initial, true)
Actions are lost after the resetReset with mergeReset with the full initial state
Selector test failsMocked state is incompleteProvide the full shape
Cannot call the hook outside a renderThe hook is not a plain functionTest 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

ItemValue
Test targetActions and selectors
IsolationFactory function or beforeEach reset
Reset methodsetState(initial, true)
Read statestore.getState()
Call actionstore.getState().action()
Selector testPure function with a mocked state
Factoryexport const createStore = () => create(...)
Test frameworkJest, Vitest

Key takeaways:

  • The factory function is the cleanest isolation. The createStore function returns a new store, and each test calls it to get its own store. The tests are isolated by construction, and the beforeEach reset is not needed .
  • The setState(initial, true) reset replaces the state. The true argument 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(). The store.getState().increment() call runs the action, and the store.getState().count read is the assertion target. The action’s return value is undefined, 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().count read 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 State tells TypeScript that the mock is valid. A missing field produces an undefined and 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.


Was this helpful?


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!