测试策略 #

为什么测试 Store? #

测试 Zustand Store 可以:

  • 确保状态更新逻辑正确
  • 验证 Actions 行为
  • 检测副作用
  • 防止回归问题
  • 提高代码可维护性

测试环境配置 #

安装依赖 #

bash
npm install -D vitest @testing-library/react jsdom
# 或
npm install -D jest @testing-library/react jsdom

Vitest 配置 #

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
  },
})

Jest 配置 #

js
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

Setup 文件 #

ts
// tests/setup.ts
import '@testing-library/jest-dom'

// 清理 localStorage
beforeEach(() => {
  localStorage.clear()
})

单元测试 #

测试基本状态 #

tsx
// stores/counterStore.ts
import { create } from 'zustand'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))
tsx
// tests/counterStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounterStore } from '../stores/counterStore'

describe('CounterStore', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 })
  })
  
  it('should have initial count of 0', () => {
    expect(useCounterStore.getState().count).toBe(0)
  })
  
  it('should increment count', () => {
    useCounterStore.getState().increment()
    expect(useCounterStore.getState().count).toBe(1)
  })
  
  it('should decrement count', () => {
    useCounterStore.getState().decrement()
    expect(useCounterStore.getState().count).toBe(-1)
  })
  
  it('should reset count', () => {
    useCounterStore.setState({ count: 5 })
    useCounterStore.getState().reset()
    expect(useCounterStore.getState().count).toBe(0)
  })
})

测试异步 Actions #

tsx
// stores/userStore.ts
import { create } from 'zustand'

interface User {
  id: string
  name: string
}

interface UserState {
  user: User | null
  isLoading: boolean
  error: string | null
  fetchUser: (id: string) => Promise<void>
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  isLoading: false,
  error: null,
  
  fetchUser: async (id: string) => {
    set({ isLoading: true, error: null })
    
    try {
      const response = await fetch(`/api/users/${id}`)
      if (!response.ok) throw new Error('获取用户失败')
      const user = await response.json()
      set({ user, isLoading: false })
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : '未知错误',
        isLoading: false 
      })
    }
  },
}))
tsx
// tests/userStore.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useUserStore } from '../stores/userStore'

global.fetch = vi.fn()

describe('UserStore', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    useUserStore.setState({ user: null, isLoading: false, error: null })
  })
  
  it('should fetch user successfully', async () => {
    const mockUser = { id: '1', name: 'John' }
    ;(fetch as any).mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockUser),
    })
    
    await useUserStore.getState().fetchUser('1')
    
    expect(useUserStore.getState().user).toEqual(mockUser)
    expect(useUserStore.getState().isLoading).toBe(false)
    expect(useUserStore.getState().error).toBeNull()
  })
  
  it('should handle fetch error', async () => {
    ;(fetch as any).mockResolvedValueOnce({
      ok: false,
    })
    
    await useUserStore.getState().fetchUser('1')
    
    expect(useUserStore.getState().user).toBeNull()
    expect(useUserStore.getState().isLoading).toBe(false)
    expect(useUserStore.getState().error).toBe('获取用户失败')
  })
})

测试复杂状态更新 #

tsx
// stores/todoStore.ts
import { create } from 'zustand'

interface Todo {
  id: string
  text: string
  completed: boolean
}

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: string) => void
  removeTodo: (id: string) => void
  clearCompleted: () => void
}

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now().toString(), text, completed: false }]
  })),
  
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  })),
  
  removeTodo: (id) => set((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id)
  })),
  
  clearCompleted: () => set((state) => ({
    todos: state.todos.filter((todo) => !todo.completed)
  })),
}))
tsx
// tests/todoStore.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTodoStore } from '../stores/todoStore'

describe('TodoStore', () => {
  beforeEach(() => {
    useUserStore.setState({ todos: [] })
    vi.useFakeTimers()
  })
  
  afterEach(() => {
    vi.useRealTimers()
  })
  
  it('should add todo', () => {
    vi.setSystemTime(new Date('2024-01-01'))
    
    useTodoStore.getState().addTodo('Buy milk')
    
    const todos = useTodoStore.getState().todos
    expect(todos).toHaveLength(1)
    expect(todos[0].text).toBe('Buy milk')
    expect(todos[0].completed).toBe(false)
  })
  
  it('should toggle todo', () => {
    useTodoStore.setState({
      todos: [{ id: '1', text: 'Test', completed: false }]
    })
    
    useTodoStore.getState().toggleTodo('1')
    
    expect(useTodoStore.getState().todos[0].completed).toBe(true)
  })
  
  it('should remove todo', () => {
    useTodoStore.setState({
      todos: [{ id: '1', text: 'Test', completed: false }]
    })
    
    useTodoStore.getState().removeTodo('1')
    
    expect(useTodoStore.getState().todos).toHaveLength(0)
  })
  
  it('should clear completed todos', () => {
    useTodoStore.setState({
      todos: [
        { id: '1', text: 'Active', completed: false },
        { id: '2', text: 'Completed', completed: true },
      ]
    })
    
    useTodoStore.getState().clearCompleted()
    
    const todos = useTodoStore.getState().todos
    expect(todos).toHaveLength(1)
    expect(todos[0].text).toBe('Active')
  })
})

