React 基础体系 · 第 14/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 测试体系:Testing Library、Vitest、用户行为和端到端测试

React 测试的目标不是证明某个函数“被调用过”,而是验证用户、浏览器、React 渲染过程和服务端之间的关键契约仍然成立。一个可靠的测试体系通常同时覆盖三类问题:

  1. 组件是否按可访问的用户界面呈现,并正确响应交互
  2. 客户端状态、异步请求和错误状态是否按预期变化
  3. 从真实浏览器进入应用,到路由、资源、服务端和数据库的完整链路是否可用

Testing Library、Vitest 和端到端测试分别解决不同层次的问题:

  • Testing Library 提供接近用户使用方式的 DOM 测试工具;
  • Vitest 提供测试运行器、断言、Mock、Fake Timer 和覆盖率能力;
  • 用户行为测试 是一种测试思想:通过点击、输入、提交、等待界面变化来验证行为,而不是直接修改组件内部状态;
  • 端到端测试 在真实或接近真实的浏览器和应用环境中验证完整系统。

它们不是互相替代的工具。Testing Library 通常运行在 Vitest 中,用户行为测试可以发生在组件测试或端到端测试中,而端到端测试通常还需要 Playwright 这类浏览器自动化工具。


一、先定义测试对象:从实现细节到外部契约

1. 用户可观察行为是什么

对一个表单而言,用户可观察的行为可以描述为:

  1. 用户看到邮箱输入框和提交按钮;
  2. 用户输入非法邮箱;
  3. 用户提交表单;
  4. 页面显示校验错误;
  5. 用户修正邮箱;
  6. 页面发起请求;
  7. 请求期间按钮不可重复提交;
  8. 请求成功后显示成功信息;
  9. 请求失败后显示可理解的错误;
  10. 网络请求结束后恢复可交互状态。

测试应该围绕这些行为组织,而不是围绕实现细节组织。例如:

expect(component.state.email).toBe('a@example.com')

通常不是好的组件测试。用户看不到 component.state,React 也没有对外保证组件内部状态的结构。

更接近契约的测试是:

await user.type(screen.getByRole('textbox', { name: '邮箱' }), 'a@example.com')
expect(screen.getByRole('textbox', { name: '邮箱' })).toHaveValue('a@example.com')

这里验证的是浏览器中真实存在的输入框及其可见值。

2. 可观察性边界

一个测试可以观察到的内容越接近真实用户,通常越能抵抗内部重构。但观察范围并不是越远越好。

可以把测试目标分成三层:

层次 主要观察对象 适合发现的问题
纯函数测试 输入和返回值 校验算法、格式化、转换逻辑
组件/集成测试 DOM、可访问名称、用户交互、请求结果 状态流转、表单行为、错误显示
端到端测试 浏览器中的完整页面和真实系统链路 路由、构建、服务端、鉴权、数据库、部署配置

例如,邮箱校验规则可以用纯函数测试;表单提交、禁用按钮和错误信息适合 Testing Library;登录后跳转和服务端会话则更适合端到端测试。

这不是绝对的测试金字塔规则。关键判断标准是:故障发生在哪个边界,测试就应尽量在该边界验证。


二、Vitest:测试运行器、断言和隔离环境

1. Vitest 负责什么

Vitest 是测试运行器。它负责:

  • 发现和执行测试文件;
  • 提供 describeittestexpect 等 API;
  • 提供 Mock、Spy 和 Fake Timer;
  • 管理测试生命周期;
  • 生成覆盖率报告;
  • 通过不同环境运行 Node 或 DOM 测试。

Vitest 本身不是 React 测试库。它不知道怎样渲染 React,也不知道什么叫“可访问的按钮”。这些能力由 @testing-library/react 和 DOM 环境提供。

一个最小的纯函数测试如下:

// src/validation.ts
export function isValidEmail(value: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
// src/validation.test.ts
import { describe, expect, it } from 'vitest'
import { isValidEmail } from './validation'

describe('isValidEmail', () => {
  it('接受常见邮箱地址', () => {
    expect(isValidEmail('alice@example.com')).toBe(true)
  })

  it('拒绝缺少域名的地址', () => {
    expect(isValidEmail('alice@')).toBe(false)
  })

  it('拒绝包含空格的地址', () => {
    expect(isValidEmail('alice @example.com')).toBe(false)
  })
})

这里测试的契约是:

isValidEmail(x)=true\text{isValidEmail}(x) = \text{true}

当且仅当字符串 x 符合当前定义的邮箱格式。这个正则并不等价于完整 RFC 邮箱语法,而是一个产品级输入校验规则。测试应明确验证这条业务规则,而不能暗示它实现了完整的邮箱标准。

2. 安装和配置

一个常见的 React + TypeScript 项目可以安装:

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

如果项目使用 Vite,可以使用以下配置:

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
    restoreMocks: true,
  },
})
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'

