Skip to content

组件测试

什么是组件测试

组件测试是介于单元测试和 E2E 测试之间的一种测试方式,专注于验证 UI 组件在隔离环境中的渲染结果、用户交互行为和状态变化。它在模拟的 DOM 环境(如 jsdom 或 happy-dom)中运行,不需要真实浏览器,但能有效验证组件对外表现的正确性。

组件测试在测试金字塔中的位置:

                    /\
                   /  \
                  / E2E \              Playwright / Cypress
                 /________\            真实浏览器、完整流程
                /          \
               /  组件测试   \          Testing Library + Vitest/Jest
              /______________\         模拟 DOM、用户视角
             /                \
            /    单元测试       \       纯函数、工具方法
           /____________________\      无 DOM 依赖

组件测试的核心目标:

                    ┌───────────────┐
  Props ──────────▶ │               │ ──────────▶ 渲染输出(DOM)
                    │   Component   │
  User Events ───▶  │               │ ──────────▶ 回调触发(Events)
                    │               │
  Context/Store ──▶ │               │ ──────────▶ 副作用(API Calls)
                    └───────────────┘

我们关注的是组件的输入输出,而不是内部实现细节。输入包括 Props、用户事件、Context 等,输出包括渲染的 DOM 结构、触发的回调、发起的网络请求等。

与单元测试的关键区别

维度单元测试组件测试
测试对象纯函数、工具方法UI 组件
运行环境Node.jsjsdom / happy-dom
DOM 依赖需要模拟 DOM
关注点输入 → 输出的逻辑正确性用户视角的行为正确性
交互测试不涉及点击、输入、表单提交
断言方式toBetoEqualtoBeInTheDocumenttoHaveTextContent
测试粒度最小可测试单元组件及其子组件的协作

Testing Library 的核心理念

"The more your tests resemble the way your software is used, the more confidence they can give you."

—— Kent C. Dodds

这句话是 Testing Library 整个生态的设计哲学。它意味着:

  1. 不要测试实现细节:不要断言 state 的值、不要检查组件实例的方法
  2. 以用户视角编写测试:用户看到什么、用户能操作什么,就测什么
  3. 通过可访问性查询元素:优先使用 Role、Label 而不是 CSS 选择器或 TestId
传统测试思维 vs Testing Library 思维:

传统思维(开发者视角)                Testing Library 思维(用户视角)
─────────────────────               ─────────────────────────────

wrapper.instance().state.count      screen.getByText('Count: 0')
wrapper.find('.btn-submit')         screen.getByRole('button', { name: '提交' })
wrapper.vm.$emit('click')          await userEvent.click(submitButton)
wrapper.setProps({ visible: true }) render(<Modal visible={true} />)
expect(wrapper.vm.isOpen).toBe(true) expect(screen.getByRole('dialog')).toBeVisible()

React Testing Library

React Testing Library(RTL)是 React 生态中最主流的组件测试方案,它是 Testing Library 家族在 React 领域的具体实现。RTL 构建在 @testing-library/dom 之上,提供了与 React 组件交互的 API。

安装与配置

核心依赖包:

bash
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
依赖关系图:

@testing-library/react

  ├── @testing-library/dom        (核心 DOM 查询引擎)
  │     └── @testing-library/user-event  (用户交互模拟)

  ├── react-dom/test-utils        (React 官方测试工具)

  └── @testing-library/jest-dom   (自定义 Jest 匹配器)
        └── toBeInTheDocument()
        └── toHaveTextContent()
        └── toBeVisible()
        └── toBeDisabled()
        └── toHaveAttribute()
        └── toHaveClass()
        └── toHaveStyle()

Vitest 配置示例(vitest.config.ts):

typescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    css: true,
  },
})

全局 setup 文件(src/test/setup.ts):

typescript
import '@testing-library/jest-dom/vitest'

render 与 screen

render 是 RTL 的核心 API,它将 React 组件渲染到模拟的 DOM 中。screen 对象提供了查询渲染结果的统一入口。

typescript
import { render, screen } from '@testing-library/react'

function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}!</h1>
}

test('renders greeting', () => {
  render(<Greeting name="World" />)
  expect(screen.getByText('Hello, World!')).toBeInTheDocument()
})

render 的返回值:

typescript
const {
  container,
  baseElement,
  debug,
  rerender,
  unmount,
  asFragment,
} = render(<MyComponent />)
返回值用途
container渲染组件的父 DOM 节点
debug()打印当前 DOM 结构到控制台
rerender(<C newProp />)用新 props 重新渲染组件
unmount()卸载组件,测试 cleanup 逻辑
asFragment()返回 DocumentFragment,用于快照测试

为什么推荐用 screen 而不是解构查询方法?

typescript
const { getByText } = render(<MyComponent />)
getByText('hello')

screen.getByText('hello')

使用 screen 的好处:

  1. 无需在每个测试中解构,代码更简洁
  2. 当多次 render 时不会产生命名冲突
  3. screen.debug() 随时可用,调试方便
  4. 社区规范推荐,代码一致性更好

查询 API 体系

RTL 的查询 API 是其最核心的设计。三种查询变体与八种查询类型的组合构成了完整的查询矩阵。

三种查询变体

