阅读视图

发现新文章,点击刷新页面。

前端测试不再难:Vite+React+Vitest单元测试完整手册

适用项目框架: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

依赖说明

库名称 简介
vitest 由 Vite 提供原生支持的高性能测试框架,支持 ESM、TypeScript、快照、mock 等
jsdom 在 Node.js 中模拟浏览器 DOM 环境,实现了 DOM 和 HTML 标准
@vitest/coverage-v8 Vitest 的 V8 代码覆盖率插件,用于收集测试覆盖率数据
@testing-library/dom 核心 DOM 查询与交互工具,强调“用户视角”测试
@testing-library/jest-dom 提供 toBeInTheDocumenttoHaveClass 等语义化断言
@testing-library/react 在 DOM Testing Library 基础上构建,提供测试 React 组件的轻量级工具
@testing-library/user-event 模拟真实用户行为(如点击、输入、hover)
@testing-library/react-hooks 用于测试 React hooks 的工具库

VSCode 插件推荐

  1. Vitest
    提供测试文件高亮、点击运行、调试支持。

    Vitest Plugin

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

    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.mockmsw
集成测试 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. 组件测试

何时需要测试

  • 核心 UI 组件
  • 包含交互逻辑
  • 状态变化影响渲染

何时不要测试

  • 纯展示组件(无状态)
  • 样式细节(用 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

何时需要测试

  • 复杂 reducer 逻辑
  • 集成测试验证状态流

何时不要测试

  • 模拟 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.tsimport '@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 引用

最佳实践原则

  1. 永远不要在 vi.mock 工厂函数内部定义新变量
  2. 需要 mock 的变量应该提升到模块顶层
  3. 通过函数引用而非字符串匹配来识别 selector
  4. 如果需要保留原模块的某些功能,使用 importOriginal 获取原始引用

2. 谨慎使用 vi.spyOn

问题现象
// ❌ 无法工作
const mockUseSolutionType = vi.spyOn(require('../solutionBasehooks'), 'useSolutionType');

// ✅ 可以工作
import { useSolutionType } from '../solutionBasehooks';
vi.mocked(useSolutionType).mockReturnValue(...);
核心原因

模块系统差异ESM/CJS 兼容性导致:

  1. vi.spyOn 的局限性

    • 仅适用于 对象上的方法(如 obj.method
    • 无法直接监视 ES 模块的默认导出函数(因为 ES 模块导出是静态的、不可变的)
    • 当使用 require() 导入时,可能获取到未 mock 的原始模块引用
  2. 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 (少量)
   🏃  集成测试 (适量)
 👤  单元测试 (大量)

推荐策略

  1. 80% 单元测试:utils、hooks、纯逻辑
  2. 15% 集成测试:组件 + Redux 状态流
  3. 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 本地开发

结语:测试不仅仅是验证代码正确性,更是推动我们写出更好代码的设计工具。如果你还没有在项目中系统引入单元测试,现在就是最好的时机。本文提供了从环境搭建到实战示例的完整路径,覆盖了前端测试最常见的场景。不要被"完美测试"所束缚——从为一个工具函数写测试开始,从为关键业务组件添加防护开始吧~

❌