testing-library 入门

安装依赖

bash
1npm install @testing-library/jest-dom @testing-library/react @testing-library/user-event --save-dev
  • @testing-library/jest-dom:用于 dom、样式类型等元素的选取
  • @testing-library/react:用于对 react 代码测试
  • @testing-library/user-event:用于模拟用户行为事件

安装完成后,需要将 jest-dom 引入全局环境,这里就使用setup文件来装载。

在 mock 文件夹内创建setup文件,并引入jest-dom

js
1// setup.ts
2import '@testing-library/jest-dom'

jest-config.ts中设置jest-dom的引入路径

js
1export default {
2	...
3  setupFilesAfterEnv: ['./src/mock/setup.ts'],
4}

安装完后,测试一下是否支持testing-library。

js
1// 注意修改测试文件后缀名为`tsx`
2import { render, screen } from '@testing-library/react'
3import App from './App'
4
5test('renders learn react link', () => {
6  render(<App />)
7  const linkElement = screen.getByText(/learn react/i)
8  expect(linkElement).toBeInTheDocument()
9})
  • render是用来渲染 react 组件的函数
  • screen.getByText是查询器,这个查询器会根据文字来查询出页面中对应的 dom 节点
  • toBeInTheDocument是 jest 提供的匹配器,表示 dom 节点能够正常渲染到页面上

如果编辑器是 vscode 的话,可以下载插件 Jest ——用来自动运行测试用例以及其他功能

chrome 浏览器中下载插件 Testing Playground。在测试组件时,经常需要用到查询器查询对应的 dom,查询器很多,比如 ByRole、ByText 等,用这个插件能够很好地提示我们用哪个查询器。——来自官方推荐

查看 dom 结构

将App.tsx中的代码替换成以下内容

js
1import React from 'react'
2
3const title = 'Hello React'
4
5function App() {
6  return <div>{title}</div>
7}
8
9export default App

同时修改src/App.test.tsx

diff
1import React from 'react';
2import { render, screen } from '@testing-library/react';
3
4import App from './App';
5
6describe('App', () => {
7  test('renders App component', () => {
8    render(<App />);
9
10+    screen.debug();
11  });
12});

screen.debug方法能够打印出页面中现有的 dom 结构,通过它我们知道现在测试环境中的 dom 结构是这样的

html
1<body>
2  <div>
3    <div>Hello React</div>
4  </div>
5</body>

接下来我们给 App 组件增加 useState 以及一个子组件让它看起来更像平时编写的组件

js
1import React, { PropsWithChildren } from 'react'
2
3function App() {
4  const [search, setSearch] = React.useState('')
5
6  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
7    setSearch(event.target.value)
8  }
9  return (
10    <div>
11      <Search value={search} onChange={handleChange}>
12        Search:
13      </Search>
14
15      <p>Searches for {search ? search : '...'}</p>
16    </div>
17  )
18}
19interface SearchProps {
20  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
21  value: string;
22}
23const Search: React.FC<PropsWithChildren<SearchProps>> = ({ value, onChange, children }) => {
24  return (
25    <div>
26      <label htmlFor="search">{children}</label>
27      <input id="search" type="text" value={value} onChange={onChange} />
28    </div>
29  )
30}
31
32export default App

新的 dom 结构如下:

html
1<body>
2  <div>
3    <div>
4      <div>
5        <label for="search"> Search: </label>
6        <input id="search" type="text" value="" />
7      </div>
8      <p>Searches for ...</p>
9    </div>
10  </div>
11</body>

getByText

testing-library提供了非常多的search function来抓取 dom 元素,抓取到的 dom 元素可以用来断言或者其他交互行为。

最简单的抓取:

diff
1describe('App', () => {
2  test('renders App component', () => {
3    render(<App />);
4
5+   screen.getByText('Search:');
6  });
7});

可以始终使用screen.debug来获取 dom 结构,这样方便查看 DOM 结构。

screen 是屏幕对象,表示用户在屏幕上能够看到的内容的集合

抓取到 dom 后就可以用来做某些交互或者断言。

比如我们要断言是否存在 document 内

diff
1describe('App', () => {
2  test('renders App component', () => {
3    render(<App />);
4    // 隐式断言,如果元素不存在则会抛出一个错误
5    screen.getByText('Search:');
6    // 推荐使用显式断言
7+    expect(screen.getByText('Search:')).toBeInTheDocument();
8  });
9});

