单元测试与 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-jestJest 配置
在项目根目录创建 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 技术模拟外部依赖是确保应用稳定性和可维护性的重要步骤。通过本文的介绍,你应该掌握了:
- 基本 Store 测试:如何测试 Store 的状态和基本 Actions
- 异步 Actions 测试:如何测试涉及 API 请求的异步操作
- 中间件测试:如何测试 Persist、Immer 等中间件的功能
- Mock 技术应用:如何 Mock API 请求、第三方库等外部依赖
- 组件中的 Store 测试:如何测试 React 组件中对 Store 的使用
- 测试最佳实践:如何编写高质量、可维护的测试
通过遵循这些测试策略和最佳实践,你可以确保你的 Zustand Store 代码具有良好的质量和可靠性。
在接下来的章节中,我们将通过 案例 1:电商购物车系统 来实践所学的 Zustand 知识。