afterEach(() => {
  cleanup()
})

各项配置的作用不同:

  • environment: 'jsdom' 提供 windowdocument 和 DOM API;
  • setupFiles 在测试文件执行前加载断言扩展和清理逻辑;
  • @testing-library/jest-dom/vitest 提供 toBeVisible()toBeDisabled()toHaveAccessibleName() 等 DOM 断言;
  • cleanup() 卸载上一个测试渲染的 React 树,避免 DOM 和副作用泄漏;
  • restoreMocks: true 在测试后恢复 Spy 和 Mock 的原始实现。

jsdom 不是完整浏览器。它通常没有真实布局、绘制、网络栈、Web Worker、浏览器导航和所有浏览器 API。因此:

expect(element).toBeInTheDocument()

可以在 jsdom 中验证,但真实 CSS 布局、跨域 Cookie、浏览器历史行为则应使用端到端测试。

package.json 可以加入:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

运行:

npm test

预期结果类似:

✓ src/validation.test.ts (3 tests)
Test Files  1 passed
Tests       3 passed

具体输出格式会随 Vitest 版本和 Reporter 配置变化,不能把某个输出文本作为业务契约。


三、Testing Library:验证用户能看到和操作的界面

1. Testing Library 的核心模型

React Testing Library 的主要流程是:

render(<Component />)
screen.getByRole(...)
await user.click(...)
expect(screen.getByText(...)).toBeVisible()

它将 React 组件渲染到 DOM 中,再通过 DOM 查询和用户交互验证结果。

这与直接调用组件方法有根本区别。React 函数组件没有稳定的公开实例 API:

// 不推荐:测试实现细节
component.setEmail('alice@example.com')
component.submit()

用户不能调用 setEmail,因此这种测试即使通过,也不能说明真实交互成立。

2. 查询优先级

常用查询包括:

screen.getByRole('button', { name: '提交' })
screen.getByLabelText('邮箱')
screen.getByText('提交成功')
screen.getByTestId('complex-widget')

推荐优先使用:

  1. getByRole:按可访问角色和名称查找;
  2. getByLabelText:按表单标签查找;
  3. getByPlaceholderText:只有没有合适标签时才考虑;
  4. getByText:查找用户可见文本;
  5. getByTestId:没有合适用户语义时的最后选择。

getByRole('button', { name: '提交' }) 中的 name 是可访问名称,可能来自:

<button>提交</button>

也可能来自:

<button aria-label="关闭对话框">
  <CloseIcon />
</button>

如果测试无法通过 getByRole 找到按钮,问题可能不在测试,而在组件缺少正确的可访问语义。

三个查询族的区别必须明确:

  • getBy...:找不到时立即抛错,适合预期当前已经存在的元素;
  • queryBy...:找不到时返回 null,适合断言元素不存在;
  • findBy...:异步等待元素出现,适合请求或状态更新后的界面。

错误示例:

expect(screen.getByText('提交成功')).not.toBeInTheDocument()

如果成功信息尚未出现,getByText 会先抛错,根本不会执行 not.toBeInTheDocument()。正确写法是:

expect(screen.queryByText('提交成功')).not.toBeInTheDocument()

异步出现则使用:

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

3. fireEventuserEvent

fireEvent 直接派发单个 DOM 事件:

fireEvent.change(input, { target: { value: 'a@example.com' } })

它有时适合验证低层事件边界,但一个真实用户输入字符通常会触发更多事件和浏览器行为。

userEvent 模拟更接近用户的交互序列:

const user = userEvent.setup()
await user.type(input, 'a@example.com')
await user.click(button)

userEvent 的操作是异步的,因为它会模拟逐步输入、焦点变化、键盘事件和点击过程。没有 await 可能造成断言早于状态更新执行:

// 不可靠
user.click(button)
expect(screen.getByText('已提交')).toBeVisible()

应写为:

await user.click(button)
expect(await screen.findByText('已提交')).toBeVisible()

这里的原则不是“所有测试都必须使用某个 API”,而是:当测试意图是模拟用户操作时,优先使用 userEvent;当测试意图是验证某个底层事件处理器时,才考虑 fireEvent


四、一个完整的 React 表单行为测试

下面实现一个客户端表单。它覆盖受控组件、同步校验、异步提交、重复提交保护和服务端错误。

// src/NewsletterForm.tsx
import { FormEvent, useState } from 'react'

type SubmitState = 'idle' | 'submitting' | 'success' | 'error'

function isValidEmail(value: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}