┌─────────────┬───────────────────┬──────────────┬──────────────────────┐
│   变体       │  无匹配时          │  多个匹配时   │  返回类型             │
├─────────────┼───────────────────┼──────────────┼──────────────────────┤
│  getBy...   │  ❌ 抛出异常       │  ❌ 抛出异常  │  HTMLElement          │
│  queryBy... │  ✅ 返回 null     │  ❌ 抛出异常  │  HTMLElement | null   │
│  findBy...  │  ❌ 抛出异常(异步) │  ❌ 抛出异常  │  Promise<HTMLElement> │
├─────────────┼───────────────────┼──────────────┼──────────────────────┤
│  getAllBy   │  ❌ 抛出异常       │  ✅ 返回数组  │  HTMLElement[]        │
│  queryAllBy │  ✅ 返回 []       │  ✅ 返回数组  │  HTMLElement[]        │
│  findAllBy  │  ❌ 抛出异常(异步) │  ✅ 返回数组  │  Promise<Element[]>   │
└─────────────┴───────────────────┴──────────────┴──────────────────────┘

使用场景对比:

选择查询变体的决策树:

            元素是否应该存在于 DOM 中?
                 /          \
               是             否
              /                \
         是否异步出现?       用 queryBy...
          /       \           断言它不存在
        是         否
        /           \
   用 findBy...   用 getBy...
   (自带等待)     (同步断言)

查询类型与优先级

RTL 定义了明确的查询优先级,核心原则是:越接近用户感知的查询方式,优先级越高

查询优先级(从高到低):

优先级 1:可访问性查询(所有用户都能感知)
  ├── getByRole          ← 最推荐!基于 ARIA Role
  ├── getByLabelText     ← 表单元素首选
  └── getByPlaceholderText ← 表单备选

优先级 2:语义化查询(视觉用户能感知)
  ├── getByText          ← 非交互元素首选
  └── getByDisplayValue  ← 已填充的表单元素

优先级 3:Test ID(用户无法感知)
  └── getByTestId        ← 最后手段

各查询类型的详细用法:

getByRole — 最推荐的查询方式

typescript
screen.getByRole('button', { name: '提交' })

screen.getByRole('heading', { level: 2 })

screen.getByRole('checkbox', { checked: true })

screen.getByRole('textbox', { name: '用户名' })

screen.getByRole('combobox', { name: '城市选择' })

screen.getByRole('dialog')

screen.getByRole('navigation')

screen.getByRole('alert')

常见 HTML 元素与 ARIA Role 的映射:

HTML 元素隐含 Role查询方式
<button>buttongetByRole('button')
<a href>linkgetByRole('link')
<input type="text">textboxgetByRole('textbox')
<input type="checkbox">checkboxgetByRole('checkbox')
<input type="radio">radiogetByRole('radio')
<select>comboboxgetByRole('combobox')
<textarea>textboxgetByRole('textbox')
<h1> ~ <h6>headinggetByRole('heading', { level: N })
<ul> / <ol>listgetByRole('list')
<li>listitemgetByRole('listitem')
<nav>navigationgetByRole('navigation')
<img>imggetByRole('img')

getByLabelText — 表单元素首选

typescript
screen.getByLabelText('邮箱地址')

screen.getByLabelText('密码')

对应的 HTML 结构:

html
<label for="email">邮箱地址</label>
<input id="email" type="email" />

<label>
  密码
  <input type="password" />
</label>

getByText — 非交互元素首选

typescript
screen.getByText('欢迎使用')

screen.getByText(/欢迎/)

screen.getByText((content, element) => {
  return element?.tagName === 'SPAN' && content.startsWith('价格')
})

getByTestId — 最后的手段

typescript
screen.getByTestId('custom-chart')
html
<div data-testid="custom-chart">
  <canvas></canvas>
</div>

用户交互:userEvent

@testing-library/user-event 提供了比 fireEvent 更真实的用户交互模拟。它会模拟完整的事件链路,而不是仅仅触发单个事件。

fireEvent vs userEvent 的事件链路差异:

fireEvent.click(button):
  click ──▶ 完毕

userEvent.click(button):
  pointerover ──▶ pointerenter ──▶ mouseover ──▶ mouseenter
  ──▶ pointermove ──▶ mousemove ──▶ pointerdown ──▶ mousedown
  ──▶ focus ──▶ pointerup ──▶ mouseup ──▶ click ──▶ 完毕

使用方式:

typescript
import userEvent from '@testing-library/user-event'

test('form interaction', async () => {
  const user = userEvent.setup()

  render(<LoginForm />)

  await user.type(screen.getByLabelText('用户名'), 'alice')
  await user.type(screen.getByLabelText('密码'), 'password123')
  await user.click(screen.getByRole('button', { name: '登录' }))
})

常用交互 API:

API用途示例
user.click(element)点击await user.click(button)
user.dblClick(element)双击await user.dblClick(card)
user.type(element, text)逐字输入await user.type(input, 'hello')
user.clear(element)清空输入框await user.clear(input)
user.selectOptions(select, values)下拉选择await user.selectOptions(select, ['opt1'])
user.upload(input, file)文件上传await user.upload(fileInput, file)
user.tab()Tab 键切换焦点await user.tab()
user.keyboard('{Enter}')键盘输入await user.keyboard('{Escape}')
user.hover(element)鼠标悬浮await user.hover(tooltip)
user.unhover(element)鼠标移出await user.unhover(tooltip)