screen.getByText这样的 search 函数默认有隐式断言功能,如果被搜索的元素不在 document 中,就能够很快发现问题。

类似于 getByText 这样的函数特别多,除了挨个啃官方文档的 API 外,还有个简单的方法:

打开localhost:3000,利用 Testing Playground的功能能够很快知道想要搜索的 DOM 元素用什么 search 函数

image-20221030224906350

Playground会给出一个比较不错的答案,是不是很方便呢?

getByRole

还有一个很常用的Search API —— getByRole。

这个 API 经常被用于抓取aria-role attributes(特性)的 HTML 标签。

ARIA (Accessible Rich Internet Applications) 是一组属性,用于定义使残障人士更容易访问 Web 内容和 Web 应用程序(尤其是使用 JavaScript 开发的应用程序)的方法。

除了开发者自己定义 aria-role外,部分 HTML 标签也会有隐形的aria-role来表示它的语义,getByRole能抓取到这些带有隐形 role 属性的标签。

比如,页面上的<Input/>就能够被抓取,使用插件试一下:

image-20221031132644003

可以看到input的role属性为textbox,我们可以拷贝插件推荐的抓取代码来对抓取到的元素进行断言。

js
1test('renders App component', () => {
2  render(<App />)
3  expect(
4    screen.getByRole('textbox', {
5      name: /search:/i,
6    }),
7  ).toBeInTheDocument()
8})

上面的代码用来断言label为search:,role 为textbox的元素是否存在于页面中。

除了input外,还有很多拥有默认的role属性的html 标签。

下面列举一些常用的 html 标签和它们隐藏的 role 属性:

标签隐性 role
a , hreflink
a (没有 href 属性), body, div, spangeneric
formform
h1 到 h6heading
htmldocument
imgimg
pparagraph
tabletable
ullist
lilistitem
inputtextbox
buttonbutton

除了默认的 role 外,我们也可以自己加 role 对标签进行覆盖,比如下面的例子:

js
1const CustomRoleDiv = () => <div role="__widget_container">widget</div>
js
1expect(screen.getByRole('__widget_container')).toBeInTheDocument()

自定义的 role 会覆盖默认的 role 属性,大部分情况下,我们都应该用默认的 role 属性。

除了基础的角色 role 外,W3C 在 ARIA 语义的提案中还包含了 aria 属性,这个语义表明 role 语义的状态和属性,比如 “按压” 的 button, "隐藏" 的 button 等。

aria 属性用来表示对应角色下的额外特殊含义,可以帮助我们在多个相同角色中选中我们需要的那一个,它们在 W3C 提案中是这样含义的:

  • aria-hidden: 不在 DOM 树上访问的元素;
  • aria-selected: 元素是否被选中;
  • aria-checked: 元素是否被勾选;
  • aria-current: 当前选中的元素;
  • aria-pressed: 被按压的元素;
  • aria-expanded:元素是否被展开;
  • aria-level: 区域的等级,值得一提的是,h1 - h6 会有默认的aria-level属性,值对应 1-6;
  • aria-describedby: 可以通过描述来定位额外的元素。

aria 属性并不会对显示造成影响,只是语义上的属性。

下面以 button 为例:

html
1<button aria-pressed></button>

我们可以这么选到它:

js
1screen.getByRole('button', { pressed: true })

可以看到getByRole的第二个参数可以用来描述 aria 的状态,以此选择到正确的元素。


使用aria-role搜索还有一个非常重要的知识点:

以下面的两个 div 元素为例:

html
1      <div role="note" style={{ display: "none" }} aria-hidden>
2        1234
3      </div>
4      <div role="note">1234</div>

它们对应的抓取代码是这样的:

js
1const [hiddenNote] = screen.getAllByRole('note', { hidden: true })
2const normalNote = screen.getByRole('note')

有两个疑问 ❓:

  1. 两个元素的role相同,为什么用getByRole直接可以抓取到第二个元素。
  2. 第一个 aria-hidden的元素为什么需要用getAllByRole。

第一个问题的答案是:

screen.getByRole("note")跟screen.getByRole("note",{ hidden : false })是一样的效果,所以可以直接忽略aria-hidden = true的元素。

第二个问题的答案是:

screen.getByRole("note",{ hidden : true })的意思是把aria-hidden = false的元素抓取过来(允许包含aria-hidden = true)的元素。


