教程 zustand tutorial

单元测试与 Mock

Zustand 作为一种轻量级的状态管理库,其测试策略与其他状态管理方案类似,但由于其简洁的 API 和无样板代码的特性,测试起来更加直观和高效。本文将介绍如何对 Zustand Store 进行单元测试,并使用 Mock 技术模拟外部依赖。

测试环境搭建

安装测试依赖

首先,我们需要安装常用的测试库:

bash
# Jest 作为测试框架
npm install --save-dev jest

# React Testing Library 用于测试 React 组件中的 Store 使用
npm install --save-dev @testing-library/react @testing-library/jest-dom

# 如果使用 TypeScript
npm install --save-dev @types/jest ts-jest

Jest 配置

在项目根目录创建 jest.config.js 文件:

javascript
module.exports = {
  testEnvironment: 'jsdom', // 用于 React 组件测试
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['@babel/preset-env', '@babel/preset-react'] }],
  },
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy', // 处理样式导入
  },
}

基本 Store 测试

测试简单 Store

让我们从一个简单的计数器 Store 开始测试:

javascript
// store/counterStore.js
import { create } from 'zustand'

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementBy: (value) => set((state) => ({ count: state.count + value })),
}))

编写测试用例:

javascript
// __tests__/counterStore.test.js
import { useCounterStore } from '../store/counterStore'

describe('Counter Store', () => {
  // 测试前重置状态
  beforeEach(() => {
    useCounterStore.setState({ count: 0 })
  })

  test('初始化状态正确', () => {
    const state = useCounterStore.getState()
    expect(state.count).toBe(0)
  })

  test('increment 方法正确更新状态', () => {
    const { increment, count } = useCounterStore.getState()
    
    // 调用 increment 方法
    increment()
    
    // 获取最新状态
    const newState = useCounterStore.getState()
    expect(newState.count).toBe(1)
  })

  test('decrement 方法正确更新状态', () => {
    // 先增加到 5
    useCounterStore.setState({ count: 5 })
    
    const { decrement } = useCounterStore.getState()
    decrement()
    
    expect(useCounterStore.getState().count).toBe(4)
  })

  test('reset 方法正确重置状态', () => {
    useCounterStore.setState({ count: 10 })
    
    const { reset } = useCounterStore.getState()
    reset()
    
    expect(useCounterStore.getState().count).toBe(0)
  })

  test('incrementBy 方法正确更新状态', () => {
    const { incrementBy } = useCounterStore.getState()
    incrementBy(5)
    
    expect(useCounterStore.getState().count).toBe(5)
    
    incrementBy(3)
    expect(useCounterStore.getState().count).toBe(8)
  })
})

测试带参数的 Actions

javascript
// store/todoStore.js
import { create } from 'zustand'

export const useTodoStore = create((set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), 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),
  })),
}))

编写测试用例:

javascript
// __tests__/todoStore.test.js
import { useTodoStore } from '../store/todoStore'

describe('Todo Store', () => {
  beforeEach(() => {
    useTodoStore.setState({ todos: [] })
  })

  test('addTodo 方法正确添加任务', () => {
    const { addTodo } = useTodoStore.getState()
    
    // 模拟 Date.now() 以确保测试可预测
    const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(123456789)
    
    addTodo('Learn Zustand')
    
    const state = useTodoStore.getState()
    expect(state.todos).toHaveLength(1)
    expect(state.todos[0]).toEqual({
      id: 123456789,
      text: 'Learn Zustand',
      completed: false,
    })
    
    // 恢复原始 Date.now()
    mockDateNow.mockRestore()
  })

  test('toggleTodo 方法正确切换任务状态', () => {
    const { addTodo, toggleTodo } = useTodoStore.getState()
    
    // 先添加一个任务
    const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(123456789)
    addTodo('Learn Zustand')
    mockDateNow.mockRestore()
    
    // 切换任务状态
    toggleTodo(123456789)
    
    const state = useTodoStore.getState()
    expect(state.todos[0].completed).toBe(true)
    
    // 再次切换
    toggleTodo(123456789)
    expect(state.todos[0].completed).toBe(false)
  })

  test('removeTodo 方法正确删除任务', () => {
    const { addTodo, removeTodo } = useTodoStore.getState()
    
    // 添加两个任务
    const mockDateNow = jest.spyOn(Date, 'now')
    mockDateNow.mockReturnValueOnce(123456789).mockReturnValueOnce(987654321)
    
    addTodo('Learn Zustand')
    addTodo('Test Zustand')
    
    mockDateNow.mockRestore()
    
    // 删除第一个任务
    removeTodo(123456789)
    
    const state = useTodoStore.getState()
    expect(state.todos).toHaveLength(1)
    expect(state.todos[0].text).toBe('Test Zustand')
  })
})