注意:推荐使用 userEvent.setup() 创建实例,而不是直接调用 userEvent.click()。setup 模式确保所有交互共享同一个输入状态(如键盘修饰键、剪贴板等)。

异步测试

组件经常涉及异步操作:数据加载、定时器、动画等。RTL 提供了 waitForfindBy* 来处理异步场景。

typescript
import { render, screen, waitFor } from '@testing-library/react'

test('loads user data', async () => {
  render(<UserProfile userId={1} />)

  expect(screen.getByText('加载中...')).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument()
  })
})

test('loads user data (findBy)', async () => {
  render(<UserProfile userId={1} />)

  expect(screen.getByText('加载中...')).toBeInTheDocument()

  const userName = await screen.findByText('Alice')
  expect(userName).toBeInTheDocument()
})

waitFor vs findBy* 的选择:

┌────────────┬──────────────────────────────┬────────────────────────────┐
│            │         waitFor              │         findBy*            │
├────────────┼──────────────────────────────┼────────────────────────────┤
│ 功能       │ 等待回调函数不再抛出异常      │ 等待元素出现在 DOM 中       │
│ 灵活度     │ 高,可以放任意断言            │ 只能等待单个元素出现         │
│ 底层实现   │ 轮询执行回调                  │ 内部就是 waitFor + getBy   │
│ 适合场景   │ 复杂条件等待、多个断言         │ 简单地等待元素出现          │
│ 超时默认   │ 1000ms                       │ 1000ms                    │
└────────────┴──────────────────────────────┴────────────────────────────┘

waitFor 的高级配置:

typescript
await waitFor(
  () => {
    expect(screen.getByText('数据加载完成')).toBeInTheDocument()
  },
  {
    timeout: 3000,
    interval: 100,
    onTimeout: (error) => {
      console.log(screen.debug())
      return error
    },
  }
)

测试场景实战

测试组件渲染

验证组件在不同 props 下是否正确渲染:

tsx
function ProductCard({ name, price, inStock }: ProductCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <span>{`¥${price.toFixed(2)}`}</span>
      {inStock ? (
        <span>有货</span>
      ) : (
        <span>暂时缺货</span>
      )}
    </div>
  )
}
typescript
describe('ProductCard', () => {
  it('renders product information correctly', () => {
    render(<ProductCard name="TypeScript 入门" price={59.9} inStock={true} />)

    expect(screen.getByRole('heading', { name: 'TypeScript 入门' })).toBeInTheDocument()
    expect(screen.getByText('¥59.90')).toBeInTheDocument()
    expect(screen.getByText('有货')).toBeInTheDocument()
  })

  it('shows out of stock when not available', () => {
    render(<ProductCard name="TypeScript 入门" price={59.9} inStock={false} />)

    expect(screen.getByText('暂时缺货')).toBeInTheDocument()
    expect(screen.queryByText('有货')).not.toBeInTheDocument()
  })
})

测试用户交互

tsx
function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <span>{`当前计数: ${count}`}</span>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
      <button onClick={() => setCount(c => c - 1)}>减少</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  )
}
typescript
describe('Counter', () => {
  it('increments count on click', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    expect(screen.getByText('当前计数: 0')).toBeInTheDocument()

    await user.click(screen.getByRole('button', { name: '增加' }))
    expect(screen.getByText('当前计数: 1')).toBeInTheDocument()

    await user.click(screen.getByRole('button', { name: '增加' }))
    expect(screen.getByText('当前计数: 2')).toBeInTheDocument()
  })

  it('decrements count on click', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByRole('button', { name: '减少' }))
    expect(screen.getByText('当前计数: -1')).toBeInTheDocument()
  })

  it('resets count to zero', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByRole('button', { name: '增加' }))
    await user.click(screen.getByRole('button', { name: '增加' }))
    await user.click(screen.getByRole('button', { name: '重置' }))

    expect(screen.getByText('当前计数: 0')).toBeInTheDocument()
  })
})

测试表单交互

tsx
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => void }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!email || !password) {
      setError('请填写所有字段')
      return
    }
    if (password.length < 6) {
      setError('密码长度不能少于 6 位')
      return
    }
    setError('')
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">邮箱</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <label htmlFor="password">密码</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      {error && <div role="alert">{error}</div>}
      <button type="submit">登录</button>
    </form>
  )
}
typescript
describe('LoginForm', () => {
  it('submits form with valid data', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()
    render(<LoginForm onSubmit={handleSubmit} />)

    await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
    await user.type(screen.getByLabelText('密码'), 'password123')
    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'alice@test.com',
      password: 'password123',
    })
  })

  it('shows error when fields are empty', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段')
  })

  it('shows error for short password', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
    await user.type(screen.getByLabelText('密码'), '123')
    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(screen.getByRole('alert')).toHaveTextContent('密码长度不能少于 6 位')
  })

  it('clears error on successful submit after failed attempt', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.click(screen.getByRole('button', { name: '登录' }))
    expect(screen.getByRole('alert')).toBeInTheDocument()

    await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
    await user.type(screen.getByLabelText('密码'), 'password123')
    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
  })
})

测试条件渲染

