使用 testing-library 的建议
前言
本文为 TL 的作者列举的使用 TL 做单例测试时会犯的常见错误。
原文连接:Common mistakes with React Testing Library
安装 ESLint 插件
如果想要避免常见的错误,使用官方的 ESLint 插件是很好的选择:
create-react-app 已经内置 eslint-plugin-testing-library
对象解构获取 render 的返回值
js1// ❌ 2const wrapper = render(<Example prop="1" />) 3wrapper.rerender(<Example prop="2" />) 4 5// ✅ 6const { rerender } = render(<Example prop="1" />) 7rerender(<Example prop="2" />)
使用 wrapper 变量是enzyme的过时用法,现在已经不需要用到它了。从 render 里直接获取工具 API 即可。
不要使用 cleanup
js1// ❌ 2import { render, screen, cleanup } from '@testing-library/react' 3 4afterEach(cleanup) 5 6// ✅ 7import { render, screen } from '@testing-library/react'
cleanup是自动执行的,不要再手动执行cleanup
使用 screen
js1// ❌ 2const { getByRole } = render(<Example />) 3const errorMessageNode = getByRole('alert') 4 5// ✅ 6render(<Example />) 7const errorMessageNode = screen.getByRole('alert')
screen是在 6.11 版本之后添加的。它跟render函数一样被引入就可以了:
js1import { render, screen } from '@testing-library/react'
使用 screen 的好处是:在添加或者删除 DOM query 时,不需要实时地从render的返回值里获取内容。输入screen,编辑器会自动补全它的 API。
也可以直接调用 screen.debug而不是debug。
使用 jest-dom 的断言
js1const button = screen.getByRole('button', { name: /disabled button/i }) 2 3// ❌ 4expect(button.disabled).toBe(true) 5// error message: 6// expect(received).toBe(expected) // Object.is equality 7// 8// Expected: true 9// Received: false 10 11// ✅ 12expect(button).toBeDisabled() 13// error message: 14// Received element is not disabled: 15// <button />
@testing-library/jest-dom库提供了很多断言函数,强烈建议使用。因为能获得更好的错误信息。
不要无脑使用 act
js1// ❌ 2act(() => { 3 render(<Example />) 4}) 5 6const input = screen.getByRole('textbox', { name: /choose a fruit/i }) 7act(() => { 8 fireEvent.keyDown(input, { key: 'ArrowDown' }) 9}) 10 11// ✅ 12render(<Example />) 13const input = screen.getByRole('textbox', { name: /choose a fruit/i }) 14fireEvent.keyDown(input, { key: 'ArrowDown' })
很多人会把操作放在act里,因为它们一看到act的 warning,就把操作放在act里面,以此来去掉 Warning。但是他们不知道的是render和fireEvent已经包裹在act里了,所以不要这么做。
大多数时间,如果你看到这些 act 的 Warning,不是要让你无脑地干掉它们,是在告诉你:你的测试有问题了。
可以来这里了解更多: Fix the "not wrapped in act(...)" warning
使用正确的 Query 查询
js1// ❌ 2// 假设你有这样的 DOM: 3// <label>Username</label><input data-testid="username" /> 4screen.getByTestId('username') 5 6// ✅ 7// 改成通过关联 label 以及设置 type 来访问 DOM 8// <label for="username">Username</label><input id="username" type="text" /> 9screen.getByRole('textbox', { name: /username/i })
我们文档里一直有维护一个页面:“Which query should I use?”。你应该按这个页面中的顺序来使用 Query API。如果你的目标和我们的一样,都想通过测试来确保用户在使用时应用能够正常工作的话,那你就要尽量用更接近用户的使用方式来查询 DOM。我们提供的 Query 都能帮你做到这一点,但并非所有 Query API 都是一样的。
别用 container 查询元素
js1// ❌ 2const { container } = render(<Example />) 3const button = container.querySelector('.btn-primary') 4expect(button).toHaveTextContent(/click me/i) 5 6// ✅ 7render(<Example />) 8screen.getByRole('button', { name: /click me/i })
实际上我们更希望用户能直接和 UI 进行交互,然而,如果用querySelector来做查询的话,不仅不能模仿用户的 UI 行为,测试代码也会变得难度,而且很容易崩。
用文本查询
js1// ❌ 2screen.getByTestId('submit-button') 3 4// ✅ 5screen.getByRole('button', { name: /submit/i })
我们更建议你用真实的文本来做查询(关于地区语言,应该用默认的地区语言文本),而不是用 Test ID 以及别的一些机制。
多数情况下使用ByRole
RTL 的最近几个版本中,对ByRole相关的 Query API 做了很多升级,这是对组件渲染输出做查询操作的最推荐的方法。下面是它的一些功能:
-
name选项可以让你通过元素的 "Accessible Name" 查询元素,这也是 Screen Reader 会对每个元素读取的内容。好处是:即使元素的文本内容被其它不同元素分割了,它还是能够以此做查询。比如:js1// 假如现在我们有这样的 DOM: 2// <button><span>Hello</span> <span>World</span></button> 3 4screen.getByText(/hello world/i) 5// ❌ 报错: 6// Unable to find an element with the text: /hello world/i. This could be 7// because the text is broken up by multiple elements. In this case, you can 8// provide a function for your text matcher to make your matcher more flexible. 9 10screen.getByRole('button', { name: /hello world/i }) 11// ✅ 成功!人们不使用
*ByRole做查询的原因之一是他们不熟悉在元素上的隐式 Role。,没关系,大家可以参考 MDN,MDN 上有写这些元素上的 Role List。 -
使用
*ByRole时如果不能通过指定好的 Role 找到元素,它不仅会像get*以及find*API 一样把整个 DOM 树打印出来,还会把当前能访问的 Role 都打印出来。js1// 假设我们有这样的 DOM 2// <button><span>Hello</span> <span>World</span></button> 3screen.getByRole('blah')上面会报这样的错误:
js1TestingLibraryElementError: Unable to find an accessible element with the role "blah" 2 3Here are the accessible roles: 4 5 button: 6 7 Name "Hello World": 8 <button /> 9 10 -------------------------------------------------- 11 12<body> 13 <div> 14 <button> 15 <span> 16 Hello 17 </span> 18 19 <span> 20 World 21 </span> 22 </button> 23 </div> 24</body>这里要注意的是,我们没有为
<button/>设置 Role,像role=button。因为这是隐形的 Role。
避免添加不必要的可访问属性:aria-,role
js1// ❌ 2render(<button role="button">Click me</button>) 3 4// ✅ 5render(<button>Click me</button>)
像上面那样随意添加/修改可访问属性(Accessibility Attributes)不仅没有必要,而且还会把 Screen Reader 和用户搞懵。只有当无法满足当前的 HTML 语义时(比如你写了一个非原生的 UI 组件,同时也要让它 像 AutoComplete 一样可访问),你才应该使用可访问属性。假如这就是你现在要开发的东西,那可以用现有的第三库根据 WAI-ARIA 实践来实现可访问性。它们一般会有一些 很好的样例来参考。
尽可能使用 user-event
js1// ❌ 2fireEvent.change(input, { target: { value: 'hello world' } }) 3 4// ✅ 5userEvent.type(input, 'hello world')
@testing-library/user-event 是在 fireEvent 基础上实现的,但它提供了一些更接近用户交互的方法。上面这个例子中,fireEvent.change 其实只触发了 Input 的一个 Change 事件。但是 type 则可以对每个字符都会触发 keyDown、keyPress 和 keyUp 一系列事件。这能更接近用户的真实交互场景。好处是可以很好地和你当前那些没有监听 Change 事件的库一起使用。
使用query*来断言元素不存在
js1// ❌ 2expect(screen.queryByRole('alert')).toBeInTheDocument() 3 4// ✅ 5expect(screen.getByRole('alert')).toBeInTheDocument() 6expect(screen.queryByRole('alert')).not.toBeInTheDocument()
把暴露 query* 相关的 API 出来的唯一原因是:可以在找不到元素的情况下不会抛出异常(返回 null)。唯一的好处是可以用来判断这个元素是否没有被渲染到页面上。这是很重要的,因为类似 get* 和 find* 相关的 API 在找不到元素时都会自动抛出异常 —— 这样你就可以看到渲染的内容以及为什么找不到元素的原因。然而,query* 只会返回 null,所以 toBeInTheDocument 在这里最好的用法就是:判断 null 不在 Document 上。
显式断言而不用get*断言
js1// ❌ 2screen.getByRole('alert', { name: /error/i }) 3 4// ✅ 5expect(screen.getByRole('alert', { name: /error/i })).toBeInTheDocument()
如果 get* API 找不到元素,它就会抛出异常。
因为这个原因,很多人直接不做断言了。但最好把断言留着,因为这样能让后面做重构、修改的人知道:这里不是一个查询操作,而是断言操作。
不用 waitFor 等待 find*的查询结果
js1// ❌ 2const submitButton = await waitFor(() => screen.getByRole('button', { name: /submit/i })) 3 4// ✅ 5const submitButton = await screen.findByRole('button', { name: /submit/i })
上面两段代码几乎是等价的(find* 其实也是在内部用了 waitFor),但是第二种使用方法更清晰,而且抛出的错误信息会更友好。
不要给 waitFor 传递空的 callback
js1// ❌ 2await waitFor(() => {}) 3expect(window.fetch).toHaveBeenCalledWith('foo') 4expect(window.fetch).toHaveBeenCalledTimes(1) 5 6// ✅ 7await waitFor(() => { 8 expect(window.fetch).toHaveBeenCalledWith('foo')) 9} 10expect(window.fetch).toHaveBeenCalledTimes(1)
waitFor的目的是等一些指定的事情发生。如果传递了空的 callback,会留下一个脆弱的测试用例,一旦改了某些逻辑它可能就奔溃了。
一个 waitFor 里只放一个断言
js1// ❌ 2await waitFor(() => { 3 expect(window.fetch).toHaveBeenCalledWith('foo') 4 expect(window.fetch).toHaveBeenCalledTimes(1) 5}) 6 7// ✅ 8await waitFor(() => { 9 expect(window.fetch).toHaveBeenCalledWith('foo')) 10} 11expect(window.fetch).toHaveBeenCalledTimes(1)
如果 fetch 调用了两次,那么 waitFor 就会失败,但是我们需要等到超时才能看到具体的报错。而如果 waitFor 只有一个断言,我们就可以在其中一个断言失败时更快地获取到报错信息。
不要在 waitFor 中使用副作用
js1// ❌ 2await waitFor(() => { 3 fireEvent.keyDown(input, { key: 'ArrowDown' }) 4 expect(screen.getAllByRole('listitem')).toHaveLength(3) 5}) 6 7// ✅ 8fireEvent.keyDown(input, { key: 'ArrowDown' }) 9await waitFor(() => { 10 expect(screen.getAllByRole('listitem')).toHaveLength(3) 11})
waitFor 的 callback 会在不确定的时间(在执行操作和断言之间以及 DOM 变化时调用)。有可能会让副作用被调用多次。
同时,waitFor 里面也不能使用快照断言。如果想要快照断言,首先要等某些断言走完了,然后再拍快照。
笔者遇到的坑
-
间接引入组件时测试不通过
js1// components/Button/index.tsx 2export default Buttonjs1// components/index.ts 2export { default as Button } from './Button'js1// jest.test.tsx 2// ❌ wrong 3import { Button } from 'components' 4 5// ✅ right 6import Button from 'components/Button' -
遇到 less 等样式文件导致报错时
安装
identity-obj-proxyjs1module.exports = { 2 moduleNameMapper: { 3 '^.+\\.module\\.(css|sass|scss|less)$': 'identity-obj-proxy', 4 }, 5}这个模块是为了解决我们 UT 测试的组件中对于非 JS 资源的使用,比如 CSS,PNG,SVG 等。