深入浅出 testing-library
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚
The more your tests resemble the way your software is used, the more confidence they can give you.
您的测试越接近软件的使用方式,它们就越能给您带来信心。
什么是 testing-library?
在了解 testing-library 前,我们可以看看使用原生方法是如何进行 React 组件测试的。
import Header from ".."
import client from 'react-dom/client'
import { act } from 'react-dom/test-utils'
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container)
});
afterEach(() => {
document.body.removeChild(container);
container = null;
})
test('test render', () => {
act(() => {
client.createRoot(container!).render(<Header />)
});
const button = container!.querySelector('button');
const count = container!.querySelector("span[title='count']");
act(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// or Simulate.click(button!)
});
expect(count?.textContent).toEqual('1')
})
在上面案例中,我们需要自行创建一个根容器来渲染 React 组件, 且我们必须及时清除该根容器,避免对其他测试用例产生影响。
在 Header 组件中,我们每次点击按钮都会进行计数+1,当我们要进行点击时,需要自行创建事件实例,且由于合成事件的原因需要增加 bubbles 属性,这使得对开发者也就有了些许能力要求。
除此之外,事件触发必须包裹在 act 方法中,render 方法中相同。
什么是
act?
正常情况下,我们的单测代码是同步执行的,即代码执行完毕则单测完成。React 中的渲染并不是同步的,当我们进行事件触发,导致了 rerender 后,并不能立马获取新的页面结果,导致后续的断言失败。act 是由 React 官方在 react-dom/test-utils 中提供的一个方法,它能够让回调函数调用后,立即执行 React 内部 pending 中的异步队列。
我们再使用 RTL 去实现代码,RTL 为我们简化了 render 所需要的重复模板代码,模拟事件也不再需要包裹 act ,并提供了通用的查询方法。
test('test by RTL', () => {
const { getByRole, getByTitle } = render(<Header />);
const button = getByRole("button")
const count = getByTitle('count')
fireEvent.click(button);
expect(count.textContent).toEqual('1');
})
设计理念
testing-library 是以用户为中心 的方式进行 UI 组件的测试。什么是 以用户为中心 ?
即以用户的角度方式去审视你的 UI 组件,用户是不关心你的组件内部是如何实现的,只关心最终的功能效果是否正确。你不应该在 testing-library 中去测试组件的 props 与内部 state,生命周期等是否正确。
Why ?
组成架构
testing-library 的核心部分是 DOM Testing Library 即 @testing-library/dom , 它提供了通用的DOM 查询功能,如getByRole getByText 与事件行为的基本实现。
在此基础上,再衍生出各自框架的专有包,如React Testing Library、Vue Testing Library ,对于不同的前端框架,其使用方法是基本一致的, 提供不同实现方式的 render 与 fireEvent 方法。
除了每个前端框架提供各自的 fireEvent 接口外,还额外提供了一个@testing-library/user-event的通用包,不依赖于所选框架实现,它能够对用户事件的真实模拟,下文会详细说到。
除此之外,还针对 Jest 测试框架开发了一个断言库 @testing-library/jest-dom ,如我们平常经常使用的 expect().toBeInTheDocument() 就是该库为我们实现的,其通过 jest 提供的自定义断言器expect.extend(matchers) 将断言库注入到我们所使用的的 jest 上下文中
查询方法
查询作为testing-library的核心,它主要以用户的角度去查询,如根据组件展示的文字,Title 等信息。
查询内容
从查询内容上来讲它提供了8种类型
ByRole
通过可访问性、语义化查询元素, 如checkbox,menu,navigation, 对于实际场景并不常用。ByLabelText
通过 label 找到 label 所对应的元素,通常在表单中使用,如下
render(
<label>
username
<input />
</label>
);
const el = screen.getByLabelText('username');
expect(el.tagName).toBe('INPUT')
ByPlaceHolder
通过占位符查询元素,当查询表单元素等,但又没有 label 标识的话,可以使用这个,但不推荐。ByText
最常用的查询方法,根据textContent进行查询,通常可配合正则一起使用,如下
render(<div>Text Content is: 1</div>);
const el = screen.getByText(/Text Content is/);
expect(el.textContent).toBe("Text Content is: 1");
- ByDisplayValue
通过数值进行查询包含该值的元素,如 input 与 select 会有 value 属性
const {container} = render(
<div>
<select>
<option value="state">state</option>
<option value="prop">prop</option>
</select>
</div>
);
const select = container.querySelector('select')!;
select.value = 'state'
const el = screen.getByDisplayValue('state');
expect(el).toEqual(select)
ByAltText
根据alt属性进行查询,如<img alt=”img1” src=”xxx” />ByTitle
根据title属性进行查询,同上ByTestId
如果以上查询方式都不容易查询到节点,则最终可以考虑 testid,这种方式会侵入源代码,但不会对页面效果产生影响,通过在元素上添加data-testid属性来查询,查询时相当于container.querySelector([data-testid="${yourId}"])
有这么多方式,我该选哪个最好?
官方推荐优先使用用户页面可视的查询方式,如 byRole, byText 等可以在页面上看到的;其次是语义话查询方式,byAltText 与 ByTitle ,这是在页面上基本看不到,但是易于机器读懂的;最后才应该考虑byTestId 。如果 byTestId 也无法实现,那你只是使用原生的 querySelector 也没什么问题
查询方式
testing-library 一共提供了三种查询类型getBy、findBy、queryBy ,这三种类型定义了对查询结果的处理方式。
通过 getBy 方式查询, 当查询不到元素时会直接抛出一个错误, 则导致测试失败。
我们看看下面这个案例,上面部分额外的显式使用了断言,其运行结果最终是一模一样的。
但应该采用哪种写法最好?
test("test getBy with assertion", () => {
const { getByRole } = render(
<div>
<button>按钮</button>
</div>
);
expect(getByRole("list")).toBeInTheDocument();
});
test("test getBy", () => {
const { getByRole } = render(
<div>
<button>按钮</button>
</div>
);
getByRole("list")
});
如果这段测试的意义是为了测试元素是否存在,则最佳实践应是采用使用显式断言的方式。
通过 queryBy 方式查询,与 getBy 的唯一不同就是查询不到元素时不会抛出错误。
那么什么情况下该使用 queryBy 还是 findBy ?
事实上,绝大多数情况下应直接使用 getBy ,只在想测试元素不存在这种场景时使用 queryBy
test("test queryBy", () => {
const { queryByRole } = render(
<div>
<button>按钮</button>
</div>
);
expect(queryByRole("list")).not.toBeInTheDocument()
});
通过 findBy 进行查询,他与 getBy 一致,查询不到时会抛出错误,但是它能够用来查询异步元素。
何为异步元素?
在前面我们讲过,setState 导致的异步渲染,我们已经通过React提供的 act 方法解决了,对于某些场景,比如上传文件后,显示已上传的文件列表,上传文件操作是异步的,我们需要在一定时间后才能拿到文件列表元素;又或者说 setTimeout 或者promise 中进行了setState 操作,渲染的元素也需要异步获取。
在讲 findBy 之前,我们先了解下 waitFor , waitFor 也是testing-library 提供的一个异步方法,它提供了一种对于不确定代码运行时间的处理方法。在使用时,必须使单测块变为异步的,否则就没了使用意义,因此 waitFor 一般都与 await 一起使用。
使用方式如下:
test("test waitFor", async () => {
const Foo = () => {
const [text, setText] = useState('text1')
useEffect(() => {
setTimeout(() => {
setText('text2')
}, 300);
}, [])
return <span>{text}</span>
}
const { getByText } = render(<Foo />);
await waitFor(() => {
expect(getByText('text2')).toBeInTheDocument()
})
})
其原理也很简单,不断的去执行传入的回调函数,直到回调函数没有抛出错误或者超出最大等待时间。expect 断言失败,本质上也是抛出个错误, 因此一般会把断言写在 waitFor 中。
waitFor 默认超时时间为1000ms,每50ms执行一次回调。但在测试环境我们也不可能真去等1秒时间,其内部做了额外处理。
- 默认会优先采用
jest的fakeTimers来略过时间等待,但这个前提是在执行waitFor前,需要进行fakeTimers的注册,也就是执行jest.useFakeTimers()。
但很多情况下我们是没有使用fakerTimers, 且 testing-library 是测试框架无关的,所以在其他情况下会使用MutationObserver来作为重复执行callback的时机。 - 在循环开始前会添加一个超时时间的定时器
overallTimeoutTimer,定时器回调被调用则说明超时,直接reject掉。 - 当采用
fakeTimers方案时,会在每次循环时通过jest.advanceTimersByTime等待一定的时间interval(并非真正的等待) - 在
checkCallback方法中,会调用callback,并进行异常捕获,如捕获到异常则会进行下一次的循环,如果正常则在onDone方法中将finished置为true,并结束当前promise。 - 当使用
MutationObserver方案时,会监听documentDOM 节点的变化(包括其自己节点)。除此之外,由于MutationObserver是监听DOM树来实现的,某些场景会有限制,如 CSS 属性的变化,因此还会启用一个setInterval原始的定时器来做辅助执行,保证回调一定会被执行。
function waitFor(
callback,
{
container = getDocument(),
timeout = getConfig().asyncUtilTimeout,
interval = 50,
// 其他参数略
},
) {
return new Promise(async (resolve, reject) => {
let lastError, intervalId, observer
let finished = false
let promiseStatus = 'idle'
// 超时时间的timerid
const overallTimeoutTimer = setTimeout(handleTimeout, timeout)
const usingJestFakeTimers = jestFakeTimersAreEnabled()
// 如果使用了jest的fakeTimers,则采用advanceTimersByTime快速略过时间
if (usingJestFakeTimers) {
const {unstable_advanceTimersWrapper: advanceTimersWrapper} = getConfig()
checkCallback()
// 不断的等待一定时间后并检测回调是否通过检测
while (!finished) {
await advanceTimersWrapper(async () => {
jest.advanceTimersByTime(interval)
})
// 调用callback并检测是否跑错
checkCallback()
if (finished) {
break
}
}
}
// 如果没有使用fakeTimers,则退化为使用MutationObserver观测元素变化时执行一遍
else {
intervalId = setInterval(checkRealTimersCallback, interval)
const {MutationObserver} = getWindowFromNode(container)
observer = new MutationObserver(checkRealTimersCallback)
observer.observe(container, mutationObserverOptions)
checkCallback()
}
function checkCallback() {
if (promiseStatus === 'pending') return
try {
const result = callback()
// 处理callback为异步函数的情况
if (typeof result?.then === 'function') {
promiseStatus = 'pending'
result.then(
resolvedValue => {
promiseStatus = 'resolved'
onDone(null, resolvedValue)
},
rejectedValue => {
promiseStatus = 'rejected'
lastError = rejectedValue
},
)
} else {
onDone(null, result)
}
} catch (error) {
lastError = error
}
}
function onDone(error, result) {
finished = true
clearTimeout(overallTimeoutTimer)
if (!usingJestFakeTimers) {
clearInterval(intervalId)
observer.disconnect()
}
if (error) {
reject(error)
} else {
resolve(result)
}
}
}
从源码中看其实现还是很巧妙的,额外还需要注意的点是,回调函数是支持传入 async 函数的,当传入 async 函数时,会等待 promise 状态改变后才会再次执行。
再回到 findBy , 它其实就是对 waitFor 的一个封装,类似于下面这种代码。
await waitFor(() => getByXXX())
在使用时也必须加上 await 关键字,并且当你想要使用 findBy 时,请确保元素最终一定会存在,如果你想要测试元素是否存在,请使用waitFor + expect的形式保证其具有足够的语义。
test("test findBy", async () => {
const Foo = () => {
const [text, setText] = useState('text1')
useEffect(() => {
setTimeout(() => {
setText('text2')
}, 300);
}, [])
return <span>{text}</span>
}
const { findByText } = render(<Foo />);
const span = await findByText('text2');
expect(span.nodeName).toBe('SPAN')
})
除此之外,所有查询方法都是严格区分数量的,如果查询结果数量返回大于1,即使是 queryBy 类型,也会报错导致测试失败,对于多个返回的,需要使用getAllBy, queryAllBy, findAllBy 。
贴一张文档上的区别图

事件触发
testing-library 提供了两种触发事件的方式,fireEvent 与 userEvent
fireEvent
fireEvent是从 React Testing LIbrary 中引入的,其内部又是基于 DOM Testing Library 的 fireEvent 为 React 做了一些兼容性改动。
其使用方式非常方便, 有 fireEvent(node, event) 或者 fireEvent(node, eventProperties) 两种使用方式
fireEvent.change(getByLabelText(/picture/i), {
target: {
files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
},
});
fireEvent(getByLabelText(/picture/i), new Event('change',
{bubbles: true, cancelable: false})
);
在 DOM Testing Library 中 fireEvent 的实现, 也是通过 dispatchEvent 来做的
function fireEvent(element, event) {
return getConfig().eventWrapper(() => {
if (!event) {
throw new Error(
`Unable to fire an event - please provide an event object.`,
)
}
if (!element) {
throw new Error(
`Unable to fire a "${event.type}" event - please provide a DOM element.`,
)
}
return element.dispatchEvent(event)
})
}
那我们看看 React Testing Library 中又做了啥,它其实是对 fireEvent 加了层 act 包裹,这也是我们能直接使用的原因, fireEvent 时切记不要再手动包裹 act 了
configureDTL({
eventWrapper: cb => {
let result
act(() => {
result = cb()
})
return result
},
// 略
})
我们实际开发中经常会时不时的遇到 act 的飘红报错,看到报错提示我们不经意间就加了个 act上去,但这样其实是没用的。

比如下面这个案例:
test("test act warning", () => {
const Foo = () => {
const [text, setText] = useState('text1')
useEffect(() => {
Promise.resolve().then(() => setText('text2'))
}, [])
return <div>
<span>{text}</span>
<div>haha</div>
</div>
}
const { getByText, debug } = render(<Foo />);
const text = getByText('haha');
expect(text).toBeInTheDocument()
debug()
})
当我们代码中进行异步请求时,并在测试完成后或者 act 执行完成后,再在回调中进行 setState 则会导致报错。
上面这段代码想要修复报错,有很多方式,如可以在测试结束前进行等待来解决,或者直接干脆把异步请求的返回mock掉,不进行setState 。
优化后的代码
test("test act warning", async () => {
const Foo = () => {
const [text, setText] = useState('text1')
const fn =
useEffect(() => {
Promise.resolve().then(() => setText('text2'))
}, [])
return <div>
<span>{text}</span>
<div>haha</div>
</div>
}
const { getByText, findByText } = render(<Foo />);
const text = getByText('haha');
expect(text).toBeInTheDocument()
// 等待后再结束测试
await findByText('text2')
})
又或者使用异步的act , 在初次 render 时 手动包裹一层 act , act 是支持嵌套使用的。这在初始化组件时请求异步数据很有用
await act(async () => render(<Foo />) )
const text = screen.getByText('text2');
expect(text).toBeInTheDocument()
如果我再加入个异步任务,结果又如何?
useEffect(() => {
Promise.resolve().then(() => setText('text2')).then(() => setText('text3'))
}, [])
答案是:tex3
userEvent
userEvent是 testing library 的单独一个测试包,需要从@testing-library/user-event中引入。
与 fireEvent 不同的是,该包是完全以模拟用户的真实行为去触发事件的。
fireEvent是浏览器低级dispatchEventAPI 的轻量级包装器,它允许开发人员触发任何元素上的任何事件。问题在于,浏览器通常不仅仅为一次交互触发一个事件。例如,当用户在文本框中键入内容时,必须聚焦该元素,然后触发键盘的输入事件。userEvent 其实就是真实模拟了用户使用时的交互方式。
下面是个简单的输入案例。
test("test userEvent", async () => {
const onChange = jest.fn();
const onFocus = jest.fn();
const onClick = jest.fn();
const { getByPlaceholderText } = render(
<input
placeholder="请输入"
type="textarea"
onChange={onChange}
onFocus={onFocus}
onClick={onClick}
/>
);
const input = getByPlaceholderText("请输入");
await userEvent.type(input, 'hello');
expect(onChange).toHaveBeenCalled();
expect(onFocus).toHaveBeenCalled();
expect(onClick).toHaveBeenCalled();
expect(input).toHaveDisplayValue('hello')
})
需要注意的是,userEvent由于模拟了一系列操作,需要以异步的形式调用才能获取结果。
userEvent 还提供了很多其他的模拟操作,如复制粘贴,模拟键盘打字,模拟文件上传等等用户交互场景。
值得注意的是,testing-library 官方是推荐我们在大多数情况下应优先考虑使用 userEvent 而非 fireEvent 的,因为您的测试越接近软件的使用方式,它们就越能给您带来信心。
产品中的一些反模式
- 不要再在无用的地方加
cleanup了**。

首先为何需要有 cleanup 清除函数?
在一个单测文件中我们可能有多个test ,每个测试实例渲染自己的组件,但是window.document 只有一个,每次 render() 都会往body下添加一个div作为根容器,我们保证测试自己的组件时 body 下是无子节点的避免影响。cleanup 会对当前所有挂载的React根组件进行unmount, 并移除对应的元素。
function cleanup() {
mountedRootEntries.forEach(({root, container}) => {
act(() => {
root.unmount()
})
if (container.parentNode === document.body) {
document.body.removeChild(container)
}
})
mountedRootEntries.length = 0
mountedContainers.clear()
}
为何不需要再 cleanup 了?RTL中自动帮我们调了。
何时才需要 cleanup ?
同一个测试块中,render 了多次使其挂载了多个组件根节点, 也就是在test代码块内进行调用。

- 错误的使用
waitFor
waitFor中语句要有抛错的能力才有实际意义
let el = null
await waitFor(() => {
el = document.querySelector('.xxx')
})
await waitFor(() => {
fireEvent.click(el);
expect(el).toBeDisabled();
})
- 为
fireEvent包裹无意义的act
// 错误的
act(() => {
fireEvent.click(el)
})
// 可能正确的方式
await act(async () => {
fireEvent.click(el)
})
tips: 更多的反模式参考一些常见的RTL错误
参考:
https://www.robinwieruch.de/react-testing-library/
https://testing-library.com/docs/queries/about#priority
最后
欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star
- 大数据分布式任务调度系统——Taier
- 轻量级的 Web IDE UI 框架——Molecule
- 针对大数据领域的 SQL Parser 项目——dt-sql-parser
- 袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices
- 一个速度更快、配置更灵活、使用更简单的模块打包器——ko
- 一个针对 antd 的组件测试工具库——ant-design-testing
深入浅出 testing-library的更多相关文章
- 如何使用TDD和React Testing Library构建健壮的React应用程序
如何使用TDD和React Testing Library构建健壮的React应用程序 当我开始学习React时,我努力的一件事就是以一种既有用又直观的方式来测试我的web应用程序. 每次我想测试它时 ...
- C/C++ unit testing tools (39 found)---reference
http://www.opensourcetesting.org/unit_c.php API Sanity AutoTest Description: An automatic generator ...
- Rails 5 Test Prescriptions 第8章 Integration Testing with Capybara and Cucumber
Capybara: A complete reference is available atrubydoc.info. 集成测试就是把局部的程序组合起来测试. 端到端测试是一个特殊的集成测试,覆盖了 ...
- RIDE -- Robot Framework setup
RobotFramework 是一款基于python 的可以实现关键字驱动和数据驱动并能够生成比较漂亮的测试报告的一款测试框架 这里使用的环境是 python-2.7.10.amd64.msi RID ...
- GO语言的开源库
Indexes and search engines These sites provide indexes and search engines for Go packages: godoc.org ...
- lua unit test introduction
Unit Test Unit testing is about testing your code during development, not in production. Typically y ...
- MRPT笔记——MRPT在VS2013中的配置
Mobile Robot Programming Toolkit (MRPT)是一个跨平台的.开源的C++库,旨在帮助机器人研究员设计和实现SLAM.机器视觉和运动规划(避障)的算法. MRPT为移动 ...
- Fluent interface
In software engineering, a fluent interface (as first coined by Eric Evans and Martin Fowler) is an ...
- RobotFramework——介绍篇
1.简介Robot Framework是一款python编写的功能自动化测试框架.具备良好的可扩展性,支持关键字驱动,可以同时测试多种类型的客户端或者接口,可以进行分布式测试执行.主要用于轮次很多的验 ...
- openstack Icehouse发布
OpenStack 2014.1 (Icehouse) Release Notes General Upgrade Notes Windows packagers should use pbr 0.8 ...
随机推荐
- Spring的@Async使用防坑
很多人会直接只用@Async来执行异步操作.但是这里面有一个问题,如果都是用spring的默认实现,是有坑的. 当我们开启 EnableAsync 只有就使用Async来执行异步操作了. 走JDK的动 ...
- 如何让Dec-C++支持C++11
1.问题 Dev-C++默认设置中是不支持C++11版本特性的,如Lambda表达式,nullptr等均不提供支持 2.解决 设置编译选项 编译时加上命令-std==c++11即可
- Shell-循环-for-while
- mongo-基本操作
mogo基本操作 mongo对命令大小写敏感,SQL对大小写不敏感 存放 json数据,一条json数据是一个文档 数据库 查看数据库 show databases 切换数据库 use db db 不 ...
- [转帖]技术分享 | 国产麒麟 arm 上编译安装 xtrabackup8
原创 发布于 2022-07-19 13:29:29 3220 举报 作者:王向 爱可生 DBA 团队成员,负责公司 DMP 产品的运维和客户 MySQL 问题的处理.擅长数据库故障处理.对数据库技术 ...
- [转帖]兆芯官方的CPU测试成绩,我复现不了
https://baijiahao.baidu.com/s?id=1734998483605483848 下图是兆芯网官上的公开测试成绩,测试对象是3.0GHz的KX-U6880A. 有几个问题: ...
- F5内核参数的简要学习
前言 最近学习了很长时间的Linux内核参数 但是大部分是纸上谈兵. 也没有一个好的系统用于学习和参照 晚上搜索F5资料时发现F5有一些iso和ova文件 就想着下载学习一下. 看看F5系统默认的参数 ...
- STM32CubeMX教程25 PWR 电源管理 - 睡眠、停止和待机模式
1.准备材料 开发板(正点原子stm32f407探索者开发板V2.4) STM32CubeMX软件(Version 6.10.0) 野火DAP仿真器 keil µVision5 IDE(MDK-Arm ...
- ZCube:在我的优惠券中的落地实践 | 京东云技术团队
前言 我的优惠券作为营销玩法的一种运营工具,在营销活跃场中起到很至关重要的作用.如何更加高效的赋能业务,助理业务发展,灵活扩展业务,是我们一直追求和思考的方向 一.背景 1.1 现状 营销中台作为 ...
- 感性理解 int 与 long long 的速度差距 & 感性理解不同取模方法的差距
long long 题该怎么做?#define int long long 会多慢? 有时候,当我们被卡常的时候,不妨想一想,自己在开头定义的 #define int long long 有多大影响? ...