跳到主要内容

测试

设置测试环境

测试运行器

通常,你的测试运行器需要配置为运行 JavaScript/TypeScript 语法。如果你要测试 UI 组件,你可能需要配置测试运行器使用 JSDOM 提供一个模拟的 DOM 环境。

参见以下资源以获取测试运行器配置指南:

UI 和网络测试工具

我们推荐使用 React 测试库 (RTL) 来测试连接到 Zustand 的 React 组件。RTL 是一个简单且完整的 React DOM 测试工具,它鼓励良好的测试实践。它使用 ReactDOM 的 render 函数和 react-dom/tests-utilsact。此外,原生测试库 (RNTL) 是测试 React Native 组件的 RTL 替代品。测试库 的工具家族还包括许多其他流行框架的适配器。

我们还推荐使用 Mock Service Worker (MSW) 来模拟网络请求,因为这意味着在编写测试时不需要更改或模拟你的应用程序逻辑。

为测试设置 Zustand

注意:由于 Jest 和 Vitest 有细微的差别,比如 Vitest 使用 ES 模块 而 Jest 使用 CommonJS 模块,如果你使用 Vitest 而不是 Jest,你需要记住这一点。

下面提供的模拟将使相关的测试运行器在每次测试后重置 zustand 存储。

仅用于测试目的的共享代码

这段共享代码是为了避免在我们的演示中代码重复,因为我们对两种实现都使用相同的计数器存储创建器,分别是带有 Context API 和不带 Context API 的 createStorecreate

// shared/counter-store-creator.ts
import { type StateCreator } from 'zustand'

export type CounterStore = {
count: number
inc: () => void
}

export const counterStoreCreator: StateCreator<CounterStore> = (set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
})

Jest

在接下来的步骤中,我们将设置我们的 Jest 环境以便模拟 Zustand。

// __mocks__/zustand.ts
import * as zustand from 'zustand'
import { act } from '@testing-library/react'

const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof zustand>('zustand')

// 一个变量用于保存应用中声明的所有存储的重置函数
export const storeResetFns = new Set<() => void>()

const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}

// 当创建一个存储时,我们获取其初始状态,创建一个重置函数并将其添加到集合中
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand create mock')

// 为了支持 create 的柯里化版本
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried
}) as typeof zustand.create

const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}

// 当创建一个存储时,我们获取其初始状态,创建一个重置函数并将其添加到集合中
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand createStore mock')

// 为了支持 createStore 的柯里化版本
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof zustand.createStore

// 在每次测试运行后重置所有存储
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})
// setup-jest.ts
import '@testing-library/jest-dom'
// jest.config.ts
import type { JestConfigWithTsJest } from 'ts-jest'

const config: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./setup-jest.ts'],
}

export default config

注意:要使用 TypeScript,我们需要安装两个包 ts-jestts-node

Vitest

在接下来的步骤中,我们将设置我们的 Vitest 环境以便模拟 Zustand。

警告: 在 Vitest 中你可以更改 root。 因此,你需要确保你在正确的地方创建你的 __mocks__ 目录。 假设你将 root 更改为 ./src,那就意味着你需要在 ./src 下创建一个 __mocks__ 目录。最终结果将是 ./src/__mocks__,而不是 ./__mocks__。 在错误的地方创建 __mocks__ 目录可能会在使用 Vitest 时导致问题。

// __mocks__/zustand.ts
import * as zustand from 'zustand'
import { act } from '@testing-library/react'

const { create: actualCreate, createStore: actualCreateStore } =
await vi.importActual<typeof zustand>('zustand')

// 一个变量用于保存应用中声明的所有存储的重置函数
export const storeResetFns = new Set<() => void>()

const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}

// 当创建一个存储时,我们获取其初始状态,创建一个重置函数并将其添加到集合中
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand create mock')

// 为了支持 create 的柯里化版本
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried
}) as typeof zustand.create

const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}

// 当创建一个存储时,我们获取其初始状态,创建一个重置函数并将其添加到集合中
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand createStore mock')

// 为了支持 createStore 的柯里化版本
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof zustand.createStore

// 在每次测试运行后重置所有存储
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})

注意:如果没有启用 全局配置,我们需要 在顶部添加 import { afterEach, vi } from 'vitest'

// global.d.ts
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />

注意:如果没有启用 全局配置,我们需要 移除 /// <reference types="vitest/globals" />

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

vi.mock('zustand') // 使其像 Jest 一样工作(自动模拟)

注意:如果没有启用 全局配置,我们需要 在顶部添加 import { vi } from 'vitest'

// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./setup-vitest.ts'],
},
}),
)

测试组件

在接下来的示例中,我们将使用 useCounterStore

注意:所有这些示例都是使用 TypeScript 编写的。

// stores/counter-store-creator.ts
import { type StateCreator } from 'zustand'