除了上面的七种状态筛选外,还有一个非常常用的 name 筛选器(Testing Playground 经常会用到它)。

这个筛选项可以查询可访问的名称,比如文本内容,或者**aria-label 属性**等。

例如以下 HTML 元素

html
1<div aria-label="test_note">1234</div>

使用name筛选器可以抓取到它:

js
1screen.getByRole('generic', { name: 'test_note' })

一般来说,我们只需要根据Testing Playground给出的抓取代码就已经够用了,aria-相关的属性内容比较多,不需要强行记忆。

其他 Search API

getByText 和 getByRole是最常用的 search 页面 HTML 元素的函数。

还有一些针对不同的参照物提供的搜索函数(前缀加上getBy|queryBy|findBy):

  • LabelText :

    html
    1<label for="search" />
  • PlaceholderText:

    html
    1<input placeholder="Search" />
  • AltText:

    html
    1<img alt="profile" />
  • DisplayValue:

    html
    1<input value="JavaScript" />
  • Title:

    html
    1<span title="title">123</span>
  • TestId:

    html
    1<div data-testid="custom-element" />

    testId 比较特殊,它是通过 data-testid自定义特性来指定一个后门查询方式。

    这个对整个页面的语义和功能来说是没有任何影响的,相当于只是我们单独加的一个标识来确定指定的区域,一般只有实在不知道怎么选取需要的区域,才会去使用它。

    js
    1const testidItem = screen.getByTestId('custom-element')

findBy 和 queryBy

跟 getBy xxx相似的,还有另外的search变体 ——queryBy 和findBy

findBy 有以下搜索函数:

  • findByText
  • findByRole
  • ...

searchBy 有以下搜索函数:

  • queryByText
  • queryByRole
  • ...

可以看出来就是把前半段的 getBy 换成queryBy|findBy。

我们把testing-library的搜索 API 用 By隔开,分为前半段和后半段名称:

  • text、role、PlaceholderText等后半段的名称对应的是不同的参照物。
  • queryBy|findBy|getBy这几个前半段的名称是用于对应不同的使用场景。

queryBy 和 getBy 的区别

我们已经知道了getBy会有隐式断言,所以假设我想要断言页面上没有某个元素时,用 getBy 是会报错的。

js
1import React from 'react'
2import { render, screen } from '@testing-library/react'
3
4import App from './App'
5
6describe('App', () => {
7  test('renders App component', () => {
8    render(<App />)
9
10    screen.debug()
11
12    // fails
13    expect(screen.getByText(/Searches for JavaScript/)).toBeNull()
14  })
15})

通过screen.debug,我们知道页面上是不存在内容为Searches for JavaScript的元素的,所以上面的代码会一直报错。我们没办法用 getBy 断言一个不存在的元素。

用 queryBy 换掉getBy就可以:

js
1test('renders App component', () => {
2  render(<App />)
3
4  screen.debug()
5
6  // fails
7  expect(screen.queryByText(/Searches for JavaScript/)).toBeNull()
8})

findBy 和 getBy 的差别

findBy 用于最终将出现的异步元素。

举一个例子,App 组件在渲染后,将获取一次异步数据,然后异步数据获取完成后,会更新一次页面。

diff
1import React, { PropsWithChildren } from 'react';
2+ function getUser() {
3+   return Promise.resolve({ id: '1', name: 'Robin' });
4+ }
5function App() {
6  const [search, setSearch] = React.useState('');
7+  const [user, setUser] = React.useState<{ id: string; name: string } | null>(null);
8+  React.useEffect(() => {
9+    const loadUser = async () => {
10+      const user = await getUser();
11+      setUser(user);
12+    };
13
14+    loadUser();
15+  }, []);
16  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
17    setSearch(event.target.value);
18  }
19  return (
20    <div>
21+      {user ? <p>Signed in as {user.name}</p> : null}
22
23      <Search value={search} onChange={handleChange}>
24        Search:
25      </Search>
26
27      <p>Searches for {search ? search : '...'}</p>
28    </div>
29  );
30}

上面的 App 组件在 useEffect执行前不会渲染<p>Signed in as {user.name}</p>,我们需要等它一会,findBy就具有 “等待” 的作用:

