Skip to main content

React Testing Library 用法

截至 2026 年 10 月,RTL 的核心主张:按用户的方式去查元素。它的查询 API 设计就是在逼你不要测实现细节——你越按「用户怎么感知界面」去查,测试就越扛得住重构。

一、按用户感知排序的查询​

查询方式按「用户怎么感知界面」排序:getByRole → getByLabelText → getByText / getByPlaceholderText → 最后才是 getByTestId。这条顺序的意义不只是风格——按 role 和 label 查询时,如果元素没有可访问名(比如按钮只有图标没有文字),测试直接失败,等于顺手做了一次无障碍检查。一上来就用 testId,等于放弃了这层保护。

render(<LoginForm onSubmit={fn} />)
await user.click(screen.getByRole('button', { name: '登录' }))
await user.type(screen.getByLabelText('邮箱'), 'a@example.com')
expect(await screen.findByText('登录成功')).toBeInTheDocument()

注意这里全程没有用 class 选择器或组件内部引用,全是「用户看得到、读屏软件读得出」的方式。好处是:哪天你把 <button> 换成 <input type="submit">,只要可访问名没变,测试照样过。

一上来就用 testId,等于放弃了「查元素顺带验无障碍」这层保护

getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByDisplayValuegetByTestId越靠上越接近用户视角,顺带把无障碍也验了兜底:用上它就等于放弃了无障碍这层保护
图:查询优先级阶梯——按用户感知的顺序找元素,测试自然更稳

二、三种查询的语义差别​

三者的区别就看两件事:找不到时抛不抛错、要不要等待异步。

变体找不到时是否等待异步典型用途
getBy*抛错否断言元素存在
queryBy*返回 null否断言元素不存在
findBy*抛错是(轮询)等待元素出现

断言「元素不存在」用 queryBy*——expect(screen.queryByText('加载中')).not.toBeInTheDocument();断言「元素等一会儿会出现」用 findBy*——await screen.findByText('成功')。混用的典型错误是用 getBy* 去断言不存在,结果没等到就先抛错了。

按你要断言的是「存在」还是「不存在」来选 get / query / find

三、异步更新与 act 警告​

异步断言不要靠固定 sleep。正确做法是用 findBy*(内部就是轮询等待)或 waitFor 等待某个条件成立;固定 sleep 在慢机器上会偶发失败,在快机器上又白白拖慢测试。act 的作用是「把一批状态更新包起来并冲刷掉」,现在多数场景已由工具自动处理,需要手动加时通常意味着测试里混入了不该有的直接状态操作。

// 等待「条件成立」,而不是等待固定时间
await waitFor(() => {
expect(screen.getByRole('button')).toBeDisabled()
})
// 等待元素出现用 findBy(内部即轮询),不要手写定时器
const msg = await screen.findByText('提交成功')

userEvent 优先于 fireEvent:前者触发的是完整浏览器事件序列(pointerdown、focus、input、change 一连串),更接近真实交互;后者只派发单个事件,容易漏掉联动行为。

四、测实现细节的代价​

第一种,断言内部 state 或方法被调用几次——expect(component.someState).toBe(...) 测的是实现,重构一次全红,应该测用户看到什么。第二种,快照测试泛滥——toMatchSnapshot() 改动即更新,没有保护力,只能当辅助。第三种,把多个行为塞进一个用例——失败时定位困难,一个用例应围绕一个行为。第四种,用例互相污染——忘记清理导致前一个用例的状态泄漏到后一个,表现为「单独跑过、一起跑挂」。

一个用例围绕一个行为,清理要在每个用例后做

组件库可以约定每个交互组件至少有一条键盘用例,写法很固定:

const user = userEvent.setup()

await user.tab()
expect(screen.getByRole('button', { name: '提交' })).toHaveFocus()

await user.keyboard('{Enter}')
expect(await screen.findByText('提交成功')).toBeInTheDocument()

// 这条用例顺带把「键盘可达」也验收了,比单独做无障碍走查便宜

五、和测试运行器怎么搭​

在 jsdom 环境下跑组件测试,配套 @testing-library/jest-dom 提供 toBeInTheDocument 这类语义化断言;每个用例后工具会自动 cleanup,避免状态泄漏——但「自动」是有前提的:测试框架得注入全局的 afterEach。Vitest 默认 globals: false,此时不会自动清理,要么开 globals: true,要么自己写 afterEach(cleanup),否则 DOM 会在用例之间累积。最常见的封装是自定义 render——把全局的 Provider(主题、国际化、请求客户端)统一包一层,避免每个测试文件重复写:

// 自定义 render:统一包裹 Provider,全项目复用
import { render } from '@testing-library/react'
import { ThemeProvider } from './theme'

function customRender(ui, options) {
return render(ui, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
...options,
})
}
export * from '@testing-library/react'
export { customRender as render }

组件库可以约定每个组件至少有一个「键盘可操作」的用例,这样顺带把无障碍验收做了。

容易误用的还有 waitFor 的空断言——它什么都不等:

// 错:waitFor 里没有断言,第一次就通过,等于没等
await waitFor(() => {})

// 对:等到「条件成立」为止,超时会带着最后一次状态报错
await waitFor(() => {
expect(screen.getByRole('status')).toHaveTextContent('已保存')
})

六、写组件测试最容易踩的四种坏味道​

要测的不是组件内部状态对不对,而是用户看到什么——测内部实现会让重构寸步难行。

快照测试也提供不了多少回归保护:改动即更新的快照没有保护力,它只能作为辅助。

把多个断言塞进一个用例同样不划算,失败时定位困难,一个用例应当围绕一个行为。

查询方式上,一上来就用 testId 等于放弃了「查元素顺带验无障碍」这层保护,按你要断言的是「存在」还是「不存在」来选 get / query / find。

查询优先级与「按可访问性找元素」的理念写在 Testing Library 文档,理解它才知道为什么不用测试 id。