#React#TypeScript

React 测试策略:从单元到 E2E

测试 React 不是"什么都测",而是"测对的东西"。本文讲清 React 测试金字塔:什么该单测(工具函数/hooks)、什么该组件测(用户交互)、什么该 E2E(关键流程),工具选型(Vitest/Testing Library/Playwright),以及"测行为不测实现"的核心原则。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月30日·更新于 2026年7月23日

写了几年 React,我见过两种极端的团队。一种把测试覆盖率挂在墙上当 KPI,CI 跑 20 分钟,真上线还是炸;另一种觉得"测试浪费时间",PR 全靠肉眼 review,改一个按钮颜色把登录流程改没了。两种都赢不了。

React 测试难,不是难在 API,而是难在"该测什么"。这篇讲清楚一件事:怎么用最小的维护成本,守住最大的信心。

一、先立个原则:测行为,不测实现

记住这句话,后面所有的选型都是它的推论——测用户能看见的行为,不测代码内部的结构。

脆性测试的根源,几乎都是违背了这一条。比如这种:

ts
// 反面教材:测的是组件内部 state
expect(result.current._internalFlag).toBe(true)
expect(wrapper.find('FetchButton').prop('onClick')).toBeDefined()

这种测试通过的时候毫无价值(它只复述了你的实现),失败的时候全是噪音(你只是把 useState 换成了 useReducer,测试就红了)。重构一旦发生,第一波报错的全是这种用例,久而久之团队会形成"测试 = 拖累"的印象,然后整个测试体系就被废弃了。

正确的姿势是问自己一个问题:用户怎么知道这件事发生了? 用户看不见 state,看不见 props,用户只看见屏幕上的文字、按钮、跳转。那就测这些。

ts
// 好的做法:测可观察的结果
expect(screen.getByText('已加载')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()

这条原则把"测试要不要改"和"用户行为有没有变"对齐了——只有用户行为变了,测试才该红。这是高质量测试体系的基石。

二、测试金字塔:少而精的分粥艺术

经典测试金字塔长这样:底层单元测试多、快、便宜;中间组件测试适中;顶层 E2E 少、慢、贵。比例上,经验值大概是 7:2:1。

为什么要金字塔而不是倒三角?因为 E2E 跑得慢、flaky 率高,堆多了 CI 会变成大家排队喝咖啡的时间。一个 200 个用例的全 E2E 套件,跑一次十几分钟是常态,而且网络、定时器、动画任何一个抖动都可能让它 flaky。把纯逻辑下沉到单元测试里,E2E 只留关键路径,整体才能又快又稳。

反过来,也别搞"全是单元测试"的扁平结构。React 应用最大的价值在交互,纯单元测试覆盖不了"用户点了一个按钮后表单校验 + API 请求 + UI 反馈"的链路。组件测试是承上启下的中间层,不能省。

一句话:金字塔不是教条,是成本/收益的经济学。

三、单元测试:把纯逻辑交给 Vitest

凡是不依赖 React 运行时的逻辑,都应该用单元测试覆盖。这层最快、最稳、最该多写。

工具上,Vitest 是当下首选——API 跟 Jest 几乎一致,但原生 ESM、启动快、配置少,跟 Vite 项目无缝衔接。Jest 也没问题,老项目不必强行迁移。

一个典型的纯函数测试:

ts
// formatPrice.ts
export function formatPrice(cents: number, currency = 'CNY'): string {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency,
  }).format(cents / 100)
}

// formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'

describe('formatPrice', () => {
  it('把分换算成元,带货币符号', () => {
    expect(formatPrice(12345)).toBe('¥123.45')
  })

  it('支持多币种', () => {
    expect(formatPrice(12345, 'USD')).toMatch(/123\.45/)
  })

  it('边界:0 和负数', () => {
    expect(formatPrice(0)).toBe('¥0.00')
    expect(formatPrice(-100)).toBe('-¥1.00')
  })
})

边界值(0、负数、空数组、超大输入)是单元测试最该发力的地方,这里 ROI 最高。

Hooks 也可以单测。React 18 之后,react-hooks-testing-library 已经废弃,直接用 @testing-library/react 的 renderHook 即可:

ts
// useCounter.ts
import { useState, useCallback } from 'react'

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

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('inc 让计数 +1', () => {
    const { result } = renderHook(() => useCounter(5))
    act(() => result.current.inc())
    expect(result.current.count).toBe(6)
  })

  it('reset 回到初始值', () => {
    const { result } = renderHook(() => useCounter(5))
    act(() => result.current.inc())
    act(() => result.current.reset())
    expect(result.current.count).toBe(5)
  })
})

但注意:hook 里如果有复杂的副作用、定时器、订阅,建议直接在组件测试里覆盖——单独 mock 这些副作用往往比直接渲染还麻烦。