tsx
function UserStatus({ user }: { user: User | null }) {
  if (!user) {
    return <div>请先登录</div>
  }
  return (
    <div>
      <span>{`欢迎回来, ${user.name}`}</span>
      {user.role === 'admin' && <button>管理后台</button>}
      {user.notifications > 0 && (
        <span>{`${user.notifications} 条新通知`}</span>
      )}
    </div>
  )
}
typescript
describe('UserStatus', () => {
  it('shows login prompt when no user', () => {
    render(<UserStatus user={null} />)
    expect(screen.getByText('请先登录')).toBeInTheDocument()
  })

  it('shows welcome message for logged in user', () => {
    const user = { name: 'Alice', role: 'user', notifications: 0 }
    render(<UserStatus user={user} />)

    expect(screen.getByText('欢迎回来, Alice')).toBeInTheDocument()
    expect(screen.queryByRole('button', { name: '管理后台' })).not.toBeInTheDocument()
  })

  it('shows admin button for admin users', () => {
    const admin = { name: 'Bob', role: 'admin', notifications: 0 }
    render(<UserStatus user={admin} />)

    expect(screen.getByRole('button', { name: '管理后台' })).toBeInTheDocument()
  })

  it('shows notification count when present', () => {
    const user = { name: 'Alice', role: 'user', notifications: 5 }
    render(<UserStatus user={user} />)

    expect(screen.getByText('5 条新通知')).toBeInTheDocument()
  })
})

测试 API 调用

Mock 网络请求是组件测试中最常见的场景之一。有两种主流方案:直接 Mock 请求函数和使用 MSW(Mock Service Worker)。

方案一:直接 Mock 模块

tsx
function UserList() {
  const [users, setUsers] = useState<User[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')

  useEffect(() => {
    fetchUsers()
      .then(data => setUsers(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false))
  }, [])

  if (loading) return <div>加载中...</div>
  if (error) return <div role="alert">{error}</div>

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}
typescript
import { fetchUsers } from '../api/users'

vi.mock('../api/users')
const mockedFetchUsers = vi.mocked(fetchUsers)

describe('UserList', () => {
  it('displays loading state initially', () => {
    mockedFetchUsers.mockReturnValue(new Promise(() => {}))
    render(<UserList />)
    expect(screen.getByText('加载中...')).toBeInTheDocument()
  })

  it('displays users after loading', async () => {
    mockedFetchUsers.mockResolvedValue([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ])
    render(<UserList />)

    expect(await screen.findByText('Alice')).toBeInTheDocument()
    expect(screen.getByText('Bob')).toBeInTheDocument()
  })

  it('displays error on fetch failure', async () => {
    mockedFetchUsers.mockRejectedValue(new Error('网络错误'))
    render(<UserList />)

    expect(await screen.findByRole('alert')).toHaveTextContent('网络错误')
  })
})

方案二:MSW(Mock Service Worker)

MSW 在网络层面拦截请求,不需要 Mock 任何模块,更加真实:

typescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('UserList with MSW', () => {
  it('displays users from API', async () => {
    render(<UserList />)

    expect(await screen.findByText('Alice')).toBeInTheDocument()
    expect(screen.getByText('Bob')).toBeInTheDocument()
  })

  it('handles server error', async () => {
    server.use(
      http.get('/api/users', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    render(<UserList />)

    expect(await screen.findByRole('alert')).toBeInTheDocument()
  })
})

两种方案对比:

┌───────────────┬──────────────────────┬──────────────────────┐
│               │ 直接 Mock 模块        │ MSW                  │
├───────────────┼──────────────────────┼──────────────────────┤
│ 拦截层级       │ 模块级别             │ 网络级别              │
│ 真实度         │ 低,跳过了 HTTP 层    │ 高,请求真实发出      │
│ 与实现的耦合   │ 高,需知道具体模块    │ 低,只关心 API 契约   │
│ 配置复杂度     │ 低                   │ 中                   │
│ 可复用性       │ 低                   │ 高,可跨测试文件复用   │
│ 适合场景       │ 简单组件、快速测试     │ 集成测试、复杂 API   │
└───────────────┴──────────────────────┴──────────────────────┘

测试自定义 Hook

使用 renderHook 可以在隔离环境中测试自定义 Hook,无需创建宿主组件。

typescript
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)
  return { count, increment, decrement, reset }
}
typescript
import { renderHook, act } from '@testing-library/react'

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

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

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

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

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

测试带异步逻辑的 Hook:

typescript
function useAsync<T>(asyncFn: () => Promise<T>) {
  const [state, setState] = useState<{
    data: T | null
    loading: boolean
    error: Error | null
  }>({ data: null, loading: true, error: null })

  useEffect(() => {
    asyncFn()
      .then(data => setState({ data, loading: false, error: null }))
      .catch(error => setState({ data: null, loading: false, error }))
  }, [asyncFn])

  return state
}
typescript
describe('useAsync', () => {
  it('handles successful async operation', async () => {
    const asyncFn = vi.fn().mockResolvedValue({ name: 'Alice' })
    const { result } = renderHook(() => useAsync(asyncFn))

    expect(result.current.loading).toBe(true)

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.data).toEqual({ name: 'Alice' })
    expect(result.current.error).toBeNull()
  })

  it('handles async failure', async () => {
    const asyncFn = vi.fn().mockRejectedValue(new Error('Failed'))
    const { result } = renderHook(() => useAsync(asyncFn))

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.data).toBeNull()
    expect(result.current.error?.message).toBe('Failed')
  })
})