diff
1describe('App', () => {
2  test('renders App component', async () => {
3    render(<App />);
4
5+    screen.debug();
6+    expect(screen.queryByText(/Signed in as/)).toBeNull();
7
8+    expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
9+    screen.debug();
10  });
11});

如果仔细观察两个 screen.debug,会发现 dom 结构是不同的,在 await screen.findByText后面的debug打出来的 dom 中,会多一个

html
1<p>Signed in as Robin</p>

findBy、queryBy、getBy 使用场景

如果你希望断言一个不存在的元素,则使用 queryBy

如果你希望等待一会后抓取到某个元素,则使用findBy

否则则使用默认的getBy

如何抓取多个元素

只需要在get/query/find单词后加上All

  • getAllBy
  • queryAllBy
  • findAllBy

这些都会返回一个数组,里面保存着多个抓取到的元素。

断言函数

大部分的断言函数都来自于 Jest, testing-library也扩展了很多函数,这些函数可以在jest-dom查到。

根据使用场景可以分为页面是否可见、表单验证和代码验证等三类:

断言使用场景断言 API
页面可见toBeEmptyDOMElement toBeVisible toBeInTheDocument toHaveTextContent
表单验证toBeDisabled toBeEnabled toBeRequired toHaveFocus toBeChecked toHaveFormValues toHaveValue
代码验证toHaveAttribute toHaveClass toHaveStyle

页面可见类

  • toBeEmptyDOMElement:标签之间是否有可见内容, 即使是空格也会失败;

    html
    1✅
    2<div aria-label="empty_note"></div>
    3❌
    4<div aria-label="note">123</div>
    5❌
    6<div aria-label="note"></div>
  • toBeVisible:是否可见,从用户直接观察的角度看能否可见;

    html
    1❌ <div role="note" style={{ display: "none" }}>1234</div>
    2❌ <div role="note" hidden={true}>1234</div>
    3✅ <div role="note">1234</div>
  • toBeInTheDocument:是否存在在文档中,document.body 是否存在这个元素。

    html
    1✅ <div role="note" hidden={true}>1234</div>
    2✅ <div role="note">1234</div>
    3✅ <div role="note" style={{ display: "none" }}>1234</div>
  • toHaveTextContent:匹配对应节点有没有指定的内容

    html
    1<div role="note">1234</div>

    如果想匹配该元素的文本是否包含 1,则可以这么写

    js
    1expect(screen.getByRole('note')).toHaveTextContent(/1/i)

表单验证类

  • toBeDisabled:检查元素是否通过 disable 属性判断
  • toBeEnabled:是否未被禁用,等同于 .not.toBeDisabled;
  • toBeRequired:元素是否必填
  • toHaveFocus:元素是否聚焦
  • toBeChecked:checkbox 或者是 radio 是否被选中
  • toHaveFormValues:验证整体表单的值是否和预期值匹配
  • toHaveValue:与 toHaveFormValues 类似,不过不同的是 toHaveValue 验证某个单独的表单元素,而不是全部
  • toHaveDisplayValue:断言input、select、textarea是否有用户能看到的显示值

代码验证类

  • toHaveAttribute:匹配元素是否具备某个特性
  • toHaveClass:匹配元素是否包含某个 class
  • toHaveStyle:匹配元素是否具有对应样式
  • toContainElement:匹配一个元素是否包含另一个元素作为后代
  • toContainHTML:断言表示 HTML 元素的字符串是否包含在另一个元素中。

fireEvent

我们可以使用fireEvent来模拟用户的行为。

js
1describe('App', () => {
2  test('renders App component', async () => {
3    render(<App />)
4
5    screen.debug()
6    fireEvent.change(
7      screen.getByRole('textbox', {
8        name: /search:/i,
9      }),
10      {
11        target: { value: 'JavaScript' },
12      },
13    )
14    screen.debug()
15  })
16})

fireEvent.change 接受一个抓取到的元素和传递给 onChange函数的值为参数。

通过上面的代码,相当于用户模拟了在<input id="search" type="text" value={value} onChange={onChange} />这个组件上输入了JavaScript字符。

传给onChange 函数的参数是{target: { value: 'JavaScript' }}。对应源代码中的

js
1function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
2  setSearch(event.target.value)
3}

通过第二个screen.debug,能清楚看到input 的值被设置成了:

html
1<input id="search" type="text" value="JavaScript" />

此外,还能看到RTL给我们报了一个 warning

image-20221031151100993