异步 Actions 测试

异步 Actions 是 Zustand 中常见的场景,例如从 API 获取数据。测试异步 Actions 需要特别注意处理 Promise 和异步流程。

测试基本异步 Action

javascript
// store/userStore.js
import { create } from 'zustand'

export const useUserStore = create((set) => ({
  users: [],
  isLoading: false,
  error: null,
  
  fetchUsers: async () => {
    set({ isLoading: true, error: null })
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users')
      const data = await response.json()
      set({ users: data, isLoading: false })
    } catch (error) {
      set({ error: error.message, isLoading: false })
    }
  },
}))

编写测试用例:

javascript
// __tests__/userStore.test.js
import { useUserStore } from '../store/userStore'

describe('User Store', () => {
  beforeEach(() => {
    useUserStore.setState({ 
      users: [], 
      isLoading: false, 
      error: null 
    })
    
    // 清除所有 mocks
    jest.clearAllMocks()
  })

  test('fetchUsers 方法正确获取用户数据', async () => {
    // Mock fetch API
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]
    
    global.fetch = jest.fn().mockResolvedValue({
      json: jest.fn().mockResolvedValue(mockUsers),
    })
    
    const { fetchUsers } = useUserStore.getState()
    
    // 调用异步方法
    await fetchUsers()
    
    const state = useUserStore.getState()
    
    // 验证状态更新
    expect(state.isLoading).toBe(false)
    expect(state.error).toBe(null)
    expect(state.users).toEqual(mockUsers)
    
    // 验证 fetch 被正确调用
    expect(global.fetch).toHaveBeenCalledTimes(1)
    expect(global.fetch).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users')
  })

  test('fetchUsers 方法正确处理错误', async () => {
    // Mock fetch API 抛出错误
    const mockError = new Error('Network Error')
    global.fetch = jest.fn().mockRejectedValue(mockError)
    
    const { fetchUsers } = useUserStore.getState()
    
    await fetchUsers()
    
    const state = useUserStore.getState()
    
    expect(state.isLoading).toBe(false)
    expect(state.error).toBe('Network Error')
    expect(state.users).toEqual([])
  })

  test('fetchUsers 方法正确设置加载状态', async () => {
    // Mock fetch API 延迟响应
    const mockUsers = [{ id: 1, name: 'Alice' }]
    global.fetch = jest.fn().mockResolvedValue({
      json: jest.fn().mockResolvedValue(mockUsers),
    })
    
    const { fetchUsers } = useUserStore.getState()
    
    // 调用异步方法但不等待完成
    const promise = fetchUsers()
    
    // 检查加载状态
    expect(useUserStore.getState().isLoading).toBe(true)
    
    // 等待异步操作完成
    await promise
    
    // 检查最终状态
    expect(useUserStore.getState().isLoading).toBe(false)
  })
})

中间件测试

测试 Persist 中间件

javascript
// store/persistStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const usePersistStore = create(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    {
      name: 'persist-store',
      storage: { 
        getItem: (name) => JSON.stringify(localStorage.getItem(name)),
        setItem: (name, value) => localStorage.setItem(name, JSON.stringify(value)),
        removeItem: (name) => localStorage.removeItem(name),
      },
    }
  )
)

编写测试用例:

javascript
// __tests__/persistStore.test.js
import { usePersistStore } from '../store/persistStore'