测试 Context Provider

当组件依赖 Context 时,需要在测试中提供对应的 Provider:

typescript
const ThemeContext = createContext<{
  theme: 'light' | 'dark'
  toggleTheme: () => void
}>({ theme: 'light', toggleTheme: () => {} })

function ThemeToggle() {
  const { theme, toggleTheme } = useContext(ThemeContext)
  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '切换到暗色模式' : '切换到亮色模式'}
    </button>
  )
}
typescript
describe('ThemeToggle', () => {
  it('displays current theme', () => {
    render(
      <ThemeContext.Provider value={{ theme: 'light', toggleTheme: vi.fn() }}>
        <ThemeToggle />
      </ThemeContext.Provider>
    )
    expect(screen.getByRole('button')).toHaveTextContent('切换到暗色模式')
  })

  it('calls toggleTheme on click', async () => {
    const user = userEvent.setup()
    const toggleTheme = vi.fn()
    render(
      <ThemeContext.Provider value={{ theme: 'light', toggleTheme }}>
        <ThemeToggle />
      </ThemeContext.Provider>
    )

    await user.click(screen.getByRole('button'))
    expect(toggleTheme).toHaveBeenCalledTimes(1)
  })
})

封装自定义 render 来简化 Provider 的设置:

typescript
function renderWithProviders(
  ui: React.ReactElement,
  {
    theme = 'light',
    ...options
  }: { theme?: 'light' | 'dark' } & RenderOptions = {}
) {
  const toggleTheme = vi.fn()

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <ThemeContext.Provider value={{ theme, toggleTheme }}>
        {children}
      </ThemeContext.Provider>
    )
  }

  return {
    ...render(ui, { wrapper: Wrapper, ...options }),
    toggleTheme,
  }
}

test('uses custom render', () => {
  renderWithProviders(<ThemeToggle />, { theme: 'dark' })
  expect(screen.getByRole('button')).toHaveTextContent('切换到亮色模式')
})

测试路由组件

使用 MemoryRouter 在测试中提供路由环境:

typescript
import { MemoryRouter, Route, Routes } from 'react-router-dom'

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/users/:id" element={<UserDetail />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  )
}
typescript
describe('App Routing', () => {
  it('renders home page on /', () => {
    render(
      <MemoryRouter initialEntries={['/']}>
        <App />
      </MemoryRouter>
    )
    expect(screen.getByText('首页')).toBeInTheDocument()
  })

  it('renders about page on /about', () => {
    render(
      <MemoryRouter initialEntries={['/about']}>
        <App />
      </MemoryRouter>
    )
    expect(screen.getByText('关于我们')).toBeInTheDocument()
  })

  it('renders user detail with params', () => {
    render(
      <MemoryRouter initialEntries={['/users/42']}>
        <App />
      </MemoryRouter>
    )
    expect(screen.getByText('用户 ID: 42')).toBeInTheDocument()
  })

  it('renders 404 for unknown routes', () => {
    render(
      <MemoryRouter initialEntries={['/unknown']}>
        <App />
      </MemoryRouter>
    )
    expect(screen.getByText('页面不存在')).toBeInTheDocument()
  })

  it('navigates between pages', async () => {
    const user = userEvent.setup()
    render(
      <MemoryRouter initialEntries={['/']}>
        <App />
      </MemoryRouter>
    )

    await user.click(screen.getByRole('link', { name: '关于' }))
    expect(screen.getByText('关于我们')).toBeInTheDocument()
  })
})

Vue Testing Library 与 Vue Test Utils

@testing-library/vue

Vue Testing Library 遵循与 React Testing Library 相同的理念,提供面向用户的测试 API:

bash
npm install -D @testing-library/vue @testing-library/jest-dom @testing-library/user-event
vue
<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ `计数: ${count}` }}</p>
    <button @click="count++">增加</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ title: string }>()
const count = ref(0)
</script>
typescript
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  it('renders title from props', () => {
    render(Counter, { props: { title: '我的计数器' } })
    expect(screen.getByRole('heading')).toHaveTextContent('我的计数器')
  })

  it('increments count on button click', async () => {
    const user = userEvent.setup()
    render(Counter, { props: { title: '计数器' } })

    expect(screen.getByText('计数: 0')).toBeInTheDocument()
    await user.click(screen.getByRole('button', { name: '增加' }))
    expect(screen.getByText('计数: 1')).toBeInTheDocument()
  })
})

Vue Test Utils

Vue Test Utils 是 Vue 官方的测试工具库,提供了更偏向开发者视角的 API:

typescript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  it('renders title', () => {
    const wrapper = mount(Counter, { props: { title: '我的计数器' } })
    expect(wrapper.find('h1').text()).toBe('我的计数器')
  })

  it('increments on click', async () => {
    const wrapper = mount(Counter, { props: { title: '计数器' } })
    await wrapper.find('button').trigger('click')
    expect(wrapper.find('p').text()).toBe('计数: 1')
  })
})

React Testing Library vs Vue Testing Library vs Vue Test Utils