这说明我们的组件有一些异步更新的操作。除了按照提示的使用act函数外,还能够用 findByText来消除这个警告:

diff
1describe('App', () => {
2  test('renders App component', async () => {
3    render(<App />);
4    screen.debug();
5+    expect(await screen.findByText(/Signed in as/i)).toBeInTheDocument();
6    fireEvent.change(screen.getByRole('textbox', { name: /search:/i }), {
7      target: { value: 'JavaScript' },
8    });
9
10    screen.debug();
11  });
12});

接着我们再对 change 前和 change 后做一些断言。

diff
1describe('App', () => {
2  test('renders App component', async () => {
3    render(<App />);
4    screen.debug();
5    expect(await screen.findByText(/Signed in as/i)).toBeInTheDocument();
6+    expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
7    fireEvent.change(screen.getByRole('textbox', { name: /search:/i }), {
8      target: { value: 'JavaScript' },
9    });
10+    expect(screen.getByText(/Searches for JavaScript/)).toBeInTheDocument();
11
12    screen.debug();
13  });
14});

我们使用queryBy搜索来检查元素在事件之前是否不存在,使用getBy搜索变体来检查它在事件之后是否存在。

getBy也可以替换成queryBy使用。

RTL 的 fireEvent可以直接模拟用户的行为,而且在之后可以用断言来判断此时应该发生什么。

userEvent

还有一个userEvent 扩展能够更真实地模拟用户的行为。

fireEvent.change可以模拟用户的输入行为。

userEvent.type也能做到,但是它还能触发 keyDown、keypress和 keyUp事件

diff
1-    fireEvent.change(screen.getByRole('textbox', { name: /search:/i }), { target: { value: 'JavaScript' } });
2
3+    userEvent.type(screen.getByRole('textbox', { name: /search:/i }), 'JavaScript');

如果有可能的话,优先考虑 userEvent 再考虑fireEvent

mock callback 函数

有些时候我们需要进行单元测试,比如下面的子组件:

js
1const Search: React.FC<PropsWithChildren<SearchProps>> = ({ value, onChange, children }) => {
2  return (
3    <div>
4      <label htmlFor="search">{children}</label>
5      <input id="search" type="text" value={value} onChange={onChange} />
6    </div>
7  )
8}

我想要知道传递给 Search 组件的 onChange 方法是否被调用过。

这时候可以利用 jest.fn来模拟一个 callback,传递给 Search 组件并对 callback 的次数进行断言。

js
1test('renders search component', async () => {
2  const callback = jest.fn()
3  render(<Search value="1" onChange={callback}></Search>)
4
5  userEvent.type(screen.getByRole('textbox'), 'JavaScript')
6
7  expect(callback).toBeCalledTimes(10)
8})

使用 userEvent.type 来模拟用户输入 JavaScript 时,共会触发 10 次按键。

如果使用 fireEvent 的话,只会触发一次。

js
1  test('renders search component', async () => {
2    const callback = jest.fn();
3    render(<Search value="1" onChange={callback}></Search>);
4
5    // userEvent.type(screen.getByRole('textbox'), 'JavaScript');
6    fireEvent.change(screen.getByRole('textbox'), {
7      target: { value: 'JavaScript' },
8    });
9
10    // expect(callback).toBeCalledTimes(10);
11    expect(callback).toBeCalledTimes(1);
12  });
13});

这也说明 userEvent 更接近用户行为。

mock axios

js
1import React from 'react'
2import axios from 'axios'
3
4const URL = 'http://hn.algolia.com/api/v1/search'
5
6function App() {
7  const [stories, setStories] = React.useState([])
8  const [error, setError] = React.useState(null)
9
10  async function handleFetch(event) {
11    let result
12
13    try {
14      result = await axios.get(`${URL}?query=React`)
15
16      setStories(result.data.hits)
17    } catch (error) {
18      setError(error)
19    }
20  }
21
22  return (
23    <div>
24      <button type="button" onClick={handleFetch}>
25        Fetch Stories
26      </button>
27
28      {error && <span>Something went wrong ...</span>}
29
30      <ul>
31        {stories.map((story) => (
32          <li key={story.objectID}>
33            <a href={story.url}>{story.title}</a>
34          </li>
35        ))}
36      </ul>
37    </div>
38  )
39}
40
41export default App

当点击按钮后,就可以发送异步请求,并将结果渲染到页面上。我们不在乎 axios 请求到的结果是不是正确的,这是后端单元测试的内容。

