React 测试策略与组件测试:从单元到 E2E 的完整体系

引言

「没有测试的重构是赌博」——这句话在 React 应用中尤其贴切。组件化让 UI 具备可测性,但测试策略的混乱(什么都测、什么都覆盖不全)同样常见。本文将从测试金字塔讲起,系统梳理 React 应用的测试分层:单元测试、组件测试、集成测试与端到端测试,深入讲解 Testing Library 的使用哲学、异步测试、mock 策略与测试覆盖率管理。

一、测试金字塔与分层策略

React 测试的金字塔遵循「底层多、上层少」的原则:单元测试量大便宜,E2E 测试量小昂贵。每一层解决不同层次的问题。

测试金字塔

flowchart TB
  classDef p1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef p2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef p3 fill:#e0f7fa,stroke:#00838f,color:#004d40
  classDef p4 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[E2E 端到端测试<br/>数量少 成本高] --> B[模拟真实用户流程]
  B --> C[Playwright / Cypress]
  D[集成测试<br/>中等数量] --> E[多组件协作]
  E --> F[Testing Library 组合]
  G[组件测试<br/>数量多] --> H[单组件行为]
  H --> I[render + 断言]
  J[单元测试<br/>数量最多 最快] --> K[纯函数/工具/Hook]
  K --> L[Vitest / Jest]

  click B "https://playwright.dev/" "Playwright 文档"
  click G "https://testing-library.com/react" "Testing Library 文档"
  click L "https://vitest.dev/" "Vitest 文档"
  class A p1
  class B p2
  class C p3
  class D p1
  class E p2
  class F p3
  class G p1
  class H p2
  class I p3
  class J p1
  class K p2
  class L p3

每层测试关注的问题

层级 工具 关注点 运行时间
单元测试 Vitest/Jest 纯函数、工具、Hook 毫秒级
组件测试 Testing Library 渲染、交互、状态 秒级
集成测试 组件组合 多组件协作流 秒级
E2E 测试 Playwright 用户完整旅程 分钟级

二、Testing Library 的测试哲学

Testing Library 的核心哲学是:像用户一样测试。用户不关心实现细节(class 名、内部状态),只关心「看到什么、怎么交互」。

查询方式优先级

flowchart TD
  classDef q1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef q2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef q3 fill:#ffebee,stroke:#c62828,color:#b71c1c

  A[查询元素] --> B[角色查询<br/>getByRole 优先]
  A --> C[文本查询<br/>getByText]
  A --> D[标签查询<br/>getByLabelText]
  A --> E[占位符查询]
  A --> F[测试 id 查询<br/>最后手段]

  B --> B1[最贴近用户感知]
  B --> B2[无障碍友好]
  C --> C1[可见文本内容]
  D --> D1[表单字段关联]
  F --> F1[结构耦合<br/>尽量避免]

  click B "https://testing-library.com/docs/queries/byrole" "byRole 查询文档"
  class A q1
  class B q1
  class C q2
  class D q2
  class E q2
  class F q3

查询优先级的实践示例

黄金法则:查询方式越接近「用户如何找到这个元素」,测试就越健壮——用户通过按钮文字找到按钮,而不是通过 data-testid。

三、异步测试与等待策略

React 测试中最大的挑战是异步:数据请求、延迟渲染、过渡动画。Testing Library 提供 waitForfindBy* 查询处理。

异步测试模式

flowchart TB
  classDef a1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef a2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef a3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[渲染组件] --> B[初始状态断言]
  B --> C{异步行为}
  C -->|数据加载| D[findByText 自动等待]
  C -->|加载态| E[断言 spinner 出现]
  C -->|加载完成| F[断言内容出现]
  C -->|加载失败| G[findByRole alert]
  F --> H[断言数据正确]

  click D "https://testing-library.com/docs/dom-testing-library/api-async" "异步 API 文档"
  class A a1
  class B a1
  class C a1
  class D a3
  class E a2
  class F a3
  class G a2
  class H a3

三种异步查询对比

查询 行为 适用场景
getBy* 立即查找,找不到抛错 同步渲染断言
queryBy* 立即查找,找不到返回 null 断言「不存在」
findBy* 轮询等待直到出现 异步渲染断言

注意findBy* 默认超时 1 秒,可传 { timeout } 调整;测试中避免使用裸 setTimeout 等待,用 waitFor 包裹更稳定。

四、Mock 策略:隔离外部依赖

组件测试需要隔离外部依赖:网络请求、路由、时钟、浏览器 API。Mock 策略的核心是「测试行为,不测实现」。

Mock 分类