export function NewsletterForm() {
  const [email, setEmail] = useState('')
  const [fieldError, setFieldError] = useState('')
  const [submitState, setSubmitState] = useState<SubmitState>('idle')
  const [serverError, setServerError] = useState('')

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()

    if (!isValidEmail(email)) {
      setFieldError('请输入有效的邮箱地址')
      return
    }

    setFieldError('')
    setServerError('')
    setSubmitState('submitting')

    try {
      const response = await fetch('/api/newsletter', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      })

      if (!response.ok) {
        throw new Error('订阅请求失败')
      }

      setSubmitState('success')
    } catch {
      setSubmitState('error')
      setServerError('暂时无法完成订阅,请稍后重试')
    }
  }

  const isSubmitting = submitState === 'submitting'

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="email">邮箱</label>
      <input
        id="email"
        name="email"
        type="email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        aria-invalid={fieldError ? 'true' : 'false'}
        aria-describedby={fieldError ? 'email-error' : undefined}
      />

      {fieldError && (
        <p id="email-error" role="alert">
          {fieldError}
        </p>
      )}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中…' : '订阅'}
      </button>

      {submitState === 'success' && (
        <p role="status">订阅成功</p>
      )}

      {submitState === 'error' && (
        <p role="alert">{serverError}</p>
      )}
    </form>
  )
}

这个组件的状态机可以写成:

idle
 ├─ 输入非法并提交 → idle + fieldError
 ├─ 输入合法并提交 → submitting
 │                      ├─ 请求成功 → success
 │                      └─ 请求失败 → error
 └─ 输入变化       → idle

其中 submitting 是一个重要中间状态。若只测试最终成功或失败,而不测试中间状态,就可能遗漏重复提交问题。

1. 测试初始界面和本地校验

// src/NewsletterForm.test.tsx
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NewsletterForm } from './NewsletterForm'

describe('NewsletterForm', () => {
  it('显示邮箱输入框和可提交按钮', () => {
    render(<NewsletterForm />)

    expect(
      screen.getByRole('textbox', { name: '邮箱' }),
    ).toBeVisible()

    expect(
      screen.getByRole('button', { name: '订阅' }),
    ).toBeEnabled()
  })

  it('提交非法邮箱时显示校验错误且不发起请求', async () => {
    const user = userEvent.setup()
    const fetchSpy = vi.spyOn(globalThis, 'fetch')

    render(<NewsletterForm />)

    await user.type(
      screen.getByRole('textbox', { name: '邮箱' }),
      'invalid',
    )
    await user.click(screen.getByRole('button', { name: '订阅' }))

    expect(
      screen.getByRole('alert'),
    ).toHaveTextContent('请输入有效的邮箱地址')

    expect(fetchSpy).not.toHaveBeenCalled()

    fetchSpy.mockRestore()
  })
})

这项测试证明了两个因果关系:

  1. 非法输入导致字段错误;
  2. 字段错误发生在客户端,因此不会调用服务端接口。

如果产品要求浏览器原生校验,还可以测试 type="email"noValidate 的取舍。但当前组件显式设置了 noValidate,意味着校验由 React 逻辑负责。测试应与真实产品决策一致,不能一边关闭原生校验,一边假设浏览器会显示原生提示。

2. 测试成功提交和提交中状态

it('合法邮箱提交成功后显示成功信息', async () => {
  const user = userEvent.setup()

  const fetchSpy = vi
    .spyOn(globalThis, 'fetch')
    .mockResolvedValue(
      new Response(JSON.stringify({ ok: true }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      }),
    )

  render(<NewsletterForm />)

  await user.type(
    screen.getByRole('textbox', { name: '邮箱' }),
    'alice@example.com',
  )
  await user.click(screen.getByRole('button', { name: '订阅' }))

  expect(await screen.findByRole('status')).toHaveTextContent('订阅成功')
  expect(fetchSpy).toHaveBeenCalledTimes(1)
  expect(fetchSpy).toHaveBeenCalledWith(
    '/api/newsletter',
    expect.objectContaining({
      method: 'POST',
      body: JSON.stringify({ email: 'alice@example.com' }),
    }),
  )

  fetchSpy.mockRestore()
})

fetchSpy 只验证接口契约的一部分:

  • URL;
  • HTTP 方法;
  • 请求体;
  • 调用次数。

不应在组件测试中重新测试 fetch 的全部实现,也不应要求测试知道组件内部使用了哪个函数名。请求契约属于组件与 API 之间的边界。

为了明确测试 submitting 状态,可以让 Promise 暂不结束:

it('请求进行中禁用按钮并显示提交中', async () => {
  const user = userEvent.setup()

  let resolveRequest!: (response: Response) => void
  const pendingRequest = new Promise<Response>((resolve) => {
    resolveRequest = resolve
  })

  const fetchSpy = vi
    .spyOn(globalThis, 'fetch')
    .mockReturnValue(pendingRequest)

  render(<NewsletterForm />)

  await user.type(
    screen.getByRole('textbox', { name: '邮箱' }),
    'alice@example.com',
  )
  await user.click(screen.getByRole('button', { name: '订阅' }))

  expect(
    screen.getByRole('button', { name: '提交中…' }),
  ).toBeDisabled()

  resolveRequest(
    new Response(JSON.stringify({ ok: true }), { status: 200 }),
  )

  expect(await screen.findByRole('status')).toHaveTextContent('订阅成功')

  fetchSpy.mockRestore()
})