我们需要自己手动 mock axios 来返回一个数据

js
1import React from 'react'
2import axios from 'axios'
3import { render, screen } from '@testing-library/react'
4import userEvent from '@testing-library/user-event'
5
6import App from './App'
7
8jest.mock('axios')
9
10describe('App', () => {
11  test('fetches stories from an API and displays them', async () => {
12    const stories = [
13      { objectID: '1', title: 'Hello' },
14      { objectID: '2', title: 'React' },
15    ]
16
17    axios.get.mockImplementationOnce(() => Promise.resolve({ data: { hits: stories } }))
18
19    render(<App />)
20
21    await userEvent.click(screen.getByRole('button'))
22
23    const items = await screen.findAllByRole('listitem')
24
25    expect(items).toHaveLength(2)
26  })
27})

在模拟 API 并呈现组件之后,我们使用 userEvent API 单击按钮,该按钮将进入 API 请求。

由于请求是异步的,我们必须等待组件更新。与之前一样,我们使用RTL的findBy搜索来等待最终出现的元素。

还可以 mock 一个错误的数据,确保传送数据失败后,展示给用户的是一个带有 error 信息的页面

js
1test('fetches stories from an API and fails', async () => {
2  axios.get.mockImplementationOnce(() => Promise.reject(new Error()))
3
4  render(<App />)
5
6  await userEvent.click(screen.getByRole('button'))
7
8  const message = await screen.findByText(/Something went wrong/)
9
10  expect(message).toBeInTheDocument()
11})

waitFor

findBy函数用来处理最终将出现的异步元素,它有一个默认的固定的时间——1000 毫秒,超过这个时间的异步元素将不会被 findBy查到。

参考这个例子:

js
1function getUser() {
2  return new Promise<{ id: string; name: string }>((resolve, reject) => {
3    setTimeout(() => {
4      resolve({ id: '1', name: 'Robin' })
5    }, 1000)
6  })
7}
8const Async = () => {
9  const [search, setSearch] = React.useState('')
10  const [user, setUser] = React.useState<{ id: string; name: string } | null>(null)
11  React.useEffect(() => {
12    const loadUser = async () => {
13      const user = await getUser()
14      setUser(user)
15    }
16
17    loadUser()
18  }, [])
19  return <div>{user ? <p>Signed in as {user.name}</p> : <p>loading</p>}</div>
20}
21
22export default Async

以下是用findBy来查询元素的代码:

js
1it('async component', async () => {
2  render(<Async />)
3  expect(await screen.findByText(/Signed in as/)).toBeInTheDocument()
4})

最终这段代码会报错,原因是findByText并没有查到内容。

如果把定时器的时间改成 900,test代码最终能成功运行。

js
1function getUser() {
2  return new Promise<{ id: string; name: string }>((resolve, reject) => {
3    setTimeout(() => {
4      resolve({ id: '1', name: 'Robin' })
5    }, 900)
6  })
7}

有没有一种方法可以取代findBy,让@testing-library能够等得更久一点?

答案是waitFor函数,findBy相当于waitFor和getBy的语法糖,用waitFor可以自定义等待的时间,所以方法更加灵活。

它是这么写的:

js
1it('async component', async () => {
2  render(<Async />)
3  await waitFor(
4    () => {
5      screen.debug() // 通过 debug 查看 dom 结构
6      expect(screen.getByText(/Signed in as/)).toBeInTheDocument()
7    },
8    {
9      timeout: 1100, // 比异步时间长一点点
10      interval: 100, // 执行间隔
11    },
12  )
13})

waitfor接收两个参数

  • 第一个是需要重复执行的回调函数,我们可以在其中查询元素并且断言,waitfor 会根据设定(或者默认)的超时时间和执行间隔来重复执行回调。

  • 第二个参数是可以配置的数据,比如说超时时间(timeout)、执行间隔(interval),通过这个参数我们就可以自定义我们需要的超时场景。

renderHook

当项目有一些公用的 hooks 时,可以用 renderHook来测试它的功能,对于能改变 state 的行为,我们需要放在 act 函数中做一次更新。

以下是hooks示例:

js
1import { useCallback, useState } from 'react'
2
3const useCount = () => {
4  const [num, setNum] = useState(0)
5
6  const increase = useCallback(() => {
7    setNum(num + 1)
8  }, [num])
9
10  return { num, increase }
11}
12
13export default useCount