flowchart LR
  classDef m1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef m2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef m3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[Mock 分类] --> B[网络请求<br/>MSW / vi.mock]
  A --> C[时钟<br/>vi.useFakeTimers]
  A --> D[浏览器 API<br/>jsdom / mock]
  A --> E[第三方模块<br/>vi.mock 替换]

  B --> B1[模拟成功/失败/慢响应]
  C --> C1[前进时间 触发定时器]
  D --> D1[matchMedia / ResizeObserver]
  E --> E1[替换为桩实现]

  click B "https://mswjs.io/" "MSW 文档"
  class A m1
  class B m1
  class C m2
  class D m2
  class E m2
  class B1 m3
  class C1 m3
  class D1 m3
  class E1 m3

网络请求 Mock 的推荐方案

MSW(Mock Service Worker) 拦截真实网络层,与浏览器/Node 环境兼容,测试代码无需侵入组件:

sequenceDiagram
  participant T as 测试
  participant M as MSW
  participant C as 组件

  T->>M: 注册 mock handler
  T->>C: 渲染组件
  C->>M: 发起真实 fetch
  M->>M: 拦截 返回 mock 数据
  M-->>C: 响应
  C->>C: 正常渲染数据

五、Hook 测试:renderHook 与 act

Hook 是 React 逻辑复用的载体,renderHook 允许不渲染 UI 直接测试 Hook 行为。

Hook 测试模式

flowchart TD
  classDef h1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef h2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef h3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[renderHook 挂载 Hook] --> B[断言初始值]
  B --> C{触发更新}
  C -->|act 包裹| D[调用返回的 setter]
  C -->|rerender| E[传入新 props]
  D --> F[断言新状态]
  E --> F
  F --> G{副作用测试}
  G --> H[waitFor 等待 Effect 完成]

  click A "https://react.dev/reference/react-dom/test-utils/renderHook" "renderHook 文档"
  class A h1
  class B h1
  class C h2
  class D h3
  class E h3
  class F h3
  class G h2
  class H h3

示例:测试 useCounter

六、E2E 测试:真实用户旅程

E2E 测试用真实浏览器模拟用户操作:登录、浏览、提交表单、验证结果。Playwright 是当前主流选择。

E2E 用例设计

flowchart TB
  classDef e1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef e2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef e3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[用户旅程拆分] --> B[核心路径<br/>登录-浏览-退出]
  A --> C[关键路径<br/>创建文章-发布]
  A --> D[边缘路径<br/>错误输入-校验]
  B --> E[每个路径一个用例]
  C --> E
  D --> E
  E --> F[CI 中稳定执行]

  click B "https://playwright.dev/docs/test-intro" "Playwright 文档"
  class A e1
  class B e1
  class C e2
  class D e2
  class E e3
  class F e3

E2E 编写要点

  1. 用角色/文本定位器(getByRole),不用 CSS 选择器;
  2. 等待真实状态(expect(...).toBeVisible() 自动等待);
  3. 控制外部依赖(mock 第三方 API);
  4. 独立数据(每个用例清理/重建测试数据);
  5. CI 中并行分片执行,控制总时长。

七、覆盖率与测试预算

覆盖率(Coverage)是参考而非目标:100% 覆盖率的代码依然可能没有测试到关键行为。合理的做法是设定「分层的覆盖目标」。

覆盖率管理

flowchart TD
  classDef g1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef g2 fill:#fff3e0,stroke:#f57c00,color:#e65100
  classDef g3 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[覆盖率策略] --> B[逻辑层 80%+<br/>纯函数/工具/Hook]
  A --> C[组件层 70%+<br/>核心交互覆盖]
  A --> D[E2E 核心路径<br/>全绿]
  A --> E[阈值进 CI<br/>低于则失败]

  click B "https://vitest.dev/guide/coverage" "Vitest 覆盖率"
  class A g1
  class B g2
  class C g2
  class D g2
  class E g3

建议:把覆盖率阈值写入 CI(如 lines ≥ 75%),并配合「变更必须带测试」的 Code Review 规范,形成可持续的测试文化。

八、总结

React 测试体系的核心不是「工具越多越好」,而是「分层清晰、各司其职」:

  1. 单元测试:逻辑层快速验证,成本最低;
  2. 组件测试:Testing Library 像用户一样测,query 优先级是灵魂;
  3. 异步测试:findBy* 与 waitFor 是标准答案;
  4. Mock 策略:MSW 拦截网络,隔离外部依赖;
  5. E2E 测试:真实旅程验证,核心路径全绿;
  6. 覆盖率:分层阈值 + CI 门槛,形成闭环。

最后一条经验:测试的质量比数量重要——一个「像用户一样」的高价值测试,胜过十个断言实现细节的脆弱测试。下一篇我们将探讨 React 与 TypeScript 类型安全的完整实践。