前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用 React Testing Library 的 15 个常见错误

使用 React Testing Library 的 15 个常见错误

作者头像
写代码的海怪
发布2022-05-09 15:33:14
1.2K0
发布2022-05-09 15:33:14
举报

前言

哈喽,大家好,我是海怪。

刚开始我在写项目的单测方案的时候,老板就让我能够写一些单测的规范。虽然表面上我非常自然地说:没问题,但是心里还是慌得不行:以前我自己写单测也没啥规范呀,直接开干就好了。

最近一直在看 Kent 的文章,刚好看到他写的这篇 《Common mistakes with React Testing Library》,里面列举了很多别人写单测时经常犯的一些错误 。正好可以作为单测规范的参考。所以,今天就把这篇文章也分享给大家~

翻译中会尽量用更地道的语言,这也意味着会给原文加一层 Buf,想看原文的可点击 这里[1]

正片开始

哈喽,大家好。以前的我(Kent)并不是很喜欢那个时候的测试环境,为此写了一个 React Testing Library。它是原来 DOM Testing Library 的一个扩展,随着不断更新迭代,现在 Testing Library 的实现也能支持当下所有流行的 JS 框架和工具来定位组件中的 DOM 了。

随时代发展,我们也对这个库的 API 做了很多修改,同时也发现社区中有很多不怎么优雅的使用方式。虽然我们已经很努力地在文档里写要怎么 “更好地” 使用我们提供的工具 API,但我还是在别的文章和博客中看到他们在用这些不优雅的使用方法。接下来,我就一一盘点这些方法,解释为什么它们不是很好,以及如何改进测试以避免这些陷阱。

注:下面是重要程度的说明。

  • 低:一般为我的主观想法,如果你觉得使用上没啥问题可以忽略它
  • 中:如果你不遵循,可能会出现 Bugs、低效的测试用例、还可能会做额外的工作
  • 高:一定要用我建议的方法。不然很有可能你会遇到大问题,而且测试用例并不怎么高效

没有使用 Testing Library 的 ESLint 插件

重要程度:中

如果你想避免这些常见的错误,那么官方的 ESLint 插件可以给你带来很多帮助:

  • eslint-plugin-testing-library
  • eslint-plugin-jest-dom

注:如果你已经在用 create-react-library,那 eslint-plugin-testing-library 已经包含包在依赖中了

建议:最好把这两个 ESLint 插件都装上。

还在用 Wrapper 作为 render 返回值的变量名

重要程度:低

代码语言:javascript
复制
// ❌
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

重要性:中

代码语言:javascript
复制
// ❌
import {render, screen, cleanup} from '@testing-library/react'

afterEach(cleanup)

// ✅
import {render, screen} from '@testing-library/react'

现在cleanup 都是自动调用的,所以你已经不再需要再考虑它了。详见这里。

建议:别手动调 cleanup

不用 screen

重要程度:中

代码语言:javascript
复制
// ❌
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 引入的时候一并引入就可以了:

代码语言:javascript
复制
import {render, screen} from '@testing-library/react'

使用 screen 的好处是:在添加/删除 DOM Query 时,不需要实时地解构 render 的返回值来获取内容。输入 screen,你的编辑器就能自动补全它里面的 API 了。

除非一种情况:你在配置 container 或者 baseElement。不过,你应该避免使用它们(因为我实在想不出使用它们的现实场景,除非你是在处理一些历史遗留问题)。

你也可以直接调 screen.debug 而不是 debug

建议:用 screen 来做 Querying 和 Debugging

使用错误的断言 API

重要程度:高

代码语言:javascript
复制
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

重要程度:中

代码语言:javascript
复制
// ❌
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。但他们不知道的是 renderfireEvent 已经包裹在 act 里了!所以这样么其实没啥卵用。

大多数时间,如果你看到这些 act 的 Warning,不是要让你无脑地干掉它们,是在告诉你:你的测试有问题了。可以看这里的视频来了解更多:Fix the "not wrapped in act(...)" warning。

建议:去了解什么时候应该用 act,别把啥东西都往 act 里放

使用错误的 Query

重要程度:高

代码语言:javascript
复制
// ❌
// 假设你有这样的 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 来查询元素的问题:

