React: 单元测试(Vitest + React Testing Library)

最后更新:2026-08-26

Tom 在重构一个旧组件时,"不小心"改了内部状态逻辑,导致依赖该组件的三个页面出现了 UI 异常。因为没有单元测试,这个问题直到 QA 测试时才被发现,浪费了整个团队的迭代时间。Tom 决定给项目引入 Vitest + React Testing Library,用自动化测试确保每次改动都不会破坏已有功能。


1. 你将学到



2. 概念图解

下面的图展示了单元测试在 React 组件开发中的位置和关系:

100%
flowchart LR
    A[编写组件] --> B[编写测试]
    B --> C{运行测试}

    C -->|通过| D[提交代码]
    C -->|失败| E[定位 Bug]

    E --> F{错误类型}
    F -->|渲染问题| G[getByText / getByRole]
    F -->|交互问题| H[userEvent.click]
    F -->|异步问题| I[findByText / waitFor]
    F -->|外部依赖| J[vi.mock / vi.fn]

    G --> A
    H --> A
    I --> A
    J --> A

    style B fill:#e3f2fd,stroke:#1565c0
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#fff3e0,stroke:#e65100


3. 一个真实场景

Tom 的团队有一个 UserCard 组件,负责展示用户信息。它接受 user 对象、onFollow 回调,并根据 isFollowing 状态切换按钮样式。

在一次重构中,Tom 修改了内部状态的初始值,导致 isFollowing 默认变成 true——所有用户卡片默认显示"已关注"。这个 Bug 在产品经理验收时才被发现。

如果当时有测试,这样的回归问题在 npm test 阶段的几秒内就能被发现。Tom 决定为所有核心组件补上单元测试。


(1) 环境配置 —— 搭建测试基础设施

首先安装所有依赖:

BASH
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

配置 Vitest(在 vite.config.ts 中添加 test 字段):

TS
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    // 使用 jsdom 模拟浏览器环境
    environment: 'jsdom',
    // 全局注册 describe / test / expect,无需手动导入
    globals: true,
    // 测试启动前运行的设置文件
    setupFiles: './src/test/setup.ts',
  },
})

创建 setup 文件:

TS
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// 这行让 toBeInTheDocument()、toHaveTextContent() 等断言可用

package.json 中添加测试脚本:

JSON
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

(2) 三大核心 API

React Testing Library 的测试哲学:不测试实现细节,只测试用户能看到和交互的东西。

API 作用 特点
render(component) 将组件渲染到虚拟 DOM 返回容器引用和辅助方法
screen 全局查找元素的入口 提供 getBy / findBy / queryBy 三类方法
userEvent 模拟用户操作 比 fireEvent 更接近真实行为

核心原则:getByRole 优先(语义化),getByText 其次,getByTestId 最后。

▶ 示例 1:测试 Counter 组件的渲染和交互

先编写一个简单的 Counter 组件:

TSX
// Counter.tsx
import { useState } from 'react'

interface CounterProps {
  initialCount?: number
  step?: number
  label?: string
}

export function Counter({
  initialCount = 0,
  step = 1,
  label = '计数',
}: CounterProps) {
  const [count, setCount] = useState(initialCount)

  return (
    <div>
      <p>
        {label}:{count}
      </p>
      <button onClick={() => setCount(c => c + step)}>+{step}</button>
      <button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
        -{step}
      </button>
      {count >= 10 && (
        <p role="alert">已达到最大值提醒</p>
      )}
    </div>
  )
}
▶ 试一试

编写测试:

TSX
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

describe('Counter 组件', () => {
  // 测试 1:初始渲染
  test('显示初始计数值', () => {
    render(<Counter initialCount={5} />)
    // getByText — 通过文本内容查找
    expect(screen.getByText('计数:5')).toBeInTheDocument()
  })

  // 测试 2:点击增加按钮
  test('点击 +1 按钮后计数增加', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} step={1} />)

    const incrementBtn = screen.getByRole('button', { name: '+1' })
    await user.click(incrementBtn)

    expect(screen.getByText('计数:1')).toBeInTheDocument()
  })

  // 测试 3:计数为 0 时减按钮禁用
  test('计数为 0 时减按钮禁用', () => {
    render(<Counter initialCount={0} />)

    const decrementBtn = screen.getByRole('button', { name: '-1' })
    expect(decrementBtn).toBeDisabled()
  })

  // 测试 4:达到阈值时显示提醒
  test('计数达到 10 时显示提醒', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={9} step={1} />)

    // 初始时没有提醒
    expect(screen.queryByRole('alert')).not.toBeInTheDocument()

    // 点击加 1
    await user.click(screen.getByRole('button', { name: '+1' }))

    // 现在有了提醒
    expect(screen.getByRole('alert')).toHaveTextContent('已达到最大值提醒')
  })

  // 测试 5:自定义 label 和 step
  test('支持自定义 label 和 step', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} step={5} label="步数" />)

    expect(screen.getByText('步数:0')).toBeInTheDocument()

    await user.click(screen.getByRole('button', { name: '+5' }))
    expect(screen.getByText('步数:5')).toBeInTheDocument()
  })
})

