使用 testing-library 的建议

前言

本文为 TL 的作者列举的使用 TL 做单例测试时会犯的常见错误。

原文连接:Common mistakes with React Testing Library

安装 ESLint 插件

如果想要避免常见的错误,使用官方的 ESLint 插件是很好的选择:

create-react-app 已经内置 eslint-plugin-testing-library

对象解构获取 render 的返回值

js
1// ❌
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

js
1// ❌
2import { render, screen, cleanup } from '@testing-library/react'
3
4afterEach(cleanup)
5
6// ✅
7import { render, screen } from '@testing-library/react'

cleanup是自动执行的,不要再手动执行cleanup

使用 screen

js
1// ❌
2const { getByRole } = render(<Example />)
3const errorMessageNode = getByRole('alert')
4
5// ✅
6render(<Example />)
7const errorMessageNode = screen.getByRole('alert')

screen是在 6.11 版本之后添加的。它跟render函数一样被引入就可以了:

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

使用 screen 的好处是:在添加或者删除 DOM query 时,不需要实时地从render的返回值里获取内容。输入screen,编辑器会自动补全它的 API。

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

使用 jest-dom 的断言

js
1const 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

js
1// ❌
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 查询

js
1// ❌
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 查询元素

js
1// ❌
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 行为,测试代码也会变得难度,而且很容易崩。

用文本查询

js
1// ❌
2screen.getByTestId('submit-button')
3
4// ✅
5screen.getByRole('button', { name: /submit/i })

我们更建议你用真实的文本来做查询(关于地区语言,应该用默认的地区语言文本),而不是用 Test ID 以及别的一些机制。

多数情况下使用ByRole

RTL 的最近几个版本中,对ByRole相关的 Query API 做了很多升级,这是对组件渲染输出做查询操作的最推荐的方法。下面是它的一些功能:

  • name 选项可以让你通过元素的 "Accessible Name" 查询元素,这也是 Screen Reader 会对每个元素读取的内容。好处是:即使元素的文本内容被其它不同元素分割了,它还是能够以此做查询。比如:

    js
    1// 假如现在我们有这样的 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 都打印出来。

    js
    1// 假设我们有这样的 DOM
    2// <button><span>Hello</span> <span>World</span></button>
    3screen.getByRole('blah')

    上面会报这样的错误:

    js
    1TestingLibraryElementError: 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

js
1// ❌
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

js
1// ❌
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*来断言元素不存在

js
1// ❌
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*断言

js
1// ❌
2screen.getByRole('alert', { name: /error/i })
3
4// ✅
5expect(screen.getByRole('alert', { name: /error/i })).toBeInTheDocument()

如果 get* API 找不到元素,它就会抛出异常。

因为这个原因,很多人直接不做断言了。但最好把断言留着,因为这样能让后面做重构、修改的人知道:这里不是一个查询操作,而是断言操作。

不用 waitFor 等待 find*的查询结果

js
1// ❌
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

js
1// ❌
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 里只放一个断言

js
1// ❌
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 中使用副作用

js
1// ❌
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 里面也不能使用快照断言。如果想要快照断言,首先要等某些断言走完了,然后再拍快照。

笔者遇到的坑

  1. 间接引入组件时测试不通过

    js
    1// components/Button/index.tsx
    2export default Button
    js
    1// components/index.ts
    2export { default as Button } from './Button'
    js
    1// jest.test.tsx
    2// ❌ wrong
    3import { Button } from 'components'
    4
    5// ✅ right
    6import Button from 'components/Button'
  2. 遇到 less 等样式文件导致报错时

    安装identity-obj-proxy

    js
    1module.exports = {
    2  moduleNameMapper: {
    3    '^.+\\.module\\.(css|sass|scss|less)$': 'identity-obj-proxy',
    4  },
    5}

    这个模块是为了解决我们 UT 测试的组件中对于非 JS 资源的使用,比如 CSS,PNG,SVG 等。