维度React Testing LibraryVue Testing LibraryVue Test Utils
测试理念用户视角用户视角开发者视角
查询方式screen.getByRolescreen.getByRolewrapper.find('.class')
交互方式userEvent.clickuserEvent.clickwrapper.trigger('click')
组件实例访问❌ 不支持❌ 不支持wrapper.vm
DOM 访问通过语义化查询通过语义化查询wrapper.find / CSS 选择器
快照测试asFragment()支持wrapper.html()
官方维护Testing Library 社区Testing Library 社区Vue 官方
学习曲线中等中等较低
适合场景注重行为测试注重行为测试需要访问内部状态
选择 Vue 测试工具的决策树:

需要测试 Vue 组件


  是否需要访问组件内部状态/方法?
       /           \
      是            否
      │              │
      ▼              ▼
  Vue Test Utils   Vue Testing Library
  (wrapper.vm)     (screen.getByRole)

Storybook 与视觉测试

Storybook 核心概念

Storybook 是一个 UI 组件开发环境,允许你在隔离状态下开发和浏览组件。它与组件测试有着密切的互补关系。

Storybook 的架构:

┌─────────────────────────────────────────────┐
│              Storybook UI                   │
│  ┌──────────┐  ┌──────────────────────────┐ │
│  │ Sidebar  │  │     Preview Panel        │ │
│  │          │  │                          │ │
│  │ ● Button │  │  ┌────────────────────┐  │ │
│  │   ├ Primary │  │  │   Your Component   │  │ │
│  │   ├ Secondary│ │  │   (isolated)       │  │ │
│  │   └ Disabled│  │  └────────────────────┘  │ │
│  │ ● Input  │  │                          │ │
│  │ ● Modal  │  │  ┌────────────────────┐  │ │
│  │          │  │  │   Controls / Docs   │  │ │
│  └──────────┘  │  └────────────────────┘  │ │
│                └──────────────────────────┘ │
└─────────────────────────────────────────────┘

Story 是 Storybook 的基本单位,代表一个组件在特定状态下的渲染。CSF(Component Story Format) 是编写 Story 的标准格式:

typescript
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: 'radio',
      options: ['sm', 'md', 'lg'],
    },
  },
}

export default meta
type Story = StoryObj<typeof Button>

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: '主要按钮',
  },
}

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: '次要按钮',
  },
}

export const Disabled: Story = {
  args: {
    variant: 'primary',
    children: '禁用按钮',
    disabled: true,
  },
}

export const AllVariants: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: '8px' }}>
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="danger">Danger</Button>
    </div>
  ),
}

交互测试(Play Function)

Storybook 的 Play Function 允许在 Story 中编写交互测试,这些测试直接在浏览器中执行:

typescript
import { within, userEvent, expect } from '@storybook/test'

export const FilledForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)

    await userEvent.type(canvas.getByLabelText('邮箱'), 'alice@test.com')
    await userEvent.type(canvas.getByLabelText('密码'), 'password123')
    await userEvent.click(canvas.getByRole('button', { name: '登录' }))

    await expect(canvas.getByText('登录成功')).toBeInTheDocument()
  },
}

export const ValidationError: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)

    await userEvent.click(canvas.getByRole('button', { name: '登录' }))

    await expect(canvas.getByText('请填写所有字段')).toBeInTheDocument()
  },
}
Storybook 交互测试与 RTL 组件测试的关系:

┌───────────────────────────────────┐
│         Storybook Play Fn         │
│  (浏览器环境,可视化交互)           │
│                                   │
│  使用相同的 Testing Library API:   │
│  - within()                       │
│  - userEvent                      │
│  - expect()                       │
└──────────┬────────────────────────┘
           │ 共享
           │ 测试逻辑
┌──────────▼────────────────────────┐
│       RTL 组件测试                 │
│  (Node.js 环境,CI 执行)           │
│                                   │
│  使用相同的 Testing Library API:   │
│  - screen / render                │
│  - userEvent                      │
│  - expect()                       │
└───────────────────────────────────┘

视觉回归测试:Chromatic

Chromatic 是 Storybook 团队提供的视觉回归测试平台。它自动对每个 Story 截图,并在 PR 中对比变化:

Chromatic 工作流程:

    Push / PR


┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  Build        │────▶│  Capture     │────▶│  Compare     │
│  Storybook    │     │  Screenshots │     │  Snapshots   │
└──────────────┘     └──────────────┘     └──────┬───────┘

                                           ┌──────▼───────┐
                                           │  有变化?      │
                                           └──────┬───────┘
                                              /       \
                                            是         否
                                            │          │
                                     ┌──────▼───┐  ┌──▼──────┐
                                     │ 人工审核  │  │ 自动通过 │
                                     │ Accept /  │  └─────────┘
                                     │ Deny     │
                                     └──────────┘

配置 Chromatic(在 CI 中):

yaml
name: Chromatic
on: push

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

Storybook 与组件测试工具链的互补

组件质量保障的完整工具链:

┌─────────────────────────────────────────────────┐
│                  开发阶段                         │
│                                                  │
│  Storybook ──▶ 隔离开发、可视化调试、文档生成     │
│      │                                           │
│      └──▶ Play Function ──▶ 交互测试             │
└──────────┬──────────────────────────────────────┘

