跳转到内容

Testing Library — 像用户一样测前端,重构不再挂测试

是什么

Testing Library 是一组只看页面”用户视角”的 DOM 查询和交互工具。日常类比:你测一个咖啡机好不好用,不会拆开看里面的电路板,你按按钮看出咖啡来不来——Testing Library 就是这种”只摸面板”的测试方式。

它故意不让你拿组件实例、不让你按 className 找节点,而是逼你按”那个 Submit 按钮”、“那段叫’已加载’的文字”去查询。结果是:你改 React state、改 class 命名、把 class 组件重构成 hooks,只要 UI 行为对用户没变,测试就不会挂

最早出现在 react-testing-library(2018),后来发现核心是 DOM 而非 React,抽出 dom-testing-library 作底座,再接出 Vue / Svelte / Angular 等适配层。整个生态是一套查询语言走遍所有 UI 框架。

为什么重要

  • 不理解 Testing Library,没法解释为什么 Kent C. Dodds 一句”测试越像用户用法、信心越强”成了前端测试新教条
  • 不理解它,没法解释为什么 Enzyme 这么强大却在 React 18 时代被弃用、官方甚至不再支持
  • 不理解它,会把 getByRole 当作”花哨写法”,错过它顺便测无障碍的红利
  • 不理解它,会把 waitFor 滥用成万能等待,写出 flaky 测试还查不出原因

核心要点

整个库可以拆成 三件事,理解了就理解了 90%:

  1. 查询优先级:先 getByRole,再 getByLabelText,再 getByText,最后才轮到 getByTestId。类比:找人先按”那个穿红衣服的”(角色 + 看得见的特征),不是先按”工号 12345”(内部 ID)。getByRole 还会顺便测 ARIA 合规——找不到的元素往往就是无障碍漏洞。

  2. 异步等待:DOM 变化是异步的。waitForMutationObserver + setInterval 双触发——前者抓 DOM 变化即时回调,后者轮询兜底纯属性变化。findBy* = getBy* + waitFor,是日常首选。

  3. 事件层级fireEvent.click 只发一个 click 事件;userEvent.click 模拟真实浏览器序列(pointerover → mouseenter → pointerdown → focus → click,共 11 个)。95% 场景用 userEvent,fireEvent 只在性能或自定义事件场景兜底。

实践案例

案例 1:用 getByRole 锁住”用户能识别的角色”

import { render, screen } from '@testing-library/react'
render(<button>提交订单</button>)
expect(screen.getByRole('button', { name: '提交订单' })).toBeInTheDocument()

逐部分解释

  • role: 'button' 不是你写的属性——<button> 标签自带隐式 ARIA rolegetImplicitAriaRoles 自动算出来
  • name: '提交订单' 走 W3C accname 算法:aria-labelledby > aria-label > 关联 <label> > 文本内容 > title
  • 这套 5 级降级和屏幕阅读器一致——你能保证”通过测试”和”屏幕阅读器读得对”两件事在源头同步
  • 后续把 <button> 重命名 className、加 wrapper、改样式——这条断言全都过

案例 2:findByText 替代 waitFor 写异步加载

import { render, screen } from '@testing-library/react'
render(<List />) // 内部 fetch 后才显示数据
expect(await screen.findByText('订单 #1024')).toBeInTheDocument()

关键观察

  • findByText 内置 waitFor,默认 1000ms 轮询,找到就 resolve、超时就把”最后一次失败的 DOM”打到错误里
  • 写成 await waitFor(() => expect(screen.getByText('订单 #1024')).toBeInTheDocument()) 也能跑,但啰嗦且错误信息更差
  • waitFor 内部还会暂时关掉”打印整个 DOM”的诊断(runWithExpensiveErrorDiagnosticsDisabled),免得每次重试都打 50 KB 文本

案例 3:userEvent.hover 触发完整事件链

import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
render(<Button>Submit</Button>) // hover 时显示 (hover) 前缀
await user.hover(screen.getByRole('button'))
expect(screen.getByRole('button', { name: '(hover) Submit' })).toBeInTheDocument()

逐部分解释

  • userEvent.setup() 给当前测试拿一个 user 实例,所有事件用同一个 pointer 状态机推进
  • user.hover 自动按顺序触发 pointerover / pointerenter / mouseover / mouseenter,与真实浏览器一致
  • 如果改成 fireEvent.click,测试会失败——fireEvent 只发一个事件,mouseenter 不会触发,hovering 状态根本不会变 true
  • 同理 user.type(input, 'abc') 会按字符触发 3 次完整 keydown/keypress/input/keyup 序列;fireEvent.change(input, { target: { value: 'abc' } }) 直接改 value,跳过中间所有事件