describe('Persist Store', () => {
  beforeEach(() => {
    // 清除 localStorage
    localStorage.clear()
    usePersistStore.setState({ count: 0 })
  })

  test('increment 方法正确更新状态并持久化', () => {
    const { increment } = usePersistStore.getState()
    
    increment()
    
    const state = usePersistStore.getState()
    expect(state.count).toBe(1)
    
    // 检查 localStorage 是否包含持久化数据
    const persistedData = localStorage.getItem('persist-store')
    expect(persistedData).toBeTruthy()
    
    const parsedData = JSON.parse(JSON.parse(persistedData))
    expect(parsedData.count).toBe(1)
  })

  test('状态在页面刷新后正确恢复', () => {
    // 先设置一个初始状态
    usePersistStore.setState({ count: 5 })
    
    // 模拟页面刷新
    usePersistStore.persist.clearStorage()
    localStorage.setItem('persist-store', JSON.stringify(JSON.stringify({ count: 10 })))
    
    // 重置 Store 并恢复持久化数据
    usePersistStore.persist.rehydrate()
    
    const state = usePersistStore.getState()
    expect(state.count).toBe(10)
  })
})

测试 Immer 中间件

javascript
// store/immerStore.js
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

export const useImmerStore = create(
  immer((set) => ({
    todos: [],
    addTodo: (text) => set((state) => {
      state.todos.push({ id: Date.now(), text, completed: false })
    }),
    toggleTodo: (id) => set((state) => {
      const todo = state.todos.find((t) => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    }),
  }))
)

编写测试用例:

javascript
// __tests__/immerStore.test.js
import { useImmerStore } from '../store/immerStore'

describe('Immer Store', () => {
  beforeEach(() => {
    useImmerStore.setState({ todos: [] })
  })

  test('addTodo 方法正确添加任务', () => {
    const { addTodo } = useImmerStore.getState()
    
    const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(123456789)
    
    addTodo('Learn Immer')
    
    const state = useImmerStore.getState()
    expect(state.todos).toHaveLength(1)
    expect(state.todos[0]).toEqual({
      id: 123456789,
      text: 'Learn Immer',
      completed: false,
    })
    
    mockDateNow.mockRestore()
  })

  test('toggleTodo 方法正确切换任务状态', () => {
    const { addTodo, toggleTodo } = useImmerStore.getState()
    
    const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(123456789)
    addTodo('Learn Immer')
    mockDateNow.mockRestore()
    
    toggleTodo(123456789)
    
    const state = useImmerStore.getState()
    expect(state.todos[0].completed).toBe(true)
  })
})

Mock 技术应用

Mock API 请求

在测试异步 Actions 时,我们经常需要 Mock API 请求。除了前面示例中使用的 jest.fn() 来 Mock fetch,我们还可以使用更高级的 Mock 库:

javascript
// __tests__/userStore.test.js
import { useUserStore } from '../store/userStore'
import axios from 'axios'

// 使用 axios 替代 fetch
// store/userStore.js
// export const useUserStore = create((set) => ({
//   users: [],
//   isLoading: false,
//   error: null,
//   
//   fetchUsers: async () => {
//     set({ isLoading: true, error: null })
//     try {
//       const response = await axios.get('https://jsonplaceholder.typicode.com/users')
//       set({ users: response.data, isLoading: false })
//     } catch (error) {
//       set({ error: error.message, isLoading: false })
//     }
//   },
// }))

describe('User Store with Axios', () => {
  beforeEach(() => {
    useUserStore.setState({ 
      users: [], 
      isLoading: false, 
      error: null 
    })
    jest.clearAllMocks()
  })

  test('fetchUsers 方法正确获取用户数据', async () => {
    const mockUsers = [{ id: 1, name: 'Alice' }]
    
    // Mock axios.get
    axios.get = jest.fn().mockResolvedValue({
      data: mockUsers,
    })
    
    const { fetchUsers } = useUserStore.getState()
    await fetchUsers()
    
    const state = useUserStore.getState()
    
    expect(state.users).toEqual(mockUsers)
    expect(axios.get).toHaveBeenCalledTimes(1)
    expect(axios.get).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users')
  })
})

Mock 第三方库

javascript
// store/analyticsStore.js
import { create } from 'zustand'
import analytics from 'analytics-library'

export const useAnalyticsStore = create((set) => ({
  events: [],
  trackEvent: (eventName, eventData) => set((state) => {
    // 记录事件到本地状态
    const newEvent = { id: Date.now(), eventName, eventData, timestamp: new Date() }
    
    // 发送事件到分析服务
    analytics.track(eventName, eventData)
    
    return { events: [...state.events, newEvent] }
  }),
}))

编写测试用例:

javascript
// __tests__/analyticsStore.test.js
import { useAnalyticsStore } from '../store/analyticsStore'
// Mock 整个分析库
jest.mock('analytics-library', () => ({
  track: jest.fn(),
}))

import analytics from 'analytics-library'

describe('Analytics Store', () => {
  beforeEach(() => {
    useAnalyticsStore.setState({ events: [] })
    jest.clearAllMocks()
  })

  test('trackEvent 方法正确记录事件并调用分析库', () => {
    const { trackEvent } = useAnalyticsStore.getState()
    
    const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(123456789)
    const mockNewDate = jest.spyOn(global, 'Date').mockImplementation(() => new Date(123456789))
    
    trackEvent('button_clicked', { buttonId: 'submit' })
    
    const state = useAnalyticsStore.getState()
    
    expect(state.events).toHaveLength(1)
    expect(state.events[0]).toEqual({
      id: 123456789,
      eventName: 'button_clicked',
      eventData: { buttonId: 'submit' },
      timestamp: new Date(123456789),
    })
    
    // 验证分析库的 track 方法被调用
    expect(analytics.track).toHaveBeenCalledTimes(1)
    expect(analytics.track).toHaveBeenCalledWith('button_clicked', { buttonId: 'submit' })
    
    mockDateNow.mockRestore()
    mockNewDate.mockRestore()
  })
})

测试 React 组件中的 Store 使用

使用 React Testing Library

javascript
// components/Counter.jsx
import React from 'react'
import { useCounterStore } from '../store/counterStore'

function Counter() {
  const { count, increment, decrement, reset } = useCounterStore()
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <button data-testid="increment-btn" onClick={increment}>Increment</button>
      <button data-testid="decrement-btn" onClick={decrement}>Decrement</button>
      <button data-testid="reset-btn" onClick={reset}>Reset</button>
    </div>
  )
}