对它的测试代码示例:

js
1import { act, renderHook } from '@testing-library/react'
2it('test react hook', () => {
3  const { result } = renderHook(() => useCount())
4  console.log(result)
5  expect(result.current.num).toBe(0)
6  act(() => {
7    /* fire events that update state */
8    result.current.increase()
9  })
10  expect(result.current.num).toBe(1)
11})

renderHook会将useCount的结果放在 result属性中,我们对其解构后从result.current中取出hook返回的内容,再对其进行断言即可。

触发 state 的修改的操作要放在act中以触发rerender。

组件 snapshot

何时适用

快照测试和它的字面意思一样,通过“快速(简单)拍出的照片”来测试,它是将我们需要判定的内容存储下来,在下一次匹配时,会判断两次的结果能否一致,从而达到从整体维度保证组件 UI 完整的能力。

我们可以把组件的快照测试输出的内容理解为 screen.debug(),比对的目的只是需要保证前后两次 debug 的输出是相同的。

基于这个特点,在以下几种情况中,我们可以使用 snapshot 来替代断言测试:

  • 每个版本迭代后,都需要测试组件 UI 的完整性
  • 某些不常更新的配置,确保它没有被随意修改
  • 不轻易改变,甚至不会去改变的一些公共逻辑

使用快照

React Testing library 中提供了组件快照测试的能力,我们只需要使用它提供给我们的 toMatchSnapshot 断言就好。

以下是示例:

js
1it('snapshot a component', () => {
2  const res = render(<AriaButton />)
3  expect(res.asFragment()).toMatchSnapshot()
4})

随后当前测试的组件快照会被保存在名为__snapshots__的文件夹内,它的格式长这样:

js
1exports[`初始化测试 snapshot a component 1`] = `
2<DocumentFragment>
3  <button
4    aria-describedby="hello"
5  >
6    <div
7      id="hello"
8    >
9      AriaButton
10    </div>
11    <div>
12      123
13    </div>
14  </button>
15</DocumentFragment>
16`

当然,我们也可以仅快照组件一部分

js
1it('snapshot a part of component', () => {
2  render(
3    <div>
4      <button>click here</button>
5    </div>,
6  )
7  expect(screen.getAllByRole('button', { name: 'click here' })).toMatchSnapshot()
8})

同样会生成一份 snapshot 数据:

js
1exports[`初始化测试 snapshot a part of component 1`] = `
2[
3  <button>
4    click here
5  </button>,
6]
7`

当下次执行这个测试用例时,Jest 会将当前处理的结果与生成的 snapshot 数据对比,如果发生变化就会抛错。

我们随手改一下代码

diff
1const AriaButton = () => {
2  return (
3    <button aria-describedby="hello">
4      <div id="hello">AriaButton</div>
5-      <div>123</div>
6    </button>
7  )
8}

运行 Jest 测试代码后 Jest 会抛出两次对比失败的结果:

js
1    Snapshot name: `初始化测试 snapshot a component 1`
2
3    - Snapshot  - 3
4    + Received  + 0
5
6    @@ -5,10 +5,7 @@
7          <div
8            id="hello"
9          >
10            AriaButton
11          </div>
12    -     <div>
13    -       123
14    -     </div>
15        </button>
16      </DocumentFragment>

更新快照

快照测试失败有两种原因:

  1. 代码误修改
  2. 确实发生了某些我们允许的变化

当第 2 种情况时,我们就需要更新快照测试中的结果,在 vscode 中右键后点击update snapshot即可。

image-20230309201720917

当然,也可以使用命令行的形式,详情请查阅更新快照。

行内快照

有一些 DOM 少的组件或者选项少的 configuration,我们并不希望它生成文件,那么可以使用toMatchInlineSnapshot,它会在测试函数内直接生成一段字符串。

js
1it('snapshot inline', () => {
2  const configuration = { env: 'dev', host: 'localhost' }
3  expect(configuration).toMatchInlineSnapshot(`
4{
5  "env": "dev",
6  "host": "localhost",
7}
8`)
9})

上面这段字符串是调用 it 函数后,Jest 自动加上去的。

行内快照看起来跟断言一样,它适用于被快照的对象数据量足够小的情况。

如果快照出来的数据很多,使用它会影响测试代码的可读性。