适用项目框架:Vite + React + Vitest
前言
在现代前端开发中,单元测试是提升代码质量、减少回归 bug、增强重构信心的关键一环。尤其在 Vite + React 的技术栈中,Vitest 作为官方推荐的测试工具,凭借其原生支持 Vite、极速启动、与 Jest 高度兼容等特性,已成为 React 项目单元测试的首选。
本文将带你从零搭建一套完整、高效、可维护的单元测试体系,覆盖:
- 依赖安装与 IDE 配置
-
Vitest 核心配置
- 工具库核心概念
- 四大核心测试场景(utils、hooks、components、redux)
- 常见踩坑与解决方案
- 最佳实践与团队落地建议
适合人群:有一定 React 基础,想系统性引入单元测试的前端开发者
1. 安装依赖和 VSCode 插件
npm install --save-dev vitest jsdom @vitest/coverage-v8 @testing-library/dom @testing-library/jest-dom @testing-library/react @testing-library/user-event @testing-library/react-hooks
依赖说明
VSCode 插件推荐
-
Vitest
提供测试文件高亮、点击运行、调试支持。

-
Vitest Runner
在编辑器侧边栏显示测试树,支持单测运行、断点调试。

2. 增加 test 配置
仅供参考,可根据项目结构灵活调整
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// 使用 jsdom 模拟浏览器环境
environment: 'jsdom',
// 全局 setup 文件
setupFiles: './src/tests/setup.ts',
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'clover'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/assets/**',
'src/constants/**',
'src/enums/**',
'src/i18n/**',
'src/styles/**',
'src/apis/**',
'src/main.tsx',
'src/vite-env.d.ts'
],
},
},
})
src/tests/setup.ts
// 引入 jest-dom 匹配器, 确保 `toBeInTheDocument` 以及其他来自 `@testing-library/jest-dom` 的匹配器在 Vitest 中正常使用。
import '@testing-library/jest-dom/vitest'
// 自动清理 DOM,防止测试间污染
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
// 可选:全局 mock console.error 避免干扰
// vi.spyOn(console, 'error').mockImplementation(() => {})
3. 重要概念
| 概念 |
说明 |
最佳实践 |
| AAA 模式 |
Arrange(准备)→ Act(操作)→ Assert(断言) |
结构清晰,避免测试逻辑混乱 |
| 用户视角测试 |
不关心实现细节,只关心“用户能否看到/操作” |
使用 screen.getByRole 而非 getByTestId
|
| 快照测试 |
捕获 UI 结构,防止意外变更 |
仅用于稳定 UI,动态内容慎用 |
| Mock |
隔离外部依赖(API、网络、定时器) |
使用 vi.mock 或 msw
|
| 集成测试 vs 单元测试 |
单元:单函数/组件;集成:多模块协作 |
Redux 推荐集成测试 |
| act() |
确保状态更新完成后再断言 |
涉及状态变更时必须使用 |
4. 一些基础的测试示例
1. utils 函数测试
何时需要测试
- 纯逻辑函数(无副作用)
- 业务复杂、易出错
- 被多个组件复用
何时不要测试
- 简单 getter/setter
- 仅做类型转换
- 已被组件测试覆盖
源代码 src/utils/sumDemo.ts
export function sum(a: number, b: number) {
return a + b
}
测试代码 sumDemo.test.ts
import { expect, test } from 'vitest'
import { sum } from './sumDemo'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
test('adds 10 + 2', () => {
expect(sum(10, 2)).toBe(12)
})
2. 自定义 Hooks 测试
何时需要测试
- 独立封装的可复用 Hook
- 包含复杂状态逻辑
- 难以通过组件测试覆盖
何时不要测试
- 组件内部定义的 Hook
- 简单
useState 包装
- 已有组件集成测试覆盖
源代码 useCounter.ts
import { useCallback, useState } from 'react'
function useCounter(initial = 0) {
const [count, setCount] = useState<number>(initial)
const increment = useCallback(() => setCount(x => x + 1), [])
const decrement = useCallback(() => setCount(x => x - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}
export default useCounter
测试代码 useCounter.test.ts
import { act, renderHook } from '@testing-library/react'
import { expect, test } from 'vitest'
import useCounter from './useCounter'
test('should increment counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
test('should support initial value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(5)
})
3. 组件测试
何时需要测试
何时不要测试
- 纯展示组件(无状态)
- 样式细节(用 Storybook)
- 第三方组件包装(除非有自定义逻辑)
源代码 MyButton.tsx
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
interface MyButtonProps extends ButtonProps {
text: string
}
const MyButton = ({ text, onClick, ...rest }: MyButtonProps) => {
return (
<Button {...rest} onClick={onClick}>
{text}
</Button>
)
}
export default MyButton
测试代码 MyButton.test.tsx
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import MyButton from './MyButton'
describe('MyButton', () => {
test('renders with correct text', () => {
render(<MyButton text="Submit" />)
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
})
test('calls onClick when clicked', async () => {
const handleClick = vi.fn()
render(<MyButton text="Click me" onClick={handleClick} />)
await userEvent.click(screen.getByRole('button', { name: 'Click me' }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
test('applies disabled state', () => {
render(<MyButton text="Disabled" disabled />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
推荐使用 getByRole 而非 getByText,更符合无障碍规范
4. Redux Store 测试
参考官方原则:Redux Testing
何时需要测试
何时不要测试
- 模拟
useSelector/useDispatch
- 测试 React-Redux 内部实现
- 简单 action creator
工具函数 renderWithProviders.ts
import { configureStore } from '@reduxjs/toolkit'
import type { RenderOptions } from '@testing-library/react'
import { render } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { Provider } from 'react-redux'
import type { AppStore, RootState } from '@/store'
import rootReducer from '@/store'
export const setupStore = (preloadedState?: Partial<RootState>): AppStore => {
return configureStore({
reducer: rootReducer,
preloadedState,
})
}
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>
store?: AppStore
}
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
store = setupStore(preloadedState),
...renderOptions
}: ExtendedRenderOptions = {}
) {
const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}
集成测试示例 UserDisplay.tsx
import { Button } from 'antd'
import { useAppDispatch, useAppSelector } from '@/store'
import { setAccount } from '@/store/accountSlice'
export default function UserDisplay() {
const userName = useAppSelector(state => state.account.name)
const dispatch = useAppDispatch()
return (
<div>
<div data-testid="username">{userName}-test</div>
<Button onClick={() => dispatch(setAccount('new user'))}>
Switch User
</Button>
</div>
)
}
// UserDisplay.test.tsx
import { screen } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { renderWithProviders } from '@/tests/renderWithProviders'
import UserDisplay from './UserDisplay'
describe('UserDisplay Integration', () => {
test('renders initial username', () => {
renderWithProviders(<UserDisplay />, {
preloadedState: { account: { name: 'John' } }
})
expect(screen.getByTestId('username')).toHaveTextContent('John-test')
})
test('updates username on button click', () => {
renderWithProviders(<UserDisplay />)
screen.getByText('Switch User').click()
expect(screen.getByTestId('username')).toHaveTextContent('new user-test')
})
})
Reducer 单元测试 todosSlice.ts
// slice
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
todoAdded(state, action: PayloadAction<string>) {
const id = state.length ? Math.max(...state.map(t => t.id)) + 1 : 0
state.push({ id, text: action.payload, completed: false })
}
}
})
// todosSlice.test.ts
test('should add todo with correct id', () => {
const state = [{ id: 5, text: 'Existing', completed: false }]
const newState = todosReducer(state, todoAdded('New'))
expect(newState).toHaveLength(2)
expect(newState[1]).toEqual({ id: 6, text: 'New', completed: false })
})
5. 踩坑记录
| 问题 |
原因 |
解决方案 |
toBeInTheDocument is not a function |
未引入 @testing-library/jest-dom
|
在 setup.ts 中 import '@testing-library/jest-dom/vitest'
|
| 测试间状态污染 |
未清理 DOM |
afterEach(cleanup) |
act 警告 |
状态更新未包裹 |
使用 act(() => { ... }) 或 await userEvent
|
覆盖率包含 node_modules
|
配置错误 |
使用 exclude 排除 |
vi.mock 不生效 |
ESM 模块提升 |
使用 vi.mock('./module', { partial: true })
|
| 异步组件未渲染 |
缺少 findBy
|
使用 await screen.findByText(...)
|
以下是一些更细节的踩坑记录📝
1. vitest的模块模拟机制提升问题
1. 报错信息:
There was an error when mocking a module. If you are using “vi.mock” factory, make sure thre are no top level variables inside, since this call is hoisted to the top of th file.
about vitest ---- record error1:
2.错误写法:
// 获取 selector 函数的 mock 引用
const mockSelectSolutionFinishedSelection = vi.fn();
const mockSelectSolutionValidateErrorSubSelections = vi.fn();
// Mock store 模块
vi.mock("@/store", async (importOriginal) => {
const actual = await importOriginal<typeof storeHooks>();
return {
...actual,
useAppSelector: vi.fn((selector) => selector()), // 动态调用 selector 函数
selectSolution_finishedSelection: mockSelectSolutionFinishedSelection,
selectSolution_validateErrorSubSelections: mockSelectSolutionValidateErrorSubSelections,
}
})
3.错误解析:
Vitest的模块模拟机制会将vi.mock提升到文件顶部,导致这些变量在初始化之前被访问,从而引发错误。
4.解决措施:
// 将 mock 变量提升到模块顶层
const mockSelectSolutionFinishedSelection = vi.fn()
const mockSelectSolutionValidateErrorSubSelections = vi.fn()
// Mock store 模块
vi.mock("@/store", async (importOriginal) => {
const actual = await importOriginal<typeof storeHooks>()
return {
...actual,
// 关键修改:让 useAppSelector 根据 selector 动态返回 mock 值
useAppSelector: vi.fn((selector) => {
switch (selector) {
case actual.selectSolution_finishedSelection:
return mockSelectSolutionFinishedSelection()
case actual.selectSolution_validateErrorSubSelections:
return mockSelectSolutionValidateErrorSubSelections()
default:
return selector()
}
}),
}
})
5.小结:
Vitest 的模块模拟机制会 提升(hoist)所有 vi.mock 调用到文件顶部,导致:
1.这些变量会在模块初始化前被访问
2.不同测试用例之间会共享同一个 mock 引用
最佳实践原则
- 永远不要在 vi.mock 工厂函数内部定义新变量
- 需要 mock 的变量应该提升到模块顶层
- 通过函数引用而非字符串匹配来识别 selector
- 如果需要保留原模块的某些功能,使用 importOriginal 获取原始引用
2. 谨慎使用 vi.spyOn
问题现象
// ❌ 无法工作
const mockUseSolutionType = vi.spyOn(require('../solutionBasehooks'), 'useSolutionType');
// ✅ 可以工作
import { useSolutionType } from '../solutionBasehooks';
vi.mocked(useSolutionType).mockReturnValue(...);
核心原因
模块系统差异和ESM/CJS 兼容性导致:
-
vi.spyOn 的局限性:
- 仅适用于 对象上的方法(如
obj.method)
- 无法直接监视 ES 模块的默认导出函数(因为 ES 模块导出是静态的、不可变的)
- 当使用
require() 导入时,可能获取到未 mock 的原始模块引用
-
vi.mocked 的优势:
- 专为 TypeScript 设计,能与
vi.mock 的模块替换机制完美配合
- 通过
import 语句获取的是已经被 Vitest 处理过的 mock 引用
- 自动继承 TypeScript 类型提示
正确做法
// ✅ 推荐方式:使用 vi.mock + vi.mocked
vi.mock('../solutionBasehooks', () => ({
useSolutionType: vi.fn()
}));
import { useSolutionType } from '../solutionBasehooks';
// 类型安全且可靠的 mock
vi.mocked(useSolutionType).mockReturnValue(SolutionEnum.Haipick3);
6. 一些心得与最佳实践
测试金字塔
👑 E2E (少量)
🏃 集成测试 (适量)
👤 单元测试 (大量)
推荐策略
-
80% 单元测试:utils、hooks、纯逻辑
-
15% 集成测试:组件 + Redux 状态流
-
5% E2E:核心用户流程
单元测试原则
1.单一职责:每个测试应该只测试一个功能点。
不推荐:一个测试测试多个功能点
test('Button renders correctly and handles click', () => {
// 测试渲染
// 测试点击事件
});
推荐:拆分为多个测试
test('Button renders correctly', () => {
// 测试渲染
});
test('Button handles click event', () => {
// 测试点击事件
});
2.独立性:测试应该相互独立,不依赖于其他测试的执行顺序或结果。
3.可读性:测试代码应该清晰易读,测试名称应描述被测试的行为。
不推荐:测试名称不清晰
test('test button', () => {
// ...
});
推荐:测试名称清晰描述行为
test('Button should be disabled when isDisabled prop is true', () => {
// ...
});
4.快速:单元测试应该执行迅速,以便频繁运行。
命名规范
sum.test.ts
useCounter.test.ts
MyButton.render.test.tsx
MyButton.interaction.test.tsx
CI/CD 集成
# .github/workflows/test.yml
- name: Run Tests
run: npm run test:cov
- name: Upload Coverage
uses: codecov/codecov-action@v3
团队落地建议
- 可以尝试先从 utils 和 hooks 开始写测试
- 新功能必须带测试
- 重构时补充测试
- 考虑使用
vitest --watch 本地开发
结语:测试不仅仅是验证代码正确性,更是推动我们写出更好代码的设计工具。如果你还没有在项目中系统引入单元测试,现在就是最好的时机。本文提供了从环境搭建到实战示例的完整路径,覆盖了前端测试最常见的场景。不要被"完美测试"所束缚——从为一个工具函数写测试开始,从为关键业务组件添加防护开始吧~