export default Counter

编写测试用例:

javascript
// __tests__/Counter.test.jsx
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from '../components/Counter'
import { useCounterStore } from '../store/counterStore'

describe('Counter Component', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 })
  })

  test('渲染正确的初始状态', () => {
    render(<Counter />)
    
    expect(screen.getByText('Count: 0')).toBeInTheDocument()
    expect(screen.getByTestId('increment-btn')).toBeInTheDocument()
    expect(screen.getByTestId('decrement-btn')).toBeInTheDocument()
    expect(screen.getByTestId('reset-btn')).toBeInTheDocument()
  })

  test('点击 Increment 按钮正确增加计数', () => {
    render(<Counter />)
    
    fireEvent.click(screen.getByTestId('increment-btn'))
    
    expect(screen.getByText('Count: 1')).toBeInTheDocument()
  })

  test('点击 Decrement 按钮正确减少计数', () => {
    // 设置初始计数为 5
    useCounterStore.setState({ count: 5 })
    
    render(<Counter />)
    
    fireEvent.click(screen.getByTestId('decrement-btn'))
    
    expect(screen.getByText('Count: 4')).toBeInTheDocument()
  })

  test('点击 Reset 按钮正确重置计数', () => {
    useCounterStore.setState({ count: 10 })
    
    render(<Counter />)
    
    fireEvent.click(screen.getByTestId('reset-btn'))
    
    expect(screen.getByText('Count: 0')).toBeInTheDocument()
  })
})

测试最佳实践

1. 隔离测试环境

javascript
// 不好的做法:测试之间共享状态
describe('Bad Test', () => {
  test('test 1', () => {
    useStore.setState({ count: 5 })
    // ...
  })
  
  test('test 2', () => {
    // 这个测试会受到 test 1 的影响
    const state = useStore.getState()
    expect(state.count).toBe(0) // 失败
  })
})

// 好的做法:使用 beforeEach 重置状态
describe('Good Test', () => {
  beforeEach(() => {
    useStore.setState({ count: 0 })
  })
  
  test('test 1', () => {
    useStore.setState({ count: 5 })
    // ...
  })
  
  test('test 2', () => {
    // 状态已重置
    const state = useStore.getState()
    expect(state.count).toBe(0) // 通过
  })
})

2. 测试单一职责