export type CounterStore = {
count: number
inc: () => void
}

export const counterStoreCreator: StateCreator<CounterStore> = (set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
})
// stores/user-counter-store.ts
import { create } from 'zustand'

import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'

export const useCounterStore = create<CounterStore>()(counterStoreCreator)
// contexts/use-counter-store-context.tsx
import { type ReactNode, createContext, useContext, useRef } from 'react'
import { createStore } from 'zustand'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'

import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'

export const createCounterStore = () => {
return createStore<CounterStore>(counterStoreCreator)
}

export type CounterStoreApi = ReturnType<typeof createCounterStore>

export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
undefined,
)

export interface CounterStoreProviderProps {
children: ReactNode
}

export const CounterStoreProvider = ({
children,
}: CounterStoreProviderProps) => {
const counterStoreRef = useRef<CounterStoreApi>()
if (!counterStoreRef.current) {
counterStoreRef.current = createCounterStore()
}

return (
<CounterStoreContext.Provider value={counterStoreRef.current}>
{children}
</CounterStoreContext.Provider>
)
}

export type UseCounterStoreContextSelector<T> = (store: CounterStore) => T

export const useCounterStoreContext = <T,>(
selector: UseCounterStoreContextSelector<T>,
): T => {
const counterStoreContext = useContext(CounterStoreContext)

if (counterStoreContext === undefined) {
throw new Error(
'useCounterStoreContext must be used within CounterStoreProvider',
)
}

return useStoreWithEqualityFn(counterStoreContext, selector, shallow)
}
// components/counter/counter.tsx
import { useCounterStore } from '../../stores/use-counter-store'

export function Counter() {
const { count, inc } = useCounterStore()

return (
<div>
<h2>Counter Store</h2>
<h4>{count}</h4>
<button onClick={inc}>One Up</button>
</div>
)
}
// components/counter/index.ts
export * from './counter'
// components/counter/counter.test.tsx
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { Counter } from './counter'

describe('Counter', () => {
test('should render with initial state of 1', async () => {
renderCounter()

expect(await screen.findByText(/^1$/)).toBeInTheDocument()
expect(
await screen.findByRole('button', { name: /one up/i }),
).toBeInTheDocument()
})

test('should increase count by clicking a button', async () => {
const user = userEvent.setup()

renderCounter()

expect(await screen.findByText(/^1$/)).toBeInTheDocument()

await act(async () => {
await user.click(await screen.findByRole('button', { name: /one up/i }))
})

expect(await screen.findByText(/^2$/)).toBeInTheDocument()
})
})

const renderCounter = () => {
return render(<Counter />)
}
// components/counter-with-context/counter-with-context.tsx
import {
CounterStoreProvider,
useCounterStoreContext,
} from '../../contexts/use-counter-store-context'

const Counter = () => {
const { count, inc } = useCounterStoreContext((state) => state)

return (
<div>
<h2>Counter Store Context</h2>
<h4>{count}</h4>
<button onClick={inc}>One Up</button>
</div>
)
}

export const CounterWithContext = () => {
return (
<CounterStoreProvider>
<Counter />
</CounterStoreProvider>
)
}
// components/counter-with-context/index.ts
export * from './counter-with-context'
// components/counter-with-context/counter-with-context.test.tsx
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { CounterWithContext } from './counter-with-context'

describe('CounterWithContext', () => {
test('should render with initial state of 1', async () => {
renderCounterWithContext()

expect(await screen.findByText(/^1$/)).toBeInTheDocument()
expect(
await screen.findByRole('button', { name: /one up/i }),
).toBeInTheDocument()
})

test('should increase count by clicking a button', async () => {
const user = userEvent.setup()

renderCounterWithContext()

expect(await screen.findByText(/^1$/)).toBeInTheDocument()

await act(async () => {
await user.click(await screen.findByRole('button', { name: /one up/i }))
})

expect(await screen.findByText(/^2$/)).toBeInTheDocument()
})
})

const renderCounterWithContext = () => {
return render(<CounterWithContext />)
}

注意:如果没有启用 全局配置,我们需要在每个测试文件的顶部添加 import { describe, test, expect } from 'vitest'

CodeSandbox 演示

参考资料

  • React 测试库React 测试库 (RTL) 是一个用于测试 React 组件的非常轻量级的解决方案。它在 react-domreact-dom/test-utils 的基础上提供了实用函数,以鼓励更好的测试实践。它的主要指导原则是:"你的测试越像你的软件被使用的方式,它们就能给你带来更多的信心。"
  • 原生测试库原生测试库 (RNTL) 是一个用于测试 React Native 组件的非常轻量级的解决方案,类似于 RTL,但它的函数是基于 react-test-renderer 构建的。
  • 测试实现细节:Kent C. Dodds 的博客文章,他建议避免 测试实现细节