错误地添加可访问属性:aria-
,role
重要程度:高
// ❌ 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
重要程度:高
// ❌ fireEvent.change(input, {target: {value: 'hello world'}}) // ✅ userEvent.type(input, 'hello world')
@testing-library/user-event 是在 fireEvent
基础上实现的,但它提供了一些更接近用户交互的方法。上面这个例子中,fireEvent.change
其实只触发了 Input 的一个 Change 事件。但是 type
则可以对每个字符都会触发 keyDown
、keyPress
和 keyUp
一系列事件。这能更接近用户的真实交互场景。好处是可以很好地和你当前那些没有监听 Change 事件的库一起使用。
我们现在还在进行 @testing-library/user-event
这个库的开发,来保证它能像它承诺的那样:能够触发用户在执行特定操作时会触发的所有相同事件。不过,现在它还没完全做到这一点,这也是为什么它还没有合入 @testing-library/dom
(可能在未来的某个时候会合入)。 但是,我对它有足够的信心,建议你多关注和使用它,而不是 fireEvent
。
建议:尽可能地使用
@testing-library/user-event
,而不是fireEvent
没有用 query*
来断言元素不存在
重要程度:高
// ❌ 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*
的查询结果
重要程度:高
// ❌ const submitButton = await waitFor(() => screen.getByRole('button', {name: /submit/i}), ) // ✅ const submitButton = await screen.findByRole('button', {name: /submit/i})
上面两段代码几乎是等价的(find*
其实也是在内部用了 waitFor
),但是第二种使用方法更清晰,而且抛出的错误信息会更友好。
建议:当查询那些不能立马能访问到的元素时,使用
find*
给 waitFor
传空 callback
重要程度:高
// ❌ 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 里有多个断言
重要程度:低
// ❌ 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
中使用副作用
重要程度:高
// ❌ 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*
来做断言
重要程度:低
// ❌ screen.getByRole('alert', {name: /error/i}) // ✅ expect(screen.getByRole('alert', {name: /error/i})).toBeInTheDocument()
虽然这不是什么大问题,但我还是想说下我的观点。如果 get*
API 找不到元素,它就
会抛出异常,打印整个 DOM 树结构(语法高亮),在 Debug 的时候很有用。也因为这点,断言是永远不可能失败的(因为如果找不到元素,查询在断言之前抛出异常)。
因为这个原因,很多人直接不做断言了。这其实也还好,但是我个人通常来说,会把断言留着,这样可以让后面做重构、修改的人知道:这里不是个查询操作,而是个断言操作。
建议:如果你想断言某个东西是否存在,那么就做显式的断言操作
总结
作为测试库工具系列的维护者,我们尽最大努力使 API 能够引导人们尽可能有效地使用,一些不足之处,我们会尝试正确地记录下来,即使这会非常地困难(尤其是 API 改动/升级等)。希望这篇文章会帮到你,我们只是想你更有信心地交付你的代码。
Good Luck!
好了,这篇外文就给大家带到这里了。翻译这篇文章还是花不少时间的,同时也学到了很多 RTL 这个库的一些思想,希望大家也能吸收里面一些测试思路。