执行顺序是:

  1. fetch 返回尚未完成的 Promise;
  2. handleSubmit 设置 submitState = 'submitting'
  3. React 重新渲染,按钮变为禁用状态;
  4. 测试手动完成 Promise;
  5. response.ok 为真,状态变为 success
  6. 测试等待成功信息出现。

如果没有第 3 步,用户可能在网络请求期间连续点击按钮,产生多个请求。

3. 测试服务端失败和网络失败

it('服务端返回错误时显示可重试的错误信息', async () => {
  const user = userEvent.setup()

  const fetchSpy = vi
    .spyOn(globalThis, 'fetch')
    .mockResolvedValue(
      new Response(JSON.stringify({ message: 'conflict' }), {
        status: 409,
      }),
    )

  render(<NewsletterForm />)

  await user.type(
    screen.getByRole('textbox', { name: '邮箱' }),
    'alice@example.com',
  )
  await user.click(screen.getByRole('button', { name: '订阅' }))

  expect(await screen.findByRole('alert')).toHaveTextContent(
    '暂时无法完成订阅,请稍后重试',
  )

  fetchSpy.mockRestore()
})

it('网络异常时显示错误信息', async () => {
  const user = userEvent.setup()

  const fetchSpy = vi
    .spyOn(globalThis, 'fetch')
    .mockRejectedValue(new TypeError('Failed to fetch'))

  render(<NewsletterForm />)

  await user.type(
    screen.getByRole('textbox', { name: '邮箱' }),
    'alice@example.com',
  )
  await user.click(screen.getByRole('button', { name: '订阅' }))

  expect(await screen.findByRole('alert')).toHaveTextContent(
    '暂时无法完成订阅,请稍后重试',
  )

  fetchSpy.mockRestore()
})

fetch 对 HTTP 4xx/5xx 默认不会自动 Reject。只有网络层失败时,它通常才会 Reject。因此组件必须显式检查:

if (!response.ok) {
  throw new Error(...)
}

如果遗漏这一步,服务端返回 500 仍可能被当作成功处理。这个测试不是测试实现细节,而是在验证 HTTP 错误是否进入正确的业务状态。


五、异步测试:等待正确的条件,而不是等待固定时间

1. 为什么不能随意使用 setTimeout

以下写法不稳定:

await new Promise((resolve) => setTimeout(resolve, 100))
expect(screen.getByText('订阅成功')).toBeVisible()

原因是:

  • 100 毫秒不代表请求和 React 更新一定完成;
  • CI 机器可能更慢;
  • 请求可能立即完成,测试却无意义地等待;
  • 固定时间无法表达“等待什么”。

正确做法是等待可观察结果:

expect(await screen.findByRole('status')).toHaveTextContent('订阅成功')

findBy... 通常等价于对 getBy... 进行异步重试,直到元素出现或超时。它表达的是测试真正需要的条件:成功状态已经呈现。

如果要等待某个元素消失:

import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'

await waitForElementToBeRemoved(() =>
  screen.getByRole('button', { name: '提交中…' }),
)

如果等待的是某个断言最终成立:

await waitFor(() => {
  expect(mockLogger).toHaveBeenCalledWith(
    expect.objectContaining({ level: 'error' }),
  )
})

但不要把 waitFor 当作万能包装器。若断言本身不需要异步重试,直接断言更清楚;若目标是元素出现或消失,应优先使用对应的 findBywaitForElementToBeRemoved

2. Fake Timer 的边界

Vitest 可以控制定时器:

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

describe('debounced search', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

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

  it('在停止输入后触发搜索', async () => {
    // 需要根据 userEvent 版本配置 advanceTimers,
    // 否则 userEvent 自身的定时器可能无法推进。
  })
})

Fake Timer 适合测试防抖、轮询和延迟提示,但会改变时间 API。Testing Library 的 userEvent 也可能使用定时器,因此如果没有正确配置,测试可能挂起。

对于网络请求,不应通过“快进 3 秒”模拟服务端完成。应该控制 Promise 或使用 MSW 模拟 HTTP 响应,因为网络完成和本地定时器不是同一件事。


六、Mock 的层次:不要让测试替代真实系统

1. 函数级 Mock

函数级 Mock 适合验证一个组件如何调用依赖:

const spy = vi.spyOn(module, 'sendRequest')
spy.mockResolvedValue({ ok: true })

优点是精确、快速;缺点是测试可能过度依赖模块结构。如果实现从 sendRequest 改为直接调用 fetch,测试会随实现变化。

2. HTTP 层 Mock

更接近真实应用的做法是在 HTTP 层拦截请求。MSW 可以让组件继续调用真实的 fetch,测试只替换请求响应。