代码语言:javascript
复制
// ❌
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 以及别的一些机制。

代码语言:javascript
复制
// ❌
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 会对每个元素读取的内容。好处是:即使元素的文本内容被其它不同元素分割了,它还是能够以此做查询。比如:

代码语言:javascript
复制
// 假如现在我们有这样的 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 都打印出来!

代码语言:javascript
复制
// 假设我们有这样的 DOM
// <button><span>Hello</span> <span>World</span></button>
screen.getByRole('blah')

上面会报这样的错误:

代码语言:javascript
复制
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>

这里要注意的是,我们并没有为 <button/> 设置 Role 而加上 role=button。因为这是隐式的 Role,下一节会详细说明。

建议:阅读并根据 “Which Query Should I Use" Guide” 里的推荐顺序来使用 Query

错误地添加可访问属性:aria-role

重要程度:高

代码语言:javascript
复制
// ❌
render(<button role="button">Click me</button>)

// ✅
render(<button>Click me</button>)

像上面那样随意添加/修改可访问属性(Accessibility Attributes)不仅没有必要,而且还会把 Screen Reader 和用户搞懵。只有当无法满足当前的 HTML 语义时(比如你写了一个非原生的 UI 组件,同时也要让它 像 AutoComplete 一样可访问),你才应该使用可访问属性。假如这就是你现在要开发的东西,那可以用现有的第三库根据 WAI-ARIA 实践来实现可访问性。它们一般会有一些 很好的样例来参考。

注意:如果要让 input 可以通过 role 来访问,你需要指定对应的 type 属性值!

建议:避免错误地添加不必要的或不正确的可访问属性

没有使用 @testing-library/user-event

重要程度:高

代码语言:javascript
复制
// ❌
fireEvent.change(input, {target: {value: 'hello world'}})

// ✅
userEvent.type(input, 'hello world')

@testing-library/user-event 是在 fireEvent 基础上实现的,但它提供了一些更接近用户交互的方法。上面这个例子中,fireEvent.change 其实只触发了 Input 的一个 Change 事件。但是 type 则可以对每个字符都会触发 keyDownkeyPresskeyUp 一系列事件。这能更接近用户的真实交互场景。好处是可以很好地和你当前那些没有监听 Change 事件的库一起使用。

我们现在还在进行 @testing-library/user-event 这个库的开发,来保证它能像它承诺的那样:能够触发用户在执行特定操作时会触发的所有相同事件。不过,现在它还没完全做到这一点,这也是为什么它还没有合入 @testing-library/dom (可能在未来的某个时候会合入)。但是,我对它有足够的信心,建议你多关注和使用它,而不是 fireEvent

建议:尽可能地使用 @testing-library/user-event,而不是 fireEvent

没有用 query* 来断言元素不存在

重要程度:高

代码语言:javascript
复制
// ❌
expect(screen.queryByRole('alert')).toBeInTheDocument()

// ✅
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()

把暴露 query* 相关的 API 出来的唯一原因是:可以在找不到元素的情况下不会抛出异常(返回 null)。唯一的好处是可以用来判断这个元素是否没有被渲染到页面上。这是很重要的,因为类似 get*find* 相关的 API 在找不到元素时都会自动抛出异常 —— 这样你就可以看到渲染的内容以及为什么找不到元素的原因。然而,query* 只会返回 null,所以 toBeInTheDocument 在这里最好的用法就是:判断 null 不在 Document 上。

建议:query* API 只用于断言当前元素不能被找到

waitFor 等待 find* 的查询结果

重要程度:高

代码语言:javascript
复制
// ❌
const submitButton = await waitFor(() =>
  screen.getByRole('button', {name: /submit/i}),
)

// ✅
const submitButton = await screen.findByRole('button', {name: /submit/i})

上面两段代码几乎是等价的(find* 其实也是在内部用了 waitFor),但是第二种使用方法更清晰,而且抛出的错误信息会更友好。

建议:当查询那些不能立马能访问到的元素时,使用 find*

waitFor 传空 callback

重要程度:高

代码语言:javascript
复制
// ❌
await waitFor(() => {})
expect(window.fetch).toHaveBeenCalledWith('foo')
expect(window.fetch).toHaveBeenCalledTimes(1)

