测试策略 #
为什么测试 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