示例:

// src/test/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

export const server = setupServer(
  http.post('/api/newsletter', async () => {
    return HttpResponse.json({ ok: true }, { status: 200 })
  }),
)
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import { server } from './server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

afterEach(() => {
  cleanup()
  server.resetHandlers()
})

afterAll(() => server.close())

在某个测试中覆盖响应:

import { http, HttpResponse } from 'msw'
import { server } from './test/server'

it('接口冲突时显示错误', async () => {
  server.use(
    http.post('/api/newsletter', () => {
      return HttpResponse.json(
        { message: 'already subscribed' },
        { status: 409 },
      )
    }),
  )

  // 执行用户输入和提交……
})

onUnhandledRequest: 'error' 很重要。它能把测试中未声明的请求直接暴露出来,避免组件偷偷访问了错误 URL 却仍然“通过”。

MSW 测试的是 HTTP 契约,而不是具体使用 fetch、Axios 还是其他客户端库。它通常比直接 Spy fetch 更接近真实运行路径,但仍然没有验证真实服务端实现。

3. 什么时候不能 Mock

如果测试目标是:

  • 服务端实际鉴权;
  • 数据库事务;
  • 服务端渲染;
  • Cookie 和重定向;
  • 真实路由;
  • 生产构建后的静态资源;
  • 反向代理和环境变量;

就不能只依赖组件级 Mock。这些问题应由服务端集成测试或端到端测试覆盖。


七、React 19 与客户端/服务端边界

1. 客户端组件和服务端组件不是同一种测试对象

React 19 支持服务端组件相关能力,但具体边界取决于所用框架和构建工具。React 本身提供组件模型;路由、服务器渲染、服务端函数、数据缓存等能力往往由框架实现。

典型的客户端组件包含:

'use client'

import { useState } from 'react'

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

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      当前计数:{count}
    </button>
  )
}

它可以在 jsdom 中渲染并通过用户点击测试:

it('点击后更新计数', async () => {
  const user = userEvent.setup()
  render(<Counter />)

  await user.click(screen.getByRole('button', { name: /当前计数:0/ }))

  expect(
    screen.getByRole('button', { name: /当前计数:1/ }),
  ).toBeVisible()
})

服务端组件可能不能直接在普通 jsdom 测试中按客户端组件方式渲染,原因包括:

  • 它可能依赖框架提供的服务端渲染管线;
  • 它不能使用客户端事件处理器;
  • 它可能在服务端读取数据库或调用服务端专用 API;
  • 它的异步解析和客户端水合由框架控制。

因此边界应这样分配:

服务端纯逻辑/数据访问
        ↓
服务端集成测试,验证权限、查询、错误映射
        ↓
框架渲染和路由
        ↓
端到端测试,验证浏览器看到的完整结果
        ↓
客户端交互组件
        ↓
Testing Library + Vitest

不要为了让测试通过而把服务端代码伪装成客户端代码,也不要在客户端测试中复刻框架的服务端渲染实现。

2. React 19 表单能力的测试边界

React 19 引入了与表单 Action、useActionStateuseFormStatus 等相关的能力,但这些 API 的使用方式还受框架、服务端函数和表单提交模式影响。测试时要先确定实际运行模型:

  • 这是普通客户端 onSubmit
  • 这是 React 表单 Action;
  • 这是框架提供的服务端函数;
  • 还是浏览器原生导航提交。

对于普通客户端表单,Testing Library 可以直接验证输入、提交和状态。对于依赖服务端函数的表单,应在框架支持的环境中验证服务端边界,并使用端到端测试确认浏览器提交和返回结果。

测试不能只验证“按钮文字变了”,还应验证状态和可交互性:

expect(screen.getByRole('button')).toBeDisabled()
expect(screen.getByRole('status')).toHaveTextContent('已完成')

如果表单使用 aria-liverole="status",测试同时验证了辅助技术能够感知状态变化。


八、错误边界、异常和日志:测试用户结果与诊断结果

1. 错误边界处理什么错误

React 错误边界主要处理渲染期间、生命周期和部分事件相关的子树错误。它不能自动捕获所有异步 Promise 拒绝,也不能替代 try/catch

一个简单错误边界如下:

// src/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react'

type Props = {
  children: ReactNode
  fallback?: ReactNode
  onError?: (error: Error, info: ErrorInfo) => void
}

type State = {
  hasError: boolean
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false }

  static getDerivedStateFromError(): State {
    return { hasError: true }
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(error, info)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <p role="alert">页面暂时无法显示</p>
    }

    return this.props.children
  }
}

测试用户看到的降级界面:

import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ErrorBoundary } from './ErrorBoundary'

function BrokenComponent(): JSX.Element {
  throw new Error('render failed')
}