// ✅
await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
expect(window.fetch).toHaveBeenCalledTimes(1)

waitFor 的目的是可以让你等一些指定的事情发生。如果传了空的 callback,可能它在今天还能 Work,因为你只是想在 Event Loop 等一个 Tick 就好了。但这样你也会留下一个脆弱的测试用例,一旦改了某些异步逻辑它很可能就崩了。

建议:在 waitFor 里等待指定的断言,不要传空 callback

一个 waitFor callback 里有多个断言

重要程度:低

代码语言:javascript
复制
// ❌
await waitFor(() => {
  expect(window.fetch).toHaveBeenCalledWith('foo')
  expect(window.fetch).toHaveBeenCalledTimes(1)
})

// ✅
await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
expect(window.fetch).toHaveBeenCalledTimes(1)

在上面的例子中,如果 window.fetch 调用了两次,那么 waitFor 就会失败,但是我们就得等到超时了才能看到具体报错。而如果 waitFor 里只有一个断言,我们则可以等待 UI 渲染到断言的同时,也可以在其中一个断言失败时更快地获得报错信息。

建议:waitFor 的 callback 里只放一个断言

waitFor 中使用副作用

重要程度:高

代码语言:javascript
复制
// ❌
await waitFor(() => {
  fireEvent.keyDown(input, {key: 'ArrowDown'})
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

// ✅
fireEvent.keyDown(input, {key: 'ArrowDown'})
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

waitFor 适用的情况是:在执行的操作和断言之间存在不确定的时间量。因此,callback 可在不确定的时间和频率(在间隔以及 DOM 变化时调用)被调用(或者检查错误)。所以这也意味着你的副作用可能会被多次调用!

同时,这也意味着你不能在 waitFor 里面使用快照断言(SnapShot Assertion)。如果你想要用快照断言,首先要等待某些断言走完了,然后再拍快照。

建议:把副作用放在 waitFor 回调的外面,回调里只能有断言

get* 来做断言

重要程度:低

代码语言:javascript
复制
// ❌
screen.getByRole('alert', {name: /error/i})

// ✅
expect(screen.getByRole('alert', {name: /error/i})).toBeInTheDocument()

虽然这不是什么大问题,但我还是想说下我的观点。如果 get* API 找不到元素,它就会抛出异常,打印整个 DOM 树结构(语法高亮),在 Debug 的时候很有用。也因为这点,断言是永远不可能失败的(因为如果找不到元素,查询在断言之前抛出异常)。

因为这个原因,很多人直接不做断言了。这其实也还好,但是我个人通常来说,会把断言留着,这样可以让后面做重构、修改的人知道:这里不是个查询操作,而是个断言操作。

建议:如果你想断言某个东西是否存在,那么就做显式的断言操作

总结

作为测试库工具系列的维护者,我们尽最大努力使 API 能够引导人们尽可能有效地使用,一些不足之处,我们会尝试正确地记录下来,即使这会非常地困难(尤其是 API 改动/升级等)。希望这篇文章会帮到你,我们只是想你更有信心地交付你的代码。

Good Luck

好了,这篇外文就给大家带到这里了。翻译这篇文章还是花不少时间的,同时也学到了很多 RTL 这个库的一些思想,希望大家也能吸收里面一些测试思路。

参考资料

[1]

原文: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-04-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 写代码的海怪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 正片开始
  • 没有使用 Testing Library 的 ESLint 插件
  • 还在用 Wrapper 作为 render 返回值的变量名
  • 手动使用 cleanup
  • 不用 screen
  • 使用错误的断言 API
  • 将不必要的操作放在 act 里
  • 使用错误的 Query
    • 使用 container 来查询元素
      • 没有用文本来做查询
        • 多数情况下没有使用 *ByRole
        • 错误地添加可访问属性:aria-,role
        • 没有使用 @testing-library/user-event
        • 没有用 query* 来断言元素不存在
        • 用 waitFor 等待 find* 的查询结果
        • 给 waitFor 传空 callback
        • 一个 waitFor callback 里有多个断言
        • 在 waitFor 中使用副作用
        • 用 get* 来做断言
        • 总结
          • 参考资料
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档