测试
搭建测试环境
测试运行器
通常,你需要配置测试运行器,让它能够运行 JavaScript/TypeScript 语法。如果你打算测试 UI 组件,很可能还需要额外配置测试运行器,让它使用 JSDOM 来提供一个模拟的 DOM 环境。
测试运行器的配置说明可参考以下资源:
UI 与网络测试工具
我们推荐使用 React Testing Library (RTL) 来测试连接 Zustand 的 React 组件。RTL 是一个简单而完整的 React DOM 测试工具,它鼓励良好的测试实践。它使用 ReactDOM 的 render 函数以及 react-dom/test-utils 中的 act。此外,Native Testing Library (RNTL) 是 RTL 的替代方案,用于测试 React Native 组件。Testing Library 系列工具还包含面向许多其他流行框架的适配器。
我们还推荐使用 Mock Service Worker (MSW) 来 mock 网络请求,因为这意味着编写测试时不需要改动或 mock 你的应用逻辑。
- React Testing Library (DOM)
- Native Testing Library (React Native)
- User Event Testing Library (DOM)
- TypeScript for Jest
- TypeScript for Node
- Mock Service Worker
为测试配置 Zustand
注意:Jest 和 Vitest 之间存在一些细微差别,例如 Vitest 使用 ES 模块,而 Jest 使用 CommonJS 模块。如果你使用 Vitest 而不是 Jest,需要留意这一点。
下面提供的 mock 能让相应的测试运行器在每个测试结束后重置 zustand store。
仅供测试使用的共享代码
加入这段共享代码,是为了避免演示中的代码重复,因为两种实现(使用和不使用 Context API)共用了同一个 counter store creator,前者对应 createStore,后者对应 create。
// 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 环境,以便 mock Zustand。
// __mocks__/zustand.ts
import { act } from '@testing-library/react'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof ZustandExportedTypes>('zustand')
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
console.log('zustand create mock')
// to support curried version of create
return typeof stateCreator === 'function' ? createUncurried(stateCreator) : createUncurried
}) as typeof ZustandExportedTypes.create
const createStoreUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
console.log('zustand createStore mock')
// to support curried version of createStore
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof ZustandExportedTypes.createStore
// reset all stores after each test run
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-jest 和 ts-node 这两个包。
Vitest
接下来,我们将配置 Vitest 环境,以便 mock Zustand。
警告:在 Vitest 中你可以修改 root。
因此,你需要确保在正确的位置创建 __mocks__ 目录。
假设你把 root 改成了 ./src,那就意味着你需要在 ./src 下创建 __mocks__
目录。最终路径会是 ./src/__mocks__,而不是 ./__mocks__。
在错误的位置创建 __mocks__ 目录,可能会导致使用 Vitest 时出现问题。
// __mocks__/zustand.ts
import { act } from '@testing-library/react'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate, createStore: actualCreateStore } =
await vi.importActual<typeof ZustandExportedTypes>('zustand')
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
console.log('zustand create mock')
// to support curried version of create
return typeof stateCreator === 'function' ? createUncurried(stateCreator) : createUncurried
}) as typeof ZustandExportedTypes.create
const createStoreUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
console.log('zustand createStore mock')
// to support curried version of createStore
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof ZustandExportedTypes.createStore
// reset all stores after each test run
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})
注意:如果未启用 globals 配置,我们需要在文件顶部添加 import { afterEach, vi } from 'vitest'。
// global.d.ts
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />
注意:如果未启用 globals 配置,我们需要移除 /// <reference types="vitest/globals" />。
// setup-vitest.ts
import '@testing-library/jest-dom/vitest'
vi.mock('zustand') // to make it work like Jest (auto-mocking)
注意:如果未启用 globals 配置,我们需要在文件顶部添加 import { vi } from 'vitest'。
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default defineConfig((configEnv) =>
mergeConfig(
viteConfig(configEnv),
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./setup-vitest.ts'],
},
}),
),
)
测试组件
接下来的示例中,我们将使用 useCounterStore
注意:下面所有示例都使用 TypeScript 编写。
// 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 })),
})
// stores/use-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, useState } 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 [store] = useState(() => createCounterStore())
return <CounterStoreContext.Provider value={store}>{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 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 user.click(await screen.findByRole('button', { name: /one up/i }))
expect(await screen.findByText(/^2$/)).toBeInTheDocument()
})
})
const renderCounterWithContext = () => {
return render(<CounterWithContext />)
}
注意:如果未启用 globals 配置,我们需要在每个测试文件的顶部添加 import { describe, test, expect } from 'vitest'。
测试 store
接下来的示例中,我们将使用 useCounterStore
注意:下面所有示例都使用 TypeScript 编写。
// 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 })),
})
// stores/use-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, useState } 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 [store] = useState(() => createCounterStore())
return <CounterStoreContext.Provider value={store}>{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, useCounterStore } from '../../../stores/use-counter-store.ts'
describe('Counter', () => {
test('should render with initial state of 1', async () => {
renderCounter()
expect(useCounterStore.getState().count).toBe(1)
})
test('should increase count by clicking a button', async () => {
const user = userEvent.setup()
renderCounter()
expect(useCounterStore.getState().count).toBe(1)
await user.click(await screen.findByRole('button', { name: /one up/i }))
expect(useCounterStore.getState().count).toBe(2)
})
})
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 { CounterStoreContext } from '../../../contexts/use-counter-store-context'
import { counterStoreCreator } from '../../../shared/counter-store-creator'
describe('CounterWithContext', () => {
test('should render with initial state of 1', async () => {
const counterStore = counterStoreCreator()
renderCounterWithContext(counterStore)
expect(counterStore.getState().count).toBe(1)
expect(await screen.findByRole('button', { name: /one up/i })).toBeInTheDocument()
})
test('should increase count by clicking a button', async () => {
const user = userEvent.setup()
const counterStore = counterStoreCreator()
renderCounterWithContext(counterStore)
expect(counterStore.getState().count).toBe(1)
await user.click(await screen.findByRole('button', { name: /one up/i }))
expect(counterStore.getState().count).toBe(2)
})
})
const renderCounterWithContext = (store) => {
return render(<CounterWithContext />, {
wrapper: ({ children }) => (
<CounterStoreContext.Provider value={store}>{children}</CounterStoreContext.Provider>
),
})
}
参考
- React Testing Library:React Testing Library (RTL) 是一个用于测试 React 组件的非常轻量的方案。它在
react-dom 和 react-dom/test-utils 之上提供了一些工具函数,以鼓励更好的测试实践。它的首要指导原则是:“测试越接近软件的实际使用方式,能给你的信心就越多。”
- Native Testing Library:Native Testing Library (RNTL) 是一个用于测试 React Native 组件的非常轻量的方案,与 RTL 类似,但它的函数构建在
react-test-renderer 之上。
- Testing Implementation Details:Kent C. Dodds 的博客文章,讲述了他为什么建议避免测试实现细节。
演示