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
关注点输入 → 输出的逻辑正确性用户视角的行为正确性
交互测试不涉及点击、输入、表单提交
断言方式toBe、toEqualtoBeInTheDocument、toHaveTextContent
测试粒度最小可测试单元组件及其子组件的协作

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 提供了 waitFor 和 findBy* 来处理异步场景。

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 的预期控制流之外。

常见原因:

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

解决方案:

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

RTL 的 render、userEvent 已经内部封装了 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

用心学习,用代码说话 💻