it('渲染错误时显示降级界面并记录诊断信息', () => {
  const onError = vi.fn()

  render(
    <ErrorBoundary onError={onError}>
      <BrokenComponent />
    </ErrorBoundary>,
  )

  expect(screen.getByRole('alert')).toHaveTextContent(
    '页面暂时无法显示',
  )
  expect(onError).toHaveBeenCalledTimes(1)
})

测试环境通常会在控制台输出 React 错误。可以在测试范围内临时 Spy console.error,但必须恢复,否则会掩盖其他测试真正的错误。

2. 错误边界不是异步请求错误处理器

以下错误发生在 fetch Promise 中:

fetch('/api/data').then(() => {
  throw new Error('request callback failed')
})

它不会因为位于错误边界子树中,就自动被错误边界捕获。应使用组件自己的 try/catch、错误状态,或者由数据请求库提供的错误状态。

因此测试应区分:

  • 渲染崩溃:错误边界测试;
  • 请求失败:请求状态测试;
  • 事件处理器异常:事件处理逻辑测试或全局错误处理测试;
  • 未处理 Promise 拒绝:运行时监控和专门的异步错误测试。

3. 日志断言不能替代用户结果断言

不应只写:

expect(logger.error).toHaveBeenCalled()

还应验证用户结果:

expect(screen.getByRole('alert')).toHaveTextContent(
  '页面暂时无法显示',
)

日志是诊断契约,界面是用户契约。两者都重要,但不能互相替代。


九、端到端测试:在真实浏览器中验证完整链路

1. 端到端测试的定义

端到端测试(End-to-End Test,E2E)从用户入口开始,在浏览器中执行完整流程。例如:

  1. 启动应用;
  2. 访问 /subscribe
  3. 填写邮箱;
  4. 点击订阅;
  5. 浏览器发起真实 HTTP 请求;
  6. 服务端校验并写入测试数据库;
  7. 页面显示成功;
  8. 刷新页面后仍符合预期。

组件测试无法验证所有这些边界,因为它通常不会启动真实应用服务器,也不会执行真实路由、Cookie 和数据库逻辑。

Playwright 是常见的 E2E 工具。安装:

npm install -D @playwright/test
npx playwright install

示例配置:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://127.0.0.1:3000',
    trace: 'retain-on-failure',
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
})

webServer 会先启动开发服务器,测试等待 url 可访问后再执行。生产 CI 更适合使用确定的构建产物和启动命令,以减少开发服务器与生产行为不一致的问题。

2. 一个完整 E2E 示例

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

test('用户可以成功订阅邮件', async ({ page }) => {
  await page.goto('/subscribe')

  await page.getByRole('textbox', { name: '邮箱' })
    .fill('alice@example.com')

  await page.getByRole('button', { name: '订阅' }).click()

  await expect(
    page.getByRole('status'),
  ).toHaveText('订阅成功')
})

执行:

npx playwright test

失败时可以查看 Trace:

npx playwright show-trace test-results/**/trace.zip

Trace 通常包含操作步骤、截图、网络和控制台信息,适合诊断“本地通过、CI 失败”这类问题。trace: 'retain-on-failure' 只保留失败轨迹,减少存储量,但具体保留策略可按 CI 成本和诊断要求调整。

3. E2E 中也应使用用户语义

以下定位方式很脆弱:

await page.locator('.form-wrapper > button:nth-child(2)').click()

它依赖 DOM 层级和 CSS 结构。改版布局后,即使用户行为没有变化,测试也会失败。

更稳定的是:

await page.getByRole('button', { name: '订阅' }).click()

但“稳定”不等于永远不变。如果按钮名称是产品契约的一部分,名称改变本来就应触发测试失败。

4. E2E 的数据隔离

E2E 测试最常见的非业务失败原因是数据互相污染。例如测试 A 创建了一个邮箱,测试 B 又使用同一个邮箱,结果 B 失败。

可选隔离策略包括:

  • 每个测试使用唯一数据;
  • 每个测试使用独立用户;
  • 测试前重置数据库;
  • 使用数据库事务并在测试后回滚;
  • 为 CI 每个并行 Worker 分配独立数据库或命名空间。

唯一邮箱可以由测试生成:

const email = `e2e-${crypto.randomUUID()}@example.com`

如果服务端有唯一约束,测试还应明确预期:同一邮箱重复订阅是成功幂等、返回冲突,还是更新已有记录。E2E 不应依赖“数据库现在恰好是空的”。


十、组件测试和端到端测试如何分工

以“登录成功后进入账户页”为例:

组件测试适合验证

  • 输入框是否有正确标签;
  • 空密码是否显示校验错误;
  • 请求中按钮是否禁用;
  • 401 响应是否显示登录失败;
  • 成功状态是否显示正确提示;
  • 错误信息是否具有 role="alert"

端到端测试适合验证

  • /login 路由是否能打开;
  • 静态资源是否加载;
  • 浏览器是否发送正确 Cookie;
  • 服务端是否验证密码;
  • 登录成功是否跳转 /account
  • 刷新账户页后会话是否仍然存在;
  • 未登录访问 /account 是否重定向到 /login