四、组件测试:Testing Library 的"用户视角"

组件测试是 React 项目的主战场。@testing-library/react 的整套 API 都是围绕"用户视角"设计的,记住它的优先级阶梯:getByRole > getByLabelText > getByText > getByTestId。

为什么 getByRole 优先?因为它查的是语义化角色(button、link、textbox),正是屏幕阅读器和键盘用户感知到的世界。如果你能 getByRole('button', { name: '登录' }) 找到按钮,意味着这个按钮同时是可访问的——测试顺手帮你做了 a11y 审计。getByTestId 应该是最后手段,它是一个逃生舱,不是默认选项。

对比两个查询方式的写法,体会一下:

ts
// 偏实现:getByTestId 依赖你给元素贴的标签
await user.click(screen.getByTestId('login-submit'))

// 偏行为:getByRole 模拟的是用户视觉/语义
await user.click(screen.getByRole('button', { name: '登录' }))

交互用 userEvent 而不是 fireEvent。fireEvent 是底层 DOM 事件的直接派发,userEvent 则模拟真实用户的操作链路(点击前 focus、键入时触发 keydown/keypress/input/keyup 全套)。绝大多数场景 userEvent 更贴近真实行为:

tsx
// LoginForm.tsx
import { useState } from 'react'

export function LoginForm({
  onSubmit,
}: {
  onSubmit: (email: string, password: string) => Promise<void>
}) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault()
        setLoading(true)
        try {
          await onSubmit(email, password)
        } finally {
          setLoading(false)
        }
      }}
    >
      <label>
        邮箱
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>
      <label>
        密码
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </label>
      <button type="submit" disabled={loading}>
        {loading ? '登录中...' : '登录'}
      </button>
    </form>
  )
}
tsx
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('提交时把邮箱和密码交给 onSubmit', async () => {
    const onSubmit = vi.fn().mockResolvedValue(undefined)
    const user = userEvent.setup()
    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText(/邮箱/), '[email protected]')
    await user.type(screen.getByLabelText(/密码/), 'secret')
    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(onSubmit).toHaveBeenCalledWith('[email protected]', 'secret')
  })

  it('请求期间禁用按钮并显示 loading 文案', async () => {
    let resolveSubmit!: () => void
    const onSubmit = vi.fn(
      () => new Promise<void>((resolve) => (resolveSubmit = resolve)),
    )
    const user = userEvent.setup()
    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText(/邮箱/), '[email protected]')
    await user.type(screen.getByLabelText(/密码/), 'secret')
    await user.click(screen.getByRole('button', { name: '登录' }))

    const button = screen.getByRole('button')
    expect(button).toBeDisabled()
    expect(button).toHaveTextContent('登录中...')

    resolveSubmit()
  })
})

注意第二个用例:它没有 mock 任何内部 state,完全靠用户能看到的"按钮禁用 + 文案变化"来判断 loading 状态。这就是"测行为"的实战样子。哪天你把 useState 换成 Zustand、把同步逻辑挪到 React Query,这个测试一个字都不用改。

五、Mock:能用 MSW 就别 mock 模块

Mock 是双刃剑。Mock 太少,测试又慢又不稳;Mock 太多,你测的是 mock 不是代码。

我的优先级是这样的:

  1. 网络层用 MSW(Mock Service Worker)。这是目前最优雅的方案——它拦截真实的 fetch/axios 请求,返回你定义的响应。组件代码完全不知道有 mock 存在,你测的是真实的请求逻辑(构造 URL、序列化 body、解析 response)。
ts
// test/server.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

export const server = setupServer(
  http.post('/api/login', async ({ request }) => {
    const { email } = (await request.json()) as { email: string }
    return HttpResponse.json({ token: 'fake-jwt', email })
  }),
  http.get('/api/user', () =>
    HttpResponse.json({ name: '张三', role: 'admin' }),
  ),
)
ts
// test/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest'
import { server } from './server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

onUnhandledRequest: 'error' 很关键——任何未声明的请求都报错,逼你把 mock 写完整,避免"测试通过了但生产里接口根本不存在"的鬼故事。

  1. 模块级 mock 只在边界用。比如 localStorage、matchMedia、IntersectionObserver 这种 jsdom 不实现的浏览器 API,或者时间相关的 Date.now / setTimeout。这时用 vi.useFakeTimers()、vi.stubGlobal() 是合理的。

  2. 绝对不要 mock 你自己的业务模块。下面这种写法是反模式:

ts
// 反模式:你 mock 掉了被测对象本身
vi.mock('./formatPrice', () => ({
  formatPrice: () => '¥0.00',
}))

一旦这么写,后续 formatPrice 怎么改都不会被这套测试发现。Mock 只该用在"外部边界"(网络、浏览器 API、第三方服务),不应该用在"被测代码内部"。