踩过的坑

  1. waitFor 滥用await waitFor(() => expect(screen.getByText('Loaded')).toBeInTheDocument()) 是反模式。优先 await screen.findByText('Loaded')——更短、错误信息更精确。waitFor 应该只用于”我要断言一件事,但这件事不能用 query 表达”——例如 mock 函数被调用了几次。
  2. fireEvent 与 userEvent 混用:fireEvent.click 不会 focus、不会触发 hover、不会带 <label> 联动到关联 input。需要测 onMouseEnter / onFocus 的副作用时一定要换 userEvent,否则会写出”测试都过、真打开页面 hover 没反应”的假信心。
  3. 自定义 div 假按钮<div onClick> 没有隐式 ARIA role,getByRole('button') 找不到。要么补 role="button" tabIndex={0} 顺手把无障碍补齐,要么直接改回原生 <button>
  4. jsdom 不实现 layoutgetBoundingClientRect() 永远返回全 0,IntersectionObserver / ResizeObserver / Canvas 大部分 API 都要 mock;CSS :has() 早期版本也不支持。复杂交互必须有 Playwright 兜底。
  5. render 后忘 cleanup:默认 import @testing-library/react 会自动注册 afterEach(cleanup),但用纯 render 模式或自定义 renderer 时容易漏,导致后一个测试看到上一个的 DOM 残骸。

适用 vs 不适用场景

适用

  • 单元 + 集成层 React/Vue/Svelte/Angular 组件测试(jsdom 跑 ~10ms 一条)
  • 想顺便检查无障碍合规——getByRole 找不到的元素往往就是 a11y 漏洞
  • 老 Enzyme 项目渐进迁移——可以两套并存,新代码走 RTL,旧代码慢慢替换
  • 团队希望测试在 className / 目录结构 / 拆组件等重构后仍然存活

不适用

  • 视觉回归 / 跨浏览器 API 真实性 → 用 playwright / Cypress
  • 大规模布局测试(按像素对齐、ResizeObserver) → 真实浏览器
  • 纯 hook 内部逻辑分支多 → 用 renderHook + 直接断言返回值,黑盒太粗
  • 颜色对比度 / 屏幕阅读器朗读语义 → 这些要 axe-core 或真实 screen reader,getByRole 只能保证结构层 ARIA 正确

历史小故事(可跳过)

  • 2016 前后:React 生态事实标准是 Airbnb 的 Enzyme,暴露 wrapper.state() / wrapper.instance() 等”开发者视角” API,结果重构时测试雪崩
  • 2018 年 4 月:Kent C. Dodds(前 PayPal 工程师,egghead 讲师)在博客发表《Testing Implementation Details》,提出”测试越像用户使用方式,信心越强”
  • 2018 年 5 月:他写了 react-testing-library,要求查询只走用户可感知路径,接口刻意贫瘠到没法读组件 state
  • 2018 年底:发现核心机制(accname 算法、ARIA role 计算、waitFor)跟 React 无关,抽出 dom-testing-library 当底座
  • 2019-2021:陆续出 Vue / Svelte / Angular / Cypress / Marko 适配层,全用同一套查询 API,跨框架知识可复用
  • 2021 年起:user-event v14 重构,从同步事件升级到完整异步事件序列,fireEvent 退居”特殊兜底”
  • 2022 年起:Enzyme 官方放弃支持 React 18,整个 React 生态默认改推 RTL,把”用户视角”从社群偏好升格为事实标准

学到什么

  1. API 设计的”约束即收益”——故意不暴露组件实例,看似限制其实是把测试和实现解耦的关键杠杆
  2. 把 cross-cutting concern 嵌进主路径——把无障碍从”额外要做的事”变成”测试自然带着做”,这种设计可复用到日志、权限、追踪
  3. 一句宪法 + 多层推论——“测试越像用户使用方式,信心越强”一句话推导出整套 API;好的工具有一句你能背下来的宪法
  4. MutationObserver + 轮询的双触发是任何”异步等待 UI 变化”工具的通用模式,纯 observer 漏抓非 DOM 变化、纯轮询慢,两者结合互补
  5. 配套教学是工具的一部分——Kent 的博客 + egghead 课 + 文档 Common Mistakes 页把哲学下沉到日常,工具加布道是一体的

延伸阅读

关联

  • react —— react-testing-library 是 RTL 适配 React 的薄封装,整个 react-testing-library 不到 500 行
  • jest —— 默认搭配的测试 runner,与 vitest 二选一;waitFor 内部专门处理了 fake timers 兼容
  • vitest —— 现代 Vite 项目首选 runner,与 RTL 完全兼容,支持 ESM 原生
  • playwright —— 真实浏览器自动化,覆盖 jsdom 测不到的 layout / 跨浏览器 API / 视觉回归
  • storybook —— 用 stories 驱动同一份组件既渲染又测试,play function 直接调 RTL queries
  • msw —— RTL 测试里 mock 网络请求的事实标准,service worker 拦截真实 fetch
  • why-did-you-render —— 同样关注 React 用户视角,但从渲染性能角度切入

反向链接

  • jest —— Jest — 一个包就能跑 JS 测试的全家桶
  • msw —— MSW — 让 mock 不改业务代码,在网络层透明拦截
  • playwright —— Playwright — 跨浏览器自动化测试
  • react —— React UI 组件库
  • starlette —— Starlette — FastAPI 底下那台轻量 ASGI 引擎
  • storybook —— Storybook — 给 UI 组件的独立工作台
  • vitest —— Vitest — Vite 原生测试框架
  • why-did-you-render —— why-did-you-render — 让 React 告诉你这次渲染到底为什么