不要用十几个 Mock 的组件测试代替一次完整的会话 E2E,也不要用几十个慢速 E2E 测试覆盖每一种输入校验分支。测试层次应与故障边界匹配。


十一、常见失败方式及诊断路径

1. 测试通过,但实际按钮不可访问

原因可能是:

<div onClick={submit}>提交</div>

它看起来像按钮,但没有按钮语义、键盘行为和默认表单行为。

更好的实现是:

<button type="submit">提交</button>

测试:

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

如果查询失败,应先检查产品代码是否违反了可访问性契约,而不是立即改成 getByTestId

2. 异步断言偶发失败

典型原因:

  • 忘记 await user.click()
  • 使用 getBy... 查询尚未出现的元素;
  • 用固定 setTimeout 代替条件等待;
  • Promise 没有被正确 Resolve 或 Reject;
  • Mock 没有在测试后恢复;
  • 测试间共享了服务端 Mock 状态。

诊断顺序可以是:

  1. 确认交互调用是否带 await
  2. 确认查询类型是否为 findBy...
  3. 确认请求是否真的发出;
  4. 确认 Mock 响应状态与组件处理逻辑一致;
  5. 确认测试后清理 DOM、Mock 和请求处理器;
  6. 检查是否存在未等待的 Promise。

3. React 的 act 警告

act 的意义是让 React 在断言前完成由测试操作引起的更新。Testing Library 通常会在 renderuserEvent 中处理大部分 act 边界。

出现警告时,不要简单地全局关闭。先检查:

user.click(button) // 忘记 await

应改成:

await user.click(button)

还要检查组件是否在测试结束后仍有异步更新,例如未清理的定时器、订阅或请求回调。这类更新可能意味着生产代码也存在生命周期问题。

4. Strict Mode 导致副作用执行两次

开发模式下的 Strict Mode 可能故意重复某些生命周期路径,以帮助发现不安全副作用。测试环境是否启用 Strict Mode 取决于渲染方式和项目配置。

如果一个 Effect 在重复执行时发送两次请求,不能先把测试包裹层删除来“修复”失败。应先判断副作用是否幂等、是否缺少清理、是否依赖错误的初始化假设。

测试调用次数时,要明确测试目标:

  • 如果目标是“用户提交一次只产生一次业务请求”,应控制组件行为并断言一次;
  • 如果目标是 Effect 初始化,则要明确测试是否在 Strict Mode 下运行;
  • 如果请求库本身会重试,则调用次数可能不等于用户操作次数。

5. 测试共享可变变量

以下写法容易相互污染:

let response = { status: 200 }

it('成功', () => {
  response.status = 200
})

it('失败', () => {
  response.status = 500
})

测试顺序或并行执行改变后,结果可能不稳定。每个测试应创建自己的数据,或在生命周期中显式重置。Vitest 的 Mock 恢复只能恢复函数,不会自动恢复任意业务对象。


十二、覆盖率的正确解释

覆盖率通常包括:

  • 行覆盖率;
  • 分支覆盖率;
  • 函数覆盖率;
  • 语句覆盖率。

分支覆盖率可以粗略表示:

Cbranch=已执行的分支数全部可执行分支数C_{\text{branch}} = \frac{\text{已执行的分支数}} {\text{全部可执行分支数}}

例如:

if (response.ok) {
  showSuccess()
} else {
  showError()
}

至少需要成功和失败两条路径,才能覆盖这个分支。但 100% 覆盖率不代表功能正确:

const title = '订阅成功'

即使所有行都执行过,测试也可能没有验证标题是否真的出现在用户界面中。

反过来,某些代码很难通过单元测试覆盖:

  • 浏览器特有 API;
  • 真实 CSS 布局;
  • 服务端运行时;
  • 网络代理;
  • 生产构建产物。

因此覆盖率应作为风险信号,而不是质量证明。更有价值的检查是:

  • 关键业务分支是否被覆盖;
  • 失败路径是否被覆盖;
  • 是否覆盖重复提交和取消操作;
  • 是否覆盖权限和数据隔离;
  • 是否存在大量只覆盖实现细节的测试。

运行覆盖率通常需要额外安装 Provider,具体 Provider 与 Vitest 版本有关,应以项目当前版本文档为准:

npm run test:coverage

不要在没有确认版本兼容性的情况下固定某个 Provider 版本或覆盖率阈值。工具升级后应验证报告格式、Source Map 和 CI 上传流程。


十三、从提交到发布的测试流水线

一个实际流水线可以按速度和故障成本分层:

flowchart TD
  A[提交代码] --> B[TypeScript 类型检查]
  B --> C[Vitest 纯函数与组件测试]
  C --> D[构建应用]
  D --> E[启动测试环境]
  E --> F[Playwright 关键 E2E]
  F --> G[发布候选环境]
  G --> H[冒烟测试与可观测性检查]
  H --> I[发布]