六、E2E:Playwright 守住关键流程

E2E 贵,所以只测"如果它挂了公司就赔钱"的流程:注册、登录、下单、支付、核心转化漏斗。一个项目留 5-15 个 E2E 用例就够了,再多就是给自己挖坑。

工具上 Playwright 已经全面超越 Cypress。跨浏览器、原生并发、auto-waiting、trace viewer 调试体验都是降维打击。Cypress 的串行架构和 iframe 沙箱在复杂场景下处处掣肘,新项目无脑选 Playwright。

ts
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'

test('用户登录后完成下单', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('[email protected]')
  await page.getByLabel('密码').fill('password123')
  await page.getByRole('button', { name: '登录' }).click()

  // 等待真实跳转
  await expect(page).toHaveURL(/\/dashboard/)

  await page.getByText('商品 A').click()
  await page.getByRole('button', { name: '加入购物车' }).click()
  await page.getByRole('link', { name: /购物车/ }).click()
  await page.getByRole('button', { name: '结算' }).click()

  await expect(page.getByText('下单成功')).toBeVisible()
})

注意几点实战要点:E2E 必须 await expect(...) 而不是裸 await page.click() 后立刻断言——Playwright 的 auto-wait 只对 locator 生效;测试之间不要共享状态,每个用例自己 setup 自己的数据(用 API 脚本预置);E2E 的 flaky 不要靠加 timeout(10000) 解决,根因是数据污染或动画抖动。

七、覆盖率:是体温计,不是 KPI

覆盖率工具(Vitest 的 --coverage、Istanbul)是个好东西,但用错了地方比没有更糟。

100% 覆盖率不等于测得好。一个全是 expect(true).toBe(true) 的项目也能跑满覆盖率。反过来,80% 覆盖率如果剩下的 20% 全是 error boundary、不可达分支,那也完全 OK。

正确做法是把覆盖率当"体检报告"而不是"KPI":

  • 设一个下限(比如 70%-80%),低于这个数 CI 就拦下来,防止劣化;
  • 别设上限(强制 100%),否则团队会写出"为了凑覆盖率"的废测试;
  • 关注关键模块的覆盖率(支付、鉴权、计费),而不是全局平均;
  • 看覆盖率的增量,比看绝对值更有意义——这次 PR 新加的代码,有没有被测?

把 branches 覆盖率看得比 lines 重要——分支覆盖才能逼你写 if 的两边、写边界值。

八、什么不该测:省下力气去做正事

最后一节,讲讲不该测的东西,这部分往往比"该测什么"更值钱。

  1. 不要测样式。expect(button).toHaveStyle('color: red') 这种测试几乎全是负担——设计师改个颜色你就改测试,而颜色对不对人眼一看便知。视觉回归该交给 Percy、Chromatic 这种视觉 diff 工具,而不是塞进单测里。

  2. 不要测第三方库的内部。你用 Zustand、React Query、react-hook-form,不要去测"Zustand 的 setState 有没有生效"——那是库作者该测的,你测了就是替他们打工,而且他们一升级你就崩。只测你怎么用这个库(也就是你的业务封装)。

  3. 不要测不稳定的依赖。真实的外部 API、当前时间、随机数、文件系统——这些都是 flaky 的温床。要么在边界 mock 掉,要么挪到 E2E 里接受一定波动。在单元测试里调真实 API 是大忌——慢、不稳、还会污染别人的数据。

  4. 不要测类型。TypeScript 编译器已经帮你查了类型,再写一堆 expect(typeof x).toBe('string') 是冗余。除非是运行时的类型守卫(比如解析后端返回的 unknown 数据),否则交给 tsc。

  5. 不要测 trivial 的代码。一行 return a + b 不需要测试,它的正确性由代码审查和类型系统保证。测试的价值在于"验证复杂行为"和"防止回归",把火力留在这两个地方。

小结

  • 测行为不测实现。问"用户怎么感知这件事",而不是"内部 state 是什么"。这是React 测试体系的地基。
  • 金字塔分层。单元测试多而快(纯函数/hooks),组件测试适中(交互链路),E2E 少而精(关键流程),比例 7:2:1。
  • 工具选型一句话:Vitest 跑单元,Testing Library + userEvent 跑组件,MSW mock 网络,Playwright 跑 E2E。
  • Mock 在边界做。网络用 MSW、浏览器 API 用 stubGlobal,绝对不要 mock 自己的业务代码。
  • 覆盖率是下限不是目标。70%-80% 拦劣化,100% 不该是 KPI,关注分支覆盖和关键模块。
  • 少测样式、少测第三方库内部、少测 trivial 代码——省下的力气用来把核心流程测扎实。

金句:好的测试,是你重构时一个用例都不用改的测试。

Related

相关推荐