Skip to content

测试

¥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:

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.

设置 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 — createStorecreate

¥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-jestts-node

¥Note: to use TypeScript we need to install two packages ts-jest and ts-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

参考资料

¥References

  • React 测试库:React 测试库 (RTL) 是一种非常轻量级的 React 组件测试解决方案。它在 react-domreact-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 and react-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.

Zustand v5.0 中文网 - 粤ICP备13048890号