这段测试展示了四个模式:

  1. getByText — 查找包含指定文本的元素(最简单直接)
  2. getByRole — 通过 ARIA 角色查找,带 name 选项精确匹配按钮文字
  3. queryByRole — 查找可能不存在的元素,返回 null 而不是抛错
  4. toBeDisabled() / toHaveTextContent() — jest-dom 提供的语义化断言

(3) 测试 Props 和事件回调

组件通常通过 Props 接收数据和回调函数。测试的目标是验证回调是否被正确调用,以及参数是否正确。

▶ 示例 2:测试 TodoItem 组件的 Props 和事件

TSX 📖 仅展示
// TodoItem.tsx
interface Todo {
  id: number
  text: string
  completed: boolean
}

interface TodoItemProps {
  todo: Todo
  onToggle: (id: number) => void
  onDelete: (id: number) => void
}

export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 12,
        padding: '8px 12px',
        background: todo.completed ? '#f6ffed' : '#fff',
        borderRadius: 6,
        border: '1px solid #f0f0f0',
      }}
    >
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        aria-label={`标记 ${todo.text}`}
      />
      <span
        style={{
          flex: 1,
          textDecoration: todo.completed ? 'line-through' : 'none',
          color: todo.completed ? '#999' : '#333',
        }}
      >
        {todo.text}
      </span>
      <button
        onClick={() => onDelete(todo.id)}
        aria-label={`删除 ${todo.text}`}
        style={{
          border: 'none',
          background: '#ff4d4f',
          color: '#fff',
          borderRadius: 4,
          padding: '2px 8px',
          cursor: 'pointer',
          fontSize: 12,
        }}
      >
        删除
      </button>
    </div>
  )
}
逻辑代码 56 行(超过 40 行限制,仅展示)
TSX
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'

describe('TodoItem 组件', () => {
  const mockTodo = {
    id: 42,
    text: '学习 React 测试',
    completed: false,
  }

  test('渲染待办事项文本', () => {
    render(
      <TodoItem
        todo={mockTodo}
        onToggle={vi.fn()}
        onDelete={vi.fn()}
      />
    )

    expect(screen.getByText('学习 React 测试')).toBeInTheDocument()
  })

  test('已完成事项显示删除线', () => {
    render(
      <TodoItem
        todo={{ ...mockTodo, completed: true }}
        onToggle={vi.fn()}
        onDelete={vi.fn()}
      />
    )

    const text = screen.getByText('学习 React 测试')
    expect(text).toHaveStyle('text-decoration: line-through')
  })

  test('点击复选框触发 onToggle', async () => {
    const onToggle = vi.fn()
    const user = userEvent.setup()

    render(
      <TodoItem
        todo={mockTodo}
        onToggle={onToggle}
        onDelete={vi.fn()}
      />
    )

    await user.click(screen.getByRole('checkbox'))
    expect(onToggle).toHaveBeenCalledTimes(1)
    expect(onToggle).toHaveBeenCalledWith(42)  // 验证参数是 todo.id
  })

  test('点击删除按钮触发 onDelete', async () => {
    const onDelete = vi.fn()
    const user = userEvent.setup()

    render(
      <TodoItem
        todo={mockTodo}
        onToggle={vi.fn()}
        onDelete={onDelete}
      />
    )

    await user.click(screen.getByRole('button', { name: /删除/ }))
    expect(onDelete).toHaveBeenCalledWith(42)
  })

  test('未完成事项没有删除线', () => {
    render(
      <TodoItem
        todo={mockTodo}
        onToggle={vi.fn()}
        onDelete={vi.fn()}
      />
    )

    const text = screen.getByText('学习 React 测试')
    // 注意:内联样式的 text-decoration 为 'none',而不是无此属性
    expect(text).not.toHaveStyle('text-decoration: line-through')
  })
})

Mock 函数 vi.fn() 的关键用法:

