前言
哈喽,大家好,我是海怪。
刚开始我在写项目的单测方案的时候,老板就让我能够写一些单测的规范。虽然表面上我非常自然地说:没问题,但是心里还是慌得不行:以前我自己写单测也没啥规范呀,直接开干就好了。
最近一直在看 Kent 的文章,刚好看到他写的这篇 《Common mistakes with React Testing Library》,里面列举了很多别人写单测时经常犯的一些错误 。正好可以作为单测规范的参考。所以,今天就把这篇文章也分享给大家~
翻译中会尽量用更地道的语言,这也意味着会给原文加一层 Buf,想看原文的可点击 这里。
正片开始
哈喽,大家好。以前的我(Kent)并不是很喜欢那个时候的测试环境,为此写了一个 React Testing Library。它是原来 DOM Testing Library 的一个扩展,随着不断更新迭代,现在 Testing Library 的实现也能支持当下所有流行的 JS 框架和工具来定位组件中的 DOM 了。
随时代发展,我们也对这个库的 API 做了很多修改,同时也发现社区中有很多不怎么优雅的使用方式。虽然我们已经很努力地在文档里写要怎么 “更好地” 使用我们提供的工具 API,但我还是在别的文章和博客中看到他们在用这些不优雅的使用方法。接下来,我就一一盘点这些方法,解释为什么它们不是很好,以及如何改进测试以避免这些陷阱。
注:下面是重要程度的说明。
- 低:一般为我的主观想法,如果你觉得使用上没啥问题可以忽略它
- 中:如果你不遵循,可能会出现 Bugs、低效的测试用例、还可能会做额外的工作
- 高:一定要用我建议的方法。不然很有可能你会遇到大问题,而且测试用例并不怎么高效
没有使用 Testing Library 的 ESLint 插件
重要程度:中
如果你想避免这些常见的错误,那么官方的 ESLint 插件可以给你带来很多帮助:
注:如果你已经在用
create-react-library
,那eslint-plugin-testing-library
已经包含包在依赖中了建议:最好把这两个 ESLint 插件都装上。
还在用 Wrapper
作为 render
返回值的变量名
重要程度:低
// ❌ const wrapper = render(<Example prop="1" />) wrapper.rerender(<Example prop="2" />) // ✅ const {rerender} = render(<Example prop="1" />) rerender(<Example prop="2" />)
Wrapper
是以前 Enzyme 的过时用法,现在已经不需要它了。而且 render
的返回值里也并没有 Wraper
任何东西,它只是一些工具 API 的集合而已。所以,一般情况下可以不需要它了。
建议:直接使用从
render
返回值解构出来的东西,或者将返回值命名为view
。
手动使用 cleanup
重要性:中
// ❌ import {render, screen, cleanup} from '@testing-library/react' afterEach(cleanup) // ✅ import {render, screen} from '@testing-library/react'
现在cleanup
都是自动调用的,所以你已经不再需要再考虑它了。详见这里。
建议:别手动调
cleanup
不用 screen
重要程度:中
// ❌ const {getByRole} = render(<Example />) const errorMessageNode = getByRole('alert') // ✅ render(<Example />) const errorMessageNode = screen.getByRole('alert')
screen
是在 DOM Testing Library v6.11.0 引入的 (就就是说,你可以在 @testing-library/react@>=9
这些版本中使用它)。直接在 render
引入的时候一并引入就可以了:
import {render, screen} from '@testing-library/react'
使用 screen
的好处是:在添加/删除 DOM Query 时,不需要实时地解构 render
的
返回值来获取内容。输入 screen
,你的编辑器就能自动补全它里面的 API 了。
除非一种情况:你在配置 container
或者 baseElement
。不过,你应该避免使用它们(因为我实在想不出使用它们的现实场景,除非你是在处理一些历史遗留问题)。
你也可以直接调 screen.debug
而不是 debug
。
建议:用
screen
来做 Querying 和 Debugging
使用错误的断言 API
重要程度:高
const button = screen.getByRole('button', {name: /disabled button/i}) // ❌ expect(button.disabled).toBe(true) // error message: // expect(received).toBe(expected) // Object.is equality // // Expected: true // Received: false // ✅ expect(button).toBeDisabled() // error message: // Received element is not disabled: // <button />
上面的 toBeDisabled
来自 jest-dom
这个库。强烈建议大家使用 jest-dom
,因为你能获得更好的错误信息。
建议:用 @testing-library/jest-dom 这个库
将不必要的操作放在 act
里
重要程度:中
// ❌ act(() => { render(<Example />) }) const input = screen.getByRole('textbox', {name: /choose a fruit/i}) act(() => { fireEvent.keyDown(input, {key: 'ArrowDown'}) }) // ✅ render(<Example />) const input = screen.getByRole('textbox', {name: /choose a fruit/i}) fireEvent.keyDown(input, {key: 'ArrowDown'})
我经常看到不少人像上面那样把一些操作放在 act
里,因为他们一看到 "act" 的 Warning,就把操作放在 act
里面,以此去掉 Warning。但他们不知道的是 render
和 fireEvent
已经包裹在 act
里了!所以这样么其实没啥卵用。
大多数时间,如果你看到这些 act
的 Warning,不是要让你无脑地干掉它们,是在告诉
你:你的测试有问题了。可以看这里的视频来了解更多:Fix the "not wrapped in act(...)" warning。
建议:去了解什么时候应该用
act
,别把啥东西都往act
里放
使用错误的 Query
重要程度:高
// ❌ // 假设你有这样的 DOM: // <label>Username</label><input data-testid="username" /> screen.getByTestId('username') // ✅ // 改成通过关联 label 以及设置 type 来访问 DOM // <label for="username">Username</label><input id="username" type="text" /> screen.getByRole('textbox', {name: /username/i})
我们文档里一直有维护一个页面:“Which query should I use?”。你应该按这个页面中的顺序来使用 Query API。如果你的目标和我们的一样,都想通过测试来确保用户在使用时应用能够正常工作的话,那你就要尽量用更接近用户的使用方式来查询 DOM。我们提供的 Query 都能帮你做到这一点,但并非所有 Query API 都是一样的。
使用 container
来查询元素
作为 “使用错误的 Query” 的子集,我想聊一下直接用 container
来查询元素的问题:
// ❌ const {container} = render(<Example />) const button = container.querySelector('.btn-primary') expect(button).toHaveTextContent(/click me/i) // ✅ render(<Example />) screen.getByRole('button', {name: /click me/i})
实际上我们更希望用户能直接和 UI 进行交互,然而,如果你用 querySelector
这些来做查询的话,不仅我们不能模仿用户的 UI 交互行为,测试代码也会变得很难读,而且容易崩。这和下面这一节也有关系:
没有用文本来做查询
作为 “使用错误的 Query” 的子集,我想聊一下为什么我们更建议你用真实的文本来做查询(关于地区语言,应该用默认的地区语言文本),而不是用 Test ID 以及别的一些机制。
// ❌ screen.getByTestId('submit-button') // ✅ screen.getByRole('button', {name: /submit/i})
如果不用真实的文本来查询,那你要做很多额外的工作,因为你要确保你的地区语言的翻译转换是正确的。这里肯定有多人会吐槽说:要是别人改了文本的内容,你的测试不就崩了么?我对此的反驳是,首先,如果有人将 “UserName” 更改为 “Email”,这是我绝对想知道的变更(因为我需要更改我的实现了)。而且,就算有人因为改了个名搞崩了测试,修复测试也用不了多长时间,马上就能修好了。
总的来说,修复的成本是很低的,而好处则是可以增加你对翻译正确性信心,而且写出来的测试也是容易阅读和修改的。
还是要声明一下,并不是所有人都同意我这个观点的,具体可以看下 Twitter 上的这个 Thread。
多数情况下没有使用 *ByRole
作为 “使用错误的 Query” 的子集,我想来聊聊 *ByRole
。在最近 RTL 的几个版本里,对 *ByRole
相关的 Query API 都做了很多的升级,这了是对组件渲染输出做查询操作最推荐的方法。下面是我比较喜欢它的一些功能。
name
选项可以让你通过元素的 "Accessible Name" 查询元素,这也是 Screen Reader 会对每个元素读取的内容。好处是:即使元素的文本内容被其它不同元素分割了,它还是能够以此做查询。比如:
// 假如现在我们有这样的 DOM: // <button><span>Hello</span> <span>World</span></button> screen.getByText(/hello world/i) // ❌ 报错: // Unable to find an element with the text: /hello world/i. This could be // because the text is broken up by multiple elements. In this case, you can // provide a function for your text matcher to make your matcher more flexible. screen.getByRole('button', {name: /hello world/i}) // ✅ 成功!
人们不使用 *ByRole
做查询的原因之一是他们不熟悉在元素上的隐式 Role。,没关系,大家可以参考 MDN,MDN 上有写这些元素上的 Role List。
另一个我喜欢这个 API 的功能是:如果不能通过指定好的 Role 找到元素,它不仅会像 get*
以及 find*
API 一样把整个 DOM 树都打印出来,而且还会把当前能访问的 Role 都打印出来!
// 假设我们有这样的 DOM // <button><span>Hello</span> <span>World</span></button> screen.getByRole('blah')
上面会报这样的错误:
TestingLibraryElementError: Unable to find an accessible element with the role "blah" Here are the accessible roles: button: Name "Hello World": <button /> -------------------------------------------------- <body> <div> <button> <span> Hello </span> <span> World </span> </button> </div> </body>
这里要注意的是,我们并没有为 设置 Role 而加上 role=button
。因为这是隐式的 Role,下一节会详细说明。
建议:阅读并根据 “Which Query Should I Use" Guide” 里的推荐顺序来使用 Query