• 简体中文
  • 测试

    搭建测试环境

    测试运行器

    通常,你需要配置测试运行器,让它能够运行 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 你的应用逻辑。

    为测试配置 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 的博客文章,讲述了他为什么建议避免测试实现细节。

    演示