集成测试 #

测试组件与 Store 集成 #

tsx
// components/Counter.tsx
import { useCounterStore } from '../stores/counterStore'

export function Counter() {
  const { count, increment, decrement, reset } = useCounterStore()
  
  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={increment} data-testid="increment">+</button>
      <button onClick={decrement} data-testid="decrement">-</button>
      <button onClick={reset} data-testid="reset">Reset</button>
    </div>
  )
}
tsx
// tests/Counter.test.tsx
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '../components/Counter'
import { useCounterStore } from '../stores/counterStore'

describe('Counter Component', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 })
  })
  
  it('should display initial count', () => {
    render(<Counter />)
    expect(screen.getByTestId('count')).toHaveTextContent('0')
  })
  
  it('should increment count', () => {
    render(<Counter />)
    fireEvent.click(screen.getByTestId('increment'))
    expect(screen.getByTestId('count')).toHaveTextContent('1')
  })
  
  it('should decrement count', () => {
    render(<Counter />)
    fireEvent.click(screen.getByTestId('decrement'))
    expect(screen.getByTestId('count')).toHaveTextContent('-1')
  })
  
  it('should reset count', () => {
    useCounterStore.setState({ count: 5 })
    render(<Counter />)
    fireEvent.click(screen.getByTestId('reset'))
    expect(screen.getByTestId('count')).toHaveTextContent('0')
  })
})

测试持久化 #

tsx
// tests/persistence.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface State {
  count: number
  increment: () => void
}

const createTestStore = () =>
  create<State>()(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
      }),
      {
        name: 'test-storage',
        storage: createJSONStorage(() => localStorage),
      }
    )
  )

describe('Persistence', () => {
  beforeEach(() => {
    localStorage.clear()
  })
  
  it('should persist state to localStorage', () => {
    const useStore = createTestStore()
    
    useStore.getState().increment()
    
    const stored = JSON.parse(localStorage.getItem('test-storage') || '{}')
    expect(stored.state.count).toBe(1)
  })
  
  it('should restore state from localStorage', () => {
    localStorage.setItem('test-storage', JSON.stringify({
      state: { count: 5 },
      version: 0,
    }))
    
    const useStore = createTestStore()
    
    expect(useStore.getState().count).toBe(5)
  })
})

测试工具函数 #

创建测试 Store #

tsx
// tests/utils.ts
import { StoreApi, UseBoundStore } from 'zustand'

export function createTestStore<T extends object>(
  initialState: T
): UseBoundStore<StoreApi<T>> {
  return create<T>(() => initialState)
}

export function resetStore<T extends object>(
  store: UseBoundStore<StoreApi<T>>,
  initialState: T
): void {
  store.setState(initialState)
}

Mock Store #

tsx
// tests/mocks/store.ts
import { vi } from 'vitest'

export function createMockStore<T extends object>(initialState: T) {
  let state = { ...initialState }
  const listeners = new Set<() => void>()
  
  return {
    getState: vi.fn(() => state),
    setState: vi.fn((newState: Partial<T> | ((s: T) => Partial<T>)) => {
      const update = typeof newState === 'function' ? newState(state) : newState
      state = { ...state, ...update }
      listeners.forEach((listener) => listener())
    }),
    subscribe: vi.fn((listener: () => void) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    }),
  }
}

测试订阅 #

tsx
// tests/subscription.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useCounterStore } from '../stores/counterStore'

describe('Store Subscriptions', () => {
  it('should notify listeners on state change', () => {
    const listener = vi.fn()
    const unsubscribe = useCounterStore.subscribe(listener)
    
    useCounterStore.getState().increment()
    
    expect(listener).toHaveBeenCalled()
    
    unsubscribe()
  })
  
  it('should not notify after unsubscribe', () => {
    const listener = vi.fn()
    const unsubscribe = useCounterStore.subscribe(listener)
    
    unsubscribe()
    useCounterStore.getState().increment()
    
    expect(listener).not.toHaveBeenCalled()
  })
})

测试最佳实践 #

1. 隔离测试 #

tsx
// ✅ 好:每个测试前重置状态
beforeEach(() => {
  useStore.setState(initialState)
})

// ❌ 不好:依赖其他测试的状态
it('test 1', () => {
  useStore.getState().increment()
})

it('test 2', () => {
  // 依赖 test 1 的状态
  expect(useStore.getState().count).toBe(1)
})

2. 测试行为而非实现 #

tsx
// ✅ 好:测试行为
it('should increment count', () => {
  const { increment } = useStore.getState()
  increment()
  expect(useStore.getState().count).toBe(1)
})

// ❌ 不好:测试实现细节
it('should call set with correct value', () => {
  const setSpy = vi.fn()
  // ...
})

3. 使用类型安全的断言 #