┌──────────▼──────────────────────────────────────┐
│                  测试阶段                         │
│                                                  │
│  RTL/VTL ──▶ 行为测试(CI 中运行)               │
│      │                                           │
│      └──▶ 覆盖率 ──▶ Istanbul / v8               │
│                                                  │
│  Chromatic ──▶ 视觉回归测试                       │
└──────────┬──────────────────────────────────────┘

┌──────────▼──────────────────────────────────────┐
│                  集成阶段                         │
│                                                  │
│  Playwright ──▶ E2E 测试(真实浏览器)            │
└─────────────────────────────────────────────────┘

组件测试最佳实践

测试文件组织

推荐的项目结构:

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx       ← 测试文件与组件同级
│   │   ├── Button.stories.tsx    ← Storybook Story
│   │   └── index.ts
│   ├── LoginForm/
│   │   ├── LoginForm.tsx
│   │   ├── LoginForm.test.tsx
│   │   ├── LoginForm.stories.tsx
│   │   └── index.ts
│   └── ...
├── hooks/
│   ├── useAuth.ts
│   ├── useAuth.test.ts
│   └── ...
└── test/
    ├── setup.ts                  ← 全局 setup
    ├── test-utils.tsx            ← 自定义 render 等工具
    └── mocks/
        ├── handlers.ts           ← MSW handlers
        └── server.ts             ← MSW server

编写可维护测试的原则

1. 每个测试只验证一个行为

typescript
it('submits form and shows success message', async () => {
  const user = userEvent.setup()
  render(<ContactForm />)

  await user.type(screen.getByLabelText('姓名'), '张三')
  await user.type(screen.getByLabelText('邮箱'), 'zhangsan@test.com')
  await user.click(screen.getByRole('button', { name: '提交' }))

  expect(await screen.findByText('提交成功')).toBeInTheDocument()
})

2. 避免测试实现细节

typescript
it('updates internal state on click', () => {
  const { result } = renderHook(() => useState(0))
  act(() => result.current[1](1))
  expect(result.current[0]).toBe(1)
})

it('shows updated count after click', async () => {
  const user = userEvent.setup()
  render(<Counter />)
  await user.click(screen.getByRole('button', { name: '增加' }))
  expect(screen.getByText('当前计数: 1')).toBeInTheDocument()
})

3. 使用有意义的测试描述

typescript
describe('LoginForm', () => {
  describe('validation', () => {
    it('shows error when email is empty', async () => { })
    it('shows error when password is too short', async () => { })
    it('shows error for invalid email format', async () => { })
  })

  describe('submission', () => {
    it('calls onSubmit with form data on valid submit', async () => { })
    it('disables submit button while loading', async () => { })
    it('shows success message after successful submit', async () => { })
  })

  describe('accessibility', () => {
    it('focuses first invalid field on submit', async () => { })
    it('announces errors to screen readers', async () => { })
  })
})

4. 使用 Page Object 模式封装复杂交互

typescript
function setupLoginForm() {
  const user = userEvent.setup()
  const onSubmit = vi.fn()
  render(<LoginForm onSubmit={onSubmit} />)

  return {
    emailInput: () => screen.getByLabelText('邮箱'),
    passwordInput: () => screen.getByLabelText('密码'),
    submitButton: () => screen.getByRole('button', { name: '登录' }),
    errorMessage: () => screen.queryByRole('alert'),
    fillEmail: (email: string) => user.type(screen.getByLabelText('邮箱'), email),
    fillPassword: (pwd: string) => user.type(screen.getByLabelText('密码'), pwd),
    submit: () => user.click(screen.getByRole('button', { name: '登录' })),
    onSubmit,
  }
}

it('submits valid form', async () => {
  const form = setupLoginForm()
  await form.fillEmail('alice@test.com')
  await form.fillPassword('password123')
  await form.submit()
  expect(form.onSubmit).toHaveBeenCalled()
})

it('shows validation error', async () => {
  const form = setupLoginForm()
  await form.submit()
  expect(form.errorMessage()).toHaveTextContent('请填写所有字段')
})

常见反模式

反模式问题正确做法
container.querySelector('.btn')依赖 CSS 类名,易碎screen.getByRole('button')
wrapper.instance().setState()测试实现细节通过用户交互触发状态变化
快照测试滥用变更频繁时快照毫无意义只对稳定的输出做快照
sleep(1000) 等待异步不可靠、浪费时间waitFor / findBy*
一个 it 里测所有情况难以定位失败原因每个 it 只测一个行为
不清理副作用测试互相影响afterEach(cleanup)
Mock 过多失去集成价值只 Mock 外部边界

面试高频问题

1. 为什么推荐 getByRole 而不是 getByTestId?

回答思路

getByRole 基于 ARIA Role 查询,这与屏幕阅读器等辅助技术感知元素的方式一致。使用 getByRole 有三个好处:

  • 测试更贴近用户视角:用户通过元素的角色(按钮、链接、文本框)来识别 UI,而不是 data-testid
  • 驱动更好的可访问性:如果你无法通过 Role 找到元素,说明该元素的可访问性存在问题
  • 测试更稳健:HTML 结构和 CSS 类名可能变化,但元素的语义角色通常是稳定的

getByTestId 是最后的手段,只在元素没有可辨识的文本、Role 或标签时使用(如自定义图表、Canvas 等)。

