Testing Zustand Stores: Actions, Selectors, Middleware, and Immer with Vitest
Zustand is a small, fast state management library with a minimal API. Its stores are plain JavaScript objects with actions — which makes them straightforward to test. The main challenges are resetting state between tests, testing middleware like persist and immer, and testing stores integrated with React components.
Setup
npm install zustand
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domBasic Store Testing
Define a store and test its actions directly:
// stores/useCartStore.ts
import { create } from 'zustand'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartStore {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
total: () => number
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => {
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
})
},
removeItem: (id) => {
set((state) => ({ items: state.items.filter((i) => i.id !== id) }))
},
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
get().removeItem(id)
return
}
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
}))
},
clearCart: () => set({ items: [] }),
total: () => {
return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
}))// stores/useCartStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from './useCartStore'
// Reset store state before each test
beforeEach(() => {
useCartStore.setState({ items: [] })
})
const headphones = { id: 'p1', name: 'Headphones', price: 79.99 }
const hub = { id: 'p2', name: 'USB-C Hub', price: 39.99 }
describe('useCartStore', () => {
it('starts with an empty cart', () => {
expect(useCartStore.getState().items).toHaveLength(0)
})
it('adds a new item to the cart', () => {
useCartStore.getState().addItem(headphones)
const items = useCartStore.getState().items
expect(items).toHaveLength(1)
expect(items[0].name).toBe('Headphones')
expect(items[0].quantity).toBe(1)
})
it('increments quantity when adding an existing item', () => {
useCartStore.getState().addItem(headphones)
useCartStore.getState().addItem(headphones)
const items = useCartStore.getState().items
expect(items).toHaveLength(1)
expect(items[0].quantity).toBe(2)
})
it('removes an item from the cart', () => {
useCartStore.getState().addItem(headphones)
useCartStore.getState().addItem(hub)
useCartStore.getState().removeItem('p1')
const items = useCartStore.getState().items
expect(items).toHaveLength(1)
expect(items[0].id).toBe('p2')
})
it('calculates total correctly', () => {
useCartStore.getState().addItem(headphones)
useCartStore.getState().addItem(headphones) // quantity 2
useCartStore.getState().addItem(hub)
const total = useCartStore.getState().total()
expect(total).toBeCloseTo(79.99 * 2 + 39.99, 2)
})
it('removes item when quantity updated to 0', () => {
useCartStore.getState().addItem(headphones)
useCartStore.getState().updateQuantity('p1', 0)
expect(useCartStore.getState().items).toHaveLength(0)
})
it('clears all items', () => {
useCartStore.getState().addItem(headphones)
useCartStore.getState().addItem(hub)
useCartStore.getState().clearCart()
expect(useCartStore.getState().items).toHaveLength(0)
})
})Key pattern: use useCartStore.getState() for direct store access, and useCartStore.setState() for resetting in beforeEach.
Testing with Immer Middleware
Immer middleware lets you write mutations instead of spreads. The test approach is identical — Immer is transparent to your tests:
// stores/useProfileStore.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface Profile {
id: string
name: string
email: string
preferences: {
notifications: boolean
darkMode: boolean
language: string
}
}
interface ProfileStore {
profile: Profile | null
setProfile: (profile: Profile) => void
updatePreference: <K extends keyof Profile['preferences']>(
key: K,
value: Profile['preferences'][K]
) => void
}
export const useProfileStore = create<ProfileStore>()(
immer((set) => ({
profile: null,
setProfile: (profile) =>
set((state) => {
state.profile = profile
}),
updatePreference: (key, value) =>
set((state) => {
if (state.profile) {
state.profile.preferences[key] = value
}
}),
}))
)// stores/useProfileStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useProfileStore } from './useProfileStore'
const testProfile = {
id: 'u1',
name: 'Alice Chen',
email: 'alice@example.com',
preferences: {
notifications: true,
darkMode: false,
language: 'en',
},
}
beforeEach(() => {
useProfileStore.setState({ profile: null })
})
describe('useProfileStore with Immer', () => {
it('sets profile correctly', () => {
useProfileStore.getState().setProfile(testProfile)
const profile = useProfileStore.getState().profile
expect(profile?.name).toBe('Alice Chen')
expect(profile?.preferences.language).toBe('en')
})
it('updates a single preference without mutating others', () => {
useProfileStore.getState().setProfile(testProfile)
useProfileStore.getState().updatePreference('darkMode', true)
const prefs = useProfileStore.getState().profile?.preferences
expect(prefs?.darkMode).toBe(true)
// Other prefs unchanged
expect(prefs?.notifications).toBe(true)
expect(prefs?.language).toBe('en')
})
it('does nothing if profile is null', () => {
// Should not throw
expect(() =>
useProfileStore.getState().updatePreference('darkMode', true)
).not.toThrow()
})
})Testing Middleware: persist
The persist middleware saves state to localStorage. In tests, mock the storage:
// stores/useSettingsStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface SettingsStore {
theme: 'light' | 'dark' | 'system'
language: string
setTheme: (theme: SettingsStore['theme']) => void
setLanguage: (language: string) => void
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'system',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{ name: 'settings' }
)
)// stores/useSettingsStore.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock localStorage before importing the store
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
import { useSettingsStore } from './useSettingsStore'
beforeEach(() => {
localStorageMock.clear()
// Reset store to initial state
useSettingsStore.setState({ theme: 'system', language: 'en' })
})
describe('useSettingsStore with persist', () => {
it('has default theme as system', () => {
expect(useSettingsStore.getState().theme).toBe('system')
})
it('updates theme and persists to localStorage', () => {
useSettingsStore.getState().setTheme('dark')
expect(useSettingsStore.getState().theme).toBe('dark')
expect(localStorageMock.setItem).toHaveBeenCalled()
})
it('rehydrates from localStorage on store creation', async () => {
// Pre-populate localStorage
localStorageMock.getItem.mockReturnValue(
JSON.stringify({ state: { theme: 'dark', language: 'fr' }, version: 0 })
)
// Re-create the store module to trigger rehydration
vi.resetModules()
const { useSettingsStore: freshStore } = await import('./useSettingsStore')
// Wait for hydration
await new Promise((resolve) => setTimeout(resolve, 10))
expect(freshStore.getState().theme).toBe('dark')
expect(freshStore.getState().language).toBe('fr')
})
})Testing Stores in React Components
For component integration tests, wrap with a provider or use renderHook:
// components/CartSummary.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from '@/stores/useCartStore'
import { CartSummary } from './CartSummary'
beforeEach(() => {
useCartStore.setState({ items: [] })
})
describe('CartSummary component', () => {
it('shows empty cart message', () => {
render(<CartSummary />)
expect(screen.getByText('Your cart is empty')).toBeInTheDocument()
})
it('shows items and total from store', () => {
useCartStore.setState({
items: [
{ id: 'p1', name: 'Headphones', price: 79.99, quantity: 2 },
],
})
render(<CartSummary />)
expect(screen.getByText('Headphones')).toBeInTheDocument()
expect(screen.getByText('×2')).toBeInTheDocument()
expect(screen.getByText(/159\.98/)).toBeInTheDocument()
})
it('removes item when delete button clicked', async () => {
useCartStore.setState({
items: [{ id: 'p1', name: 'Headphones', price: 79.99, quantity: 1 }],
})
render(<CartSummary />)
await userEvent.click(screen.getByRole('button', { name: /remove headphones/i }))
expect(useCartStore.getState().items).toHaveLength(0)
expect(screen.getByText('Your cart is empty')).toBeInTheDocument()
})
})Testing Selectors
Zustand selectors (passed to useStore) optimize re-renders. Test that selectors derive the right values:
// Selector functions
export const selectItemCount = (state: CartStore) =>
state.items.reduce((sum, item) => sum + item.quantity, 0)
export const selectIsInCart = (id: string) => (state: CartStore) =>
state.items.some((item) => item.id === id)import { useCartStore } from './useCartStore'
import { selectItemCount, selectIsInCart } from './useCartStore'
describe('Cart selectors', () => {
beforeEach(() => useCartStore.setState({ items: [] }))
it('selectItemCount returns sum of all quantities', () => {
useCartStore.setState({
items: [
{ id: 'p1', name: 'A', price: 10, quantity: 3 },
{ id: 'p2', name: 'B', price: 20, quantity: 2 },
],
})
const count = selectItemCount(useCartStore.getState())
expect(count).toBe(5)
})
it('selectIsInCart returns true when item is in cart', () => {
useCartStore.setState({
items: [{ id: 'p1', name: 'A', price: 10, quantity: 1 }],
})
expect(selectIsInCart('p1')(useCartStore.getState())).toBe(true)
expect(selectIsInCart('p2')(useCartStore.getState())).toBe(false)
})
})Testing Subscriptions
Zustand's subscribe notifies when state changes. Test subscribers:
import { useCartStore } from './useCartStore'
import { vi } from 'vitest'
describe('store subscriptions', () => {
it('calls subscriber when items change', () => {
const subscriber = vi.fn()
const unsubscribe = useCartStore.subscribe(subscriber)
useCartStore.getState().addItem({ id: 'p1', name: 'Test', price: 10 })
expect(subscriber).toHaveBeenCalled()
unsubscribe()
})
it('does not call subscriber after unsubscribing', () => {
const subscriber = vi.fn()
const unsubscribe = useCartStore.subscribe(subscriber)
unsubscribe()
useCartStore.getState().addItem({ id: 'p1', name: 'Test', price: 10 })
expect(subscriber).not.toHaveBeenCalled()
})
})What Automated Tests Miss
Store unit tests cover state logic but not:
- Race conditions — concurrent mutations from multiple components
- DevTools integration — whether Redux DevTools shows correct state diffs
- Persistence hydration timing — flash of initial state before persisted state loads
- Memory leaks from uncleared subscriptions
HelpMeTest tests your full app on a schedule. When Zustand store state causes unexpected UI behaviour in a real browser session, continuous monitoring catches it.
Summary
Testing Zustand stores:
- Direct store access → use
store.getState()andstore.setState()— no React required - Reset between tests →
store.setState(initialState)inbeforeEach - Immer → transparent to tests — write assertions against final state values
- persist → mock
localStorage, reset between tests, test rehydration withvi.resetModules() - Components → seed state via
setState()before render; assert DOM changes - Selectors → call selector functions directly with
store.getState()as input