主题
测试
¥Testing
设置测试环境
¥Setting Up a Test Environment
测试运行器
¥Test Runners
通常,你的测试运行器需要配置为运行 JavaScript/TypeScript 语法。如果你要测试 UI 组件,则可能需要配置测试运行器以使用 JSDOM 来提供模拟 DOM 环境。
¥Usually, your test runner needs to be configured to run JavaScript/TypeScript syntax. If you're going to be testing UI components, you will likely need to configure the test runner to use JSDOM to provide a mock DOM environment.
有关测试运行器配置说明,请参阅这些资源:
¥See these resources for test runner configuration instructions:
Jest
Vitest
UI 和网络测试工具
¥UI and Network Testing Tools
我们建议使用 React 测试库 (RTL) 来测试连接到 Zustand 的 React 组件。RTL 是一个简单而完整的 React DOM 测试实用程序,鼓励良好的测试实践。它使用 ReactDOM 的 render
函数和 react-dom/tests-utils
中的 act
。此外,原生测试库 (RNTL) 是 RTL 的替代方案,用于测试 React Native 组件。测试库 系列工具还包括许多其他流行框架的适配器。
¥We recommend using React Testing Library (RTL) to test out React components that connect to Zustand. RTL is a simple and complete React DOM testing utility that encourages good testing practices. It uses ReactDOM's render
function and act
from react-dom/tests-utils
. Futhermore, Native Testing Library (RNTL) is the alternative to RTL to test out React Native components. The Testing Library family of tools also includes adapters for many other popular frameworks.
我们还建议使用 Mock Service Worker (MSW) 来模拟网络请求,因为这意味着在编写测试时不需要更改或模拟你的应用逻辑。
¥We also recommend using Mock Service Worker (MSW) to mock network requests, as this means your application logic does not need to be changed or mocked when writing tests.
React 测试库 (DOM)
¥React Testing Library (DOM)
原生测试库 (React Native)
¥Native Testing Library (React Native)
用户事件测试库 (DOM)
¥User Event Testing Library (DOM)
Jest 的 TypeScript
¥TypeScript for Jest
Node 的 TypeScript
¥TypeScript for Node
模拟服务工作者
¥Mock Service Worker
设置 Zustand 进行测试
¥Setting Up Zustand for testing
注意:由于 Jest 和 Vitest 略有不同,例如 Vitest 使用 ES 模块,而 Jest 使用 CommonJS 模块,因此如果你使用 Vitest 而不是 Jest,则需要记住这一点。
¥Note: Since Jest and Vitest have slight differences, like Vitest using ES modules and Jest using CommonJS modules, you need to keep that in mind if you are using Vitest instead of Jest.
下面提供的模拟将使相关测试运行器能够在每次测试后重置 zustand 存储。
¥The mock provided below will enable the relevant test runner to reset the zustand stores after each test.
仅用于测试目的的共享代码
¥Shared code just for testing purposes
添加此共享代码是为了避免在我们的演示中出现代码重复,因为我们对两个实现使用相同的计数器存储创建器,分别带有和不带有 Context
API — createStore
和 create
。
¥This shared code was added to avoid code duplication in our demo since we use the same counter store creator for both implementations, with and without Context
API — createStore
and create
, respectively.
ts
// 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。
¥In the next steps we are going to setup our Jest environment in order to mock Zustand.
ts
// __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()
})
})
})
ts
// setup-jest.ts
import '@testing-library/jest-dom'
ts
// 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
。¥Note: to use TypeScript we need to install two packages
ts-jest
andts-node
.
Vitest
在接下来的步骤中,我们将设置我们的 Vitest 环境以模拟 Zustand。
¥In the next steps we are going to setup our Vitest environment in order to mock Zustand.
警告:在 Vitest 中,你可以更改 root。因此,你需要确保在正确的位置创建
__mocks__
目录。假设你将根更改为./src
,这意味着你需要在./src
下创建一个__mocks__
目录。最终结果将是./src/__mocks__
,而不是./__mocks__
。在使用 Vitest 时,在错误的位置创建__mocks__
目录会导致问题。¥Warning: In Vitest you can change the root. Due to that, you need make sure that you are creating your
__mocks__
directory in the right place. Let's say that you change the root to./src
, that means you need to create a__mocks__
directory under./src
. The end result would be./src/__mocks__
, rather than./__mocks__
. Creating__mocks__
directory in the wrong place can lead to issues when using Vitest.
ts
// __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()
})
})
})
注意:如果不启用 全局配置,我们需要在顶部添加
import { afterEach, vi } from 'vitest'
。¥Note: without globals configuration enabled, we need to add
import { afterEach, vi } from 'vitest'
at the top.
ts
// global.d.ts
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />
注意:如果不启用 全局配置,我们需要删除
/// <reference types="vitest/globals" />
。¥Note: without globals configuration enabled, we do need to remove
/// <reference types="vitest/globals" />
.
ts
// setup-vitest.ts
import '@testing-library/jest-dom'
vi.mock('zustand') // to make it work like Jest (auto-mocking)
注意:如果不启用 全局配置,我们需要在顶部添加
import { vi } from 'vitest'
。¥Note: without globals configuration enabled, we need to add
import { vi } from 'vitest'
at the top.
ts
// 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'],
},
}),
)
测试组件
¥Testing components
在接下来的例子中,我们将使用 useCounterStore
¥In the next examples we are going to use useCounterStore
注意:所有这些示例都是使用 TypeScript 编写的。
¥Note: all of these examples are written using TypeScript.
ts
// 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 })),
})
ts
// stores/use-counter-store.ts
import { create } from 'zustand'
import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'
export const useCounterStore = create<CounterStore>()(counterStoreCreator)
tsx
// 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>(null)
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)
}
tsx
// 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>
)
}
ts
// components/counter/index.ts
export * from './counter'
tsx
// 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 />)
}
tsx
// 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>
)
}
tsx
// components/counter-with-context/index.ts
export * from './counter-with-context'
tsx
// 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'
。¥Note: without globals configuration enabled, we need to add
import { describe, test, expect } from 'vitest'
at the top of each test file.
CodeSandbox 演示
¥CodeSandbox Demos
Jest 演示:https://stackblitz.com/edit/jest-zustand
¥Jest Demo: https://stackblitz.com/edit/jest-zustand
Vitest 演示:https://stackblitz.com/edit/vitest-zustand
¥Vitest Demo: https://stackblitz.com/edit/vitest-zustand
参考资料
¥References
React 测试库:React 测试库 (RTL) 是一种非常轻量级的 React 组件测试解决方案。它在
react-dom
和react-dom/test-utils
之上提供了实用函数,以鼓励更好的测试实践。其主要指导原则是:"你的测试越像你的软件使用方式,它们就越能给你信心。"¥React Testing Library: React Testing Library (RTL) is a very lightweight solution for testing React components. It provides utility functions on top of
react-dom
andreact-dom/test-utils
, in a way that encourages better testing practices. Its primary guiding principle is: "The more your tests resemble the way your software is used, the more confidence they can give you."原生测试库:原生测试库 (RNTL) 是一种非常轻量级的测试 React Native 组件的解决方案,类似于 RTL,但其功能建立在
react-test-renderer
之上。¥Native Testing Library: Native Testing Library (RNTL) is a very lightweight solution for testing React Native components, similarly to RTL, but its functions are built on top of
react-test-renderer
.测试实现细节:Kent C. 的博客文章Dodds 解释了为什么他建议避免使用 测试实现细节。
¥Testing Implementation Details: Blog post by Kent C. Dodds on why he recommends to avoid testing implementation details.