API 作用
vi.fn() 创建一个空的 Mock 函数
toHaveBeenCalledTimes(n) 验证调用了 n 次
toHaveBeenCalledWith(...) 验证调用时的参数
vi.fn().mockResolvedValue(x) Mock 异步成功返回
vi.fn().mockRejectedValue(e) Mock 异步失败返回

(4) 测试异步组件

很多组件在加载时先显示"加载中...",数据到达后显示内容。需要用 findBy(异步等待)来测试这类场景。

▶ 示例 3:测试异步数据加载组件

TSX 📖 仅展示
// UserProfile.tsx
interface UserProfileProps {
  userId: number
}

interface UserData {
  id: number
  name: string
  email: string
}

// 模拟 API 调用
async function fetchUser(id: number): Promise<UserData> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('加载失败')
  return res.json()
}

export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<UserData | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let cancelled = false

    async function load() {
      setLoading(true)
      setError(null)
      try {
        const data = await fetchUser(userId)
        if (!cancelled) setUser(data)
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : '未知错误')
        }
      } finally {
        if (!cancelled) setLoading(false)
      }
    }

    load()
    return () => { cancelled = true }
  }, [userId])

  if (loading) return <div aria-label="加载中">加载中...</div>
  if (error) return <div role="alert">错误:{error}</div>
  if (!user) return <div>无数据</div>

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}
逻辑代码 46 行(超过 40 行限制,仅展示)
TSX
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'

// Mock 掉 fetchUser 模块
vi.mock('./UserProfile', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    // 重写 fetchUser 的实现
    fetchUser: vi.fn(),
  }
})

// 更好的做法:单独 mock API 模块
// vi.mock('../api', () => ({
//   fetchUser: vi.fn()
// }))

describe('UserProfile 异步组件', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  test('加载时显示加载中', () => {
    // 让 fetch 一直 pending
    vi.spyOn(global, 'fetch').mockImplementation(
      () => new Promise(() => {})  // 永不 resolve
    )

    render(<UserProfile userId={1} />)
    expect(screen.getByLabelText('加载中')).toBeInTheDocument()
  })

  test('加载成功后显示用户信息', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }

    // Mock fetch 返回成功数据
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    } as Response)

    render(<UserProfile userId={1} />)

    // findByText — 异步等待元素出现(默认超时 1000ms)
    expect(await screen.findByText('Alice')).toBeInTheDocument()
    expect(screen.getByText('alice@example.com')).toBeInTheDocument()
  })

  test('加载失败时显示错误信息', async () => {
    // Mock fetch 返回 500
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error',
    } as Response)

    render(<UserProfile userId={1} />)

    // 等待错误提示出现
    expect(await screen.findByRole('alert')).toHaveTextContent('错误:')
  })

  test('组件卸载时不会更新状态(防止内存泄漏)', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
    let resolvePromise!: (value: any) => void

    vi.spyOn(global, 'fetch').mockReturnValue(
      new Promise((resolve) => {
        resolvePromise = resolve
      })
    )

    const { unmount } = render(<UserProfile userId={1} />)
    // 在 fetch 完成前卸载组件
    unmount()

    // 此时 resolve,但组件已卸载,应该不会 setState
    resolvePromise({
      ok: true,
      json: async () => mockUser,
    } as Response)

    // 没有抛错 = 测试通过
  })
})

异步测试的三个核心方法对比:

方法 同步/异步 元素不存在时 默认超时 使用场景
getByText 同步 立即抛错 - 元素一定存在
queryByText 同步 返回 null - 验证元素不存在
findByText 异步(Promise) 超时后抛错 1000ms 等待异步渲染

(5) Mock 外部依赖的最佳实践

真实项目中组件常依赖 API、路由、状态管理。Mock 这些依赖是测试的关键。

方式一:Mock 全局 fetch

TS
// 在每个测试前替换全局 fetch
beforeEach(() => {
  vi.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => ({ data: 'mock' }),
  } as Response)
})

afterEach(() => {
  vi.restoreAllMocks()  // 恢复原始 fetch
})

方式二:Mock 模块

TS
// api.ts — 真实模块
export async function fetchUsers() {
  const res = await fetch('/api/users')
  return res.json()
}

// 测试中 Mock
vi.mock('../api', () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: 'Mock User' },
  ])
}))

方式三:Mock React Router

TSX
import { MemoryRouter } from 'react-router-dom'

test('在路由上下文中渲染', () => {
  render(
    <MemoryRouter initialEntries={['/users/1']}>
      <UserDetailPage />
    </MemoryRouter>
  )
})