2. fireEvent 和 userEvent 有什么区别?

回答思路

fireEvent 直接派发单个 DOM 事件,而 userEvent 模拟完整的用户交互链路。

举例说明:当用户在输入框中输入文字时,实际会触发 focus → keydown → keypress → input → keyup 一系列事件。userEvent.type() 会模拟这个完整链路,而 fireEvent.change() 只触发 change 事件。

userEvent 还会正确处理:

  • 点击被禁用的按钮时不会触发 click 事件
  • 输入时会更新光标位置
  • 键盘修饰键(Shift、Ctrl)的状态跟踪

因此在组件测试中应始终优先使用 userEvent

3. 如何测试一个包含 useEffect 和 API 调用的组件?

回答思路

核心思路是 Mock 网络请求,然后验证组件在不同阶段的渲染状态。有两种方案:

直接 Mock:用 vi.mock() 替换 API 模块,简单直接但与实现耦合。

MSW:在网络层拦截请求,更真实,推荐用于集成测试。

测试流程通常覆盖三个阶段:

  1. 加载态(loading):验证加载指示器是否显示
  2. 成功态(success):使用 findBy* 等待异步数据渲染
  3. 错误态(error):Mock 失败响应,验证错误提示

关键点是不要 Mock useEffect 本身,让副作用正常触发,只控制外部依赖的返回值。

4. act() 警告是什么原因?如何解决?

回答思路

act() 是 React 提供的测试工具函数,确保在断言之前所有状态更新和副作用都已执行完毕。当测试中出现 "not wrapped in act(...)" 警告时,意味着某些状态更新发生在 React 的预期控制流之外。

常见原因:

  • 异步操作(setTimeoutfetch)在测试完成后才触发状态更新
  • 没有等待异步渲染完成就进行断言

解决方案:

  • 使用 waitForfindBy* 等待异步更新
  • renderHook 中使用 act() 包裹状态更新操作
  • 确保在测试结束前所有 Promise 都已 resolve

RTL 的 renderuserEvent 已经内部封装了 act(),大多数场景不需要手动调用。

5. 组件测试中如何处理定时器?

回答思路

使用 Vitest/Jest 提供的 Fake Timer 来控制时间流逝:

typescript
beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  vi.useRealTimers()
})

it('shows toast then hides after 3 seconds', async () => {
  render(<Toast message="保存成功" duration={3000} />)

  expect(screen.getByText('保存成功')).toBeInTheDocument()

  act(() => {
    vi.advanceTimersByTime(3000)
  })

  expect(screen.queryByText('保存成功')).not.toBeInTheDocument()
})

注意 Fake Timer 与 userEvent 的兼容问题:userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 可以让 userEvent 正确配合 Fake Timer 工作。

6. 什么时候应该用快照测试?什么时候不应该用?

回答思路

快照测试适合用于:

  • 稳定不变的输出:如格式化函数的输出、静态配置对象
  • 纯展示组件:没有交互逻辑,只根据 props 渲染的组件
  • 防止意外变更:作为安全网捕获非预期的 UI 变化

不适合用于:

  • 频繁变动的组件:快照会频繁失败,开发者倾向于盲目更新
  • 大型组件树:快照过大,审查 diff 毫无意义
  • 替代行为测试:快照不能验证交互是否正确

最佳实践是用 toMatchInlineSnapshot() 做小范围快照,配合行为测试覆盖交互逻辑。

7. 如何在组件测试中测试无障碍性(a11y)?

回答思路

三个层次的无障碍测试:

  1. RTL 查询本身就是 a11y 测试:如果你能用 getByRole 找到元素,说明基本的 ARIA 语义是正确的

  2. 使用 jest-axe 做自动化检测

typescript
import { axe } from 'jest-axe'

it('has no accessibility violations', async () => {
  const { container } = render(<LoginForm />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})
  1. Storybook + a11y addon:在开发阶段实时检测每个 Story 的无障碍问题

自动化工具只能发现约 30%~50% 的 a11y 问题,键盘导航和屏幕阅读器体验仍需人工测试。

8. React Testing Library 的 cleanup 机制是什么?

回答思路

cleanup 函数会卸载通过 render 挂载的组件,清除 DOM 残留。在使用 Vitest 或 Jest 时,RTL 会自动在每个测试之后调用 cleanup(通过 afterEach 钩子)。

这意味着:

  • 每个 it 块都在全新的 DOM 环境中运行
  • 不需要手动调用 cleanup()
  • 前一个测试渲染的组件不会影响后一个测试

如果使用了其他测试运行器且未自动 cleanup,则需手动添加:

typescript
import { cleanup } from '@testing-library/react'
afterEach(cleanup)

延伸阅读

官方文档

推荐文章

工具生态

工具定位链接
Vitest测试运行器(Vite 生态)vitest.dev
Jest测试运行器(通用)jestjs.io
Testing Library组件测试工具(框架无关)testing-library.com
MSWAPI Mock(网络层拦截)mswjs.io
Storybook组件开发环境storybook.js.org
Chromatic视觉回归测试chromatic.com
PlaywrightE2E 测试(组件测试模式)playwright.dev
jest-axe无障碍自动化测试GitHub

用心学习,用代码说话 💻