tsx
// ✅ 好:类型安全
expect(useStore.getState().count).toBeTypeOf('number')

// ❌ 不好:类型不安全
expect(useStore.getState().count).toBe('1')

4. 测试边界情况 #

tsx
describe('CounterStore', () => {
  it('should handle negative values', () => {
    useStore.setState({ count: -100 })
    useStore.getState().increment()
    expect(useStore.getState().count).toBe(-99)
  })
  
  it('should handle large values', () => {
    useStore.setState({ count: Number.MAX_SAFE_INTEGER })
    useStore.getState().increment()
    expect(useStore.getState().count).toBe(Number.MAX_SAFE_INTEGER + 1)
  })
})

实际案例 #

完整的测试套件 #

tsx
// stores/authStore.ts
import { create } from 'zustand'

interface User {
  id: string
  email: string
  name: string
}

interface AuthState {
  user: User | null
  token: string | null
  isAuthenticated: boolean
  isLoading: boolean
  error: string | null
  
  login: (email: string, password: string) => Promise<void>
  logout: () => void
  clearError: () => void
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  token: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,
  
  login: async (email, password) => {
    set({ isLoading: true, error: null })
    
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })
      
      if (!response.ok) {
        const data = await response.json()
        throw new Error(data.message || '登录失败')
      }
      
      const { user, token } = await response.json()
      set({ user, token, isAuthenticated: true, isLoading: false })
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : '登录失败',
        isLoading: false,
      })
    }
  },
  
  logout: () => set({
    user: null,
    token: null,
    isAuthenticated: false,
    error: null,
  }),
  
  clearError: () => set({ error: null }),
}))
tsx
// tests/authStore.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAuthStore } from '../stores/authStore'

const mockUser = {
  id: '1',
  email: 'test@example.com',
  name: 'Test User',
}

const initialState = {
  user: null,
  token: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,
}

describe('AuthStore', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    useAuthStore.setState(initialState)
  })
  
  describe('login', () => {
    it('should login successfully', async () => {
      global.fetch = vi.fn().mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ user: mockUser, token: 'token123' }),
      })
      
      await useAuthStore.getState().login('test@example.com', 'password')
      
      const state = useAuthStore.getState()
      expect(state.user).toEqual(mockUser)
      expect(state.token).toBe('token123')
      expect(state.isAuthenticated).toBe(true)
      expect(state.isLoading).toBe(false)
      expect(state.error).toBeNull()
    })
    
    it('should handle login failure', async () => {
      global.fetch = vi.fn().mockResolvedValueOnce({
        ok: false,
        json: () => Promise.resolve({ message: 'Invalid credentials' }),
      })
      
      await useAuthStore.getState().login('test@example.com', 'wrong')
      
      const state = useAuthStore.getState()
      expect(state.user).toBeNull()
      expect(state.isAuthenticated).toBe(false)
      expect(state.error).toBe('Invalid credentials')
    })
    
    it('should set loading state during login', async () => {
      let resolveFetch: () => void
      global.fetch = vi.fn().mockImplementation(() => 
        new Promise((resolve) => {
          resolveFetch = () => resolve({
            ok: true,
            json: () => Promise.resolve({ user: mockUser, token: 'token' }),
          })
        })
      )
      
      const loginPromise = useAuthStore.getState().login('test@example.com', 'password')
      
      expect(useAuthStore.getState().isLoading).toBe(true)
      
      resolveFetch!()
      await loginPromise
      
      expect(useAuthStore.getState().isLoading).toBe(false)
    })
  })
  
  describe('logout', () => {
    it('should clear all auth state', () => {
      useAuthStore.setState({
        user: mockUser,
        token: 'token123',
        isAuthenticated: true,
      })
      
      useAuthStore.getState().logout()
      
      const state = useAuthStore.getState()
      expect(state.user).toBeNull()
      expect(state.token).toBeNull()
      expect(state.isAuthenticated).toBe(false)
      expect(state.error).toBeNull()
    })
  })
  
  describe('clearError', () => {
    it('should clear error', () => {
      useAuthStore.setState({ error: 'Some error' })
      
      useAuthStore.getState().clearError()
      
      expect(useAuthStore.getState().error).toBeNull()
    })
  })
})

运行测试 #

Vitest #

bash
# 运行所有测试
vitest

# 运行特定文件
vitest tests/counterStore.test.ts

# 监听模式
vitest watch

# 覆盖率报告
vitest coverage

Jest #

bash
# 运行所有测试
npm test

# 运行特定文件
npm test -- counterStore.test.ts

# 监听模式
npm test -- --watch

# 覆盖率报告
npm test -- --coverage

总结 #

测试 Zustand Store 的关键点:

  • 使用 getState()setState() 直接测试状态
  • Mock 异步操作和外部依赖
  • 在每个测试前重置状态
  • 测试组件与 Store 的集成
  • 测试持久化和订阅功能

接下来,让我们学习 性能优化,掌握优化技巧。

最后更新:2026-03-28