关键路径是:

  1. 类型检查:发现类型契约错误,但不能替代运行时测试;
  2. Vitest:快速反馈状态、校验和组件行为;
  3. 构建:发现模块、环境变量和编译配置问题;
  4. E2E:验证真实浏览器和系统边界;
  5. 发布后诊断:确认错误边界、日志、Web Vitals 和请求失败信息仍可观测。

例如:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "build": "vite build",
    "e2e": "playwright test",
    "ci": "npm run typecheck && npm test && npm run build && npm run e2e"
  }
}

如果 E2E 依赖数据库,ci 之前还需要创建隔离数据库、执行迁移并准备测试数据。不能只在本地手动执行一次,然后假设 CI 环境拥有相同状态。


十四、测试与可观测性的连接

测试不仅要验证“页面显示什么”,还可以验证发布诊断所需的最小契约。

例如错误日志需要带上稳定的错误标识:

type ErrorEvent = {
  level: 'error'
  code: string
  route: string
}

function reportError(event: ErrorEvent) {
  console.error(event)
}

错误边界测试可以断言:

expect(onError).toHaveBeenCalledWith(
  expect.any(Error),
  expect.objectContaining({
    componentStack: expect.any(String),
  }),
)

但不要断言完整的 componentStack 字符串,因为它可能随 React、编译器和文件结构变化。应只断言必要的结构。

对于 Web Vitals 或性能指标,组件测试通常不能真实测量首屏绘制、布局偏移和交互延迟。可以测试“指标上报函数是否收到指定数据”,但真实指标应在浏览器环境和发布环境中验证。否则 Mock 出来的时间值只能证明 Mock 工作正常。


十五、测试设计的取舍

1. 测试越接近用户,通常越慢

组件测试需要 DOM,但不一定需要真实浏览器;E2E 需要启动应用和浏览器,还可能访问数据库。层次越高,覆盖边界越多,执行成本也越高。

因此常见分配是:

  • 大量纯函数和组件测试;
  • 少量覆盖关键用户旅程的 E2E;
  • 对高风险业务增加服务端集成测试;
  • 对浏览器兼容、布局和发布环境增加专门验证。

这不是机械比例,而是基于风险:

测试优先级故障影响×故障概率×检测成本\text{测试优先级} \approx \text{故障影响} \times \text{故障概率} \times \text{检测成本}

支付、登录、权限、数据删除等功能,即使 E2E 成本较高,也通常值得覆盖完整链路。

2. 测试内部细节会增加重构成本

以下测试强绑定实现:

expect(setIsSubmitting).toHaveBeenCalledWith(true)
expect(wrapper.find('.loading-class')).toHaveLength(1)

它们可能在 UI 行为没有改变时因为重命名状态变量或 CSS 类名而失败。

以下测试绑定外部契约:

expect(screen.getByRole('button', { name: '提交中…' }))
  .toBeDisabled()

它仍然关心用户能否继续操作,而且验证了可见反馈和交互限制。

但是“不要测试实现细节”也不是绝对规则。纯函数模块的导出 API、序列化格式、缓存键和请求参数本身就是模块契约,可以直接测试。关键在于:被测试的细节是否对调用者或系统边界有意义。


十六、一个可执行的检查清单

在提交一组 React 测试前,可以逐项确认:

  • 是否明确了测试运行环境是 Node、jsdom 还是真实浏览器;
  • 是否用用户可观察的 DOM 契约验证组件;
  • 表单控件是否有正确的 label、角色和可访问名称;
  • 用户输入和点击是否使用了 await userEvent
  • 异步结果是否等待条件,而不是等待固定时间;
  • 是否分别覆盖成功、校验失败、HTTP 失败和网络失败;
  • 是否验证了提交中的禁用状态;
  • Mock 是否在测试后恢复,HTTP Handler 是否重置;
  • 是否区分了错误边界、请求异常和未处理 Promise;
  • 是否在客户端测试与服务端测试之间划清边界;
  • 是否有至少一条关键用户旅程通过真实浏览器验证;
  • E2E 数据是否隔离,测试是否可以独立运行;
  • 是否把覆盖率当作风险指标,而不是质量结论;
  • CI 是否验证类型、测试、构建和关键 E2E;
  • 测试失败时是否能通过日志、截图、Trace 和错误边界信息定位问题。

React 测试体系的核心并不是某个断言库或某个覆盖率数字,而是把产品行为拆成可验证的边界:纯逻辑验证输入输出,Testing Library 验证用户可见的组件状态,Vitest 管理快速且隔离的执行环境,端到端测试验证浏览器到服务端的完整路径。边界划分正确后,测试既能抵抗内部重构,也能在真实故障发生前暴露状态、网络、路由和发布配置问题。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。