javascript
// 不好的做法:一个测试验证多个功能
test('multiple assertions', () => {
  const { increment, decrement } = useStore.getState()
  
  increment()
  increment()
  decrement()
  
  expect(useStore.getState().count).toBe(1)
  expect(useStore.getState().history).toHaveLength(3) // 测试了未在测试名称中提及的功能
})

// 好的做法:每个测试只验证一个功能
test('increment and decrement update count correctly', () => {
  const { increment, decrement } = useStore.getState()
  
  increment()
  increment()
  decrement()
  
  expect(useStore.getState().count).toBe(1)
})

test('increment and decrement record history', () => {
  const { increment, decrement } = useStore.getState()
  
  increment()
  increment()
  decrement()
  
  expect(useStore.getState().history).toHaveLength(3)
})

3. 使用有意义的测试名称

javascript
// 不好的做法:不明确的测试名称
test('test 1', () => {
  // ...
})

test('test increment', () => {
  // ...
})

// 好的做法:明确描述测试内容
test('increment method increases count by 1', () => {
  // ...
})

test('fetchUsers method handles network error gracefully', () => {
  // ...
})

4. 避免过度测试

javascript
// 不好的做法:测试实现细节
test('increment method calls set with correct function', () => {
  const setSpy = jest.spyOn(useStore, 'setState')
  const { increment } = useStore.getState()
  
  increment()
  
  expect(setSpy).toHaveBeenCalledWith(expect.any(Function))
  setSpy.mockRestore()
})

// 好的做法:测试行为而不是实现
test('increment method increases count by 1', () => {
  const { increment } = useStore.getState()
  const initialCount = useStore.getState().count
  
  increment()
  
  expect(useStore.getState().count).toBe(initialCount + 1)
})

常见问题与解决方案

1. 测试中状态不更新

问题:调用了 Store 的方法,但状态没有更新

解决方案:

  • 确保在测试中使用 getState() 获取最新状态
  • 对于异步操作,确保使用 await 等待完成
  • 检查是否在测试前正确重置了状态
javascript
// 错误示例
test('increment does not work', () => {
  const { count, increment } = useStore.getState()
  increment()
  expect(count).toBe(1) // 失败,因为 count 是调用 increment 前的旧值
})

// 正确示例
test('increment works correctly', () => {
  const { increment } = useStore.getState()
  increment()
  const newState = useStore.getState()
  expect(newState.count).toBe(1) // 成功
})

2. 异步测试超时

问题:异步测试经常超时

解决方案:

  • 确保正确使用 async/await
  • 检查是否有未解决的 Promise
  • 调整 Jest 的超时时间(如果必要)
javascript
// 设置更长的超时时间
test('long running async test', async () => {
  // ...
}, 10000) // 10秒超时

3. Mock 不生效

问题:Mock 的函数或模块没有按预期工作

解决方案:

  • 确保 Mock 语句在导入被 Mock 的模块之前
  • 检查 Mock 的路径是否正确
  • 使用 jest.clearAllMocks() 或 jest.resetAllMocks() 清除旧的 Mock
javascript
// 错误示例:导入后才 Mock
import { useStore } from '../store'
jest.mock('../store') // 这不会生效,因为模块已经被导入

// 正确示例:Mock 后再导入
jest.mock('../store')
import { useStore } from '../store' // 现在 Mock 会生效

总结

对 Zustand Store 进行单元测试和使用 Mock 技术模拟外部依赖是确保应用稳定性和可维护性的重要步骤。通过本文的介绍,你应该掌握了:

  1. 基本 Store 测试:如何测试 Store 的状态和基本 Actions
  2. 异步 Actions 测试:如何测试涉及 API 请求的异步操作
  3. 中间件测试:如何测试 Persist、Immer 等中间件的功能
  4. Mock 技术应用:如何 Mock API 请求、第三方库等外部依赖
  5. 组件中的 Store 测试:如何测试 React 组件中对 Store 的使用
  6. 测试最佳实践:如何编写高质量、可维护的测试

通过遵循这些测试策略和最佳实践,你可以确保你的 Zustand Store 代码具有良好的质量和可靠性。

在接下来的章节中,我们将通过 案例 1:电商购物车系统 来实践所学的 Zustand 知识。