▶ 示例 4:测试自定义 Hook

JSX
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial)
  const increment = useCallback(() => setCount(c => c + 1), [])
  const decrement = useCallback(() => setCount(c => c - 1), [])
  const reset = useCallback(() => setCount(initial), [initial])
  return { count, increment, decrement, reset }
}

describe('useCounter', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  test('increments counter', () => {
    const { result } = renderHook(() => useCounter())
    act(() => result.current.increment())
    expect(result.current.count).toBe(1)
  })

  test('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5))
    act(() => result.current.decrement())
    expect(result.current.count).toBe(4)
  })

  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10))
    act(() => result.current.increment())
    act(() => result.current.reset())
    expect(result.current.count).toBe(10)
  })
})
▶ 试一试

▶ 示例 5:集成测试——表单提交流程

JSX 📖 仅展示
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  async function handleSubmit(e) {
    e.preventDefault()
    if (!email.includes('@')) { setError('Invalid email'); return }
    if (password.length < 6) { setError('Password too short'); return }
    setError('')
    await onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
      {error && <p data-testid="error">{error}</p>}
      <button type="submit">Login</button>
    </form>
  )
}

describe('LoginForm integration', () => {
  test('shows error for invalid email', async () => {
    render(<LoginForm onSubmit={jest.fn()} />)
    await userEvent.type(screen.getByTestId('email'), 'invalid')
    await userEvent.type(screen.getByTestId('password'), 'password123')
    await userEvent.click(screen.getByRole('button', { name: /login/i }))
    expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
  })

  test('calls onSubmit with valid data', async () => {
    const onSubmit = jest.fn().mockResolvedValue(undefined)
    render(<LoginForm onSubmit={onSubmit} />)
    await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
    await userEvent.type(screen.getByTestId('password'), 'secure123')
    await userEvent.click(screen.getByRole('button', { name: /login/i }))
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
    })
  })
})
逻辑代码 41 行(超过 40 行限制,仅展示)

❓ 常见问题

Q getBy、findBy、queryBy 三者到底怎么区分?
A 简单判断规则:元素一定存在用 getBy(找不到就抛错);元素可能不存在用 queryBy(返回 null);元素是异步渲染的用 findBy(返回 Promise,等待超时后抛错)。每种方法都有对应的 Role/Text/TestId 变体,如 getByRolefindByTextqueryByTestId
Q userEventfireEvent 应该用哪个?
A 优先用 userEventfireEvent 是底层 API,直接触发 DOM 事件。userEventfireEvent 之上模拟了完整的用户操作序列(如 click 会包含 mousedown → mouseup → click),更接近浏览器真实行为。只有 userEvent 不支持的操作才退回到 fireEvent
Q 测试中应该 Mock 子组件吗?
A React Testing Library 的哲学是"不 Mock 子组件",因为测试应该模拟用户视角,用户能看到的是完整的组件树。只有当子组件有高昂的副作用(如复杂的动画库、第三方图表组件)时才考虑 Mock。用 vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>) 替换即可。
Q beforeEach 中要清理测试环境吗?
A 需要。每次测试后要清理 render 的 DOM 和 Mock。推荐用 afterEach(() => { vi.clearAllMocks() })。Vitest 本身会卸载每次 render 的组件,但 Mock 状态需要手动清理。如果使用了全局 fetch Mock,用 vi.restoreAllMocks() 恢复原始实现。
Q 测试覆盖率要达到多少才够?
A 没有万能标准,但业界参考:核心业务逻辑 80%+,通用工具函数 90%+,UI 组件 60%+。不要追求 100% 覆盖率——有些代码(如 CSS 样式、简单的 prop 传递)测试 ROI 很低。关键是覆盖"出 Bug 后影响最大"的代码路径。Vitest 的 --coverage 参数可以生成 Istanbul 覆盖率报告。

📖 小节


📝 作业

  1. 为一个 Button 组件写完整的测试:验证点击触发 onClickdisabled 时不可点击、显示正确的文本、自定义 className 生效。至少覆盖 4 个测试用例。
  2. 为一个 UserProfile 组件写测试:测试加载状态显示 loading、成功状态显示用户信息(异步加强)、失败状态显示错误信息。用 vi.spyOn Mock fetch API。
  3. 为一个 TodoApp 组件写集成测试(包含 TodoList + TodoItem + AddTodo 表单):测试添加新待办、标记完成、删除待办三个功能,验证列表 UI 正确更新。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