Testing Zustand Stores: Actions, Selectors, Middleware, and Immer with Vitest

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-dom

Basic 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() and store.setState() — no React required
  • Reset between testsstore.setState(initialState) in beforeEach
  • Immer → transparent to tests — write assertions against final state values
  • persist → mock localStorage, reset between tests, test rehydration with vi.resetModules()
  • Components → seed state via setState() before render; assert DOM changes
  • Selectors → call selector functions directly with store.getState() as input

Read more