Published on

React 组件测试最佳实践之 React Testing Library

Authors

React Testing Library 已经成为 React 组件测试的首选,流行的 UI 组件库 antdmui 等都在使用它作为组件测试库,还有Playwright 和 Cypress 等其它测试框架也都在参考其API 。除了支持 React外,它同时还支持很多其它 UI框架库,如 Vue,Angular,Preact,Svelte,React Native等。

1. 如何测试 UI 组件?

Testing Library 给出的答案很简单,以用户的角度来测试,即用户怎么样使用你的组件的,你就应该怎么样测试你的组件。

例如,一个 Fetch 组件包含一个按钮,点击按钮会获取服务端提供的问候语并将其显示

import React, {useState, useReducer} from 'react'
import axios from 'axios'
// 初始状态
const initialState = {
  error: null,
  greeting: null,
}
// Reducer
function greetingReducer(state, action) {
  switch (action.type) {
    case 'SUCCESS': {
      return {
        error: null,
        greeting: action.greeting,
      }
    }
    case 'ERROR': {
      return {
        error: action.error,
        greeting: null,
      }
    }
    default: {
      return state
    }
  }
}
// 组件
export default function Fetch({url}) {
  const [{error, greeting}, dispatch] = useReducer(
    greetingReducer,
    initialState,
  )
  const [buttonClicked, setButtonClicked] = useState(false)
  const fetchGreeting = async url =>
    axios
      .get(url)
      .then(response => {
        const {data} = response
        const {greeting} = data
        dispatch({type: 'SUCCESS', greeting})
        setButtonClicked(true)
      })
      .catch(error => {
        dispatch({type: 'ERROR', error})
      })
  const buttonText = buttonClicked ? '确定' : '加载问候语'
  return (
    <div>
      <button onClick={() => fetchGreeting(url)} disabled={buttonClicked}>
        {buttonText}
      </button>
      {greeting && <h1>{greeting}</h1>}
      {error && <p role="alert">哎哟,获取失败了!</p>}
    </div>
  )
}

那用户是怎么使用这个 Fetch 组件的呢?用户首先会通过 “加载问候语” 找到按钮,然后点击它,等待问候语出现。示例代码(请关注注释,而不是具体的方法调用):

test('加载和显示问候语', async () => {
  // 准备(渲染组件)
  render(<Fetch url="/greeting" />)
  
  // 操作
  // 点击按钮
  await userEvent.click(screen.getByText('加载问候语'))
  // 等待文字出现
  await screen.findByRole('heading')
  // 断言问候语出现
  expect(screen.getByRole('heading')).toHaveTextContent('你好 世界')
})

可以看出,以用户使用的方式来测试的好处:

1.易维护

代码没有关注组件内的状态和 API 请求这些实现细节,这样的测试会更加简洁且稳定。

2.有效

操作和断言部分都在模拟真正用户的交互,这样的测试才是最直接有效的,最终增加我们对组件代码的信心。(其实不管是自测还是测试同学功能或回归测试都是在模拟真实的用户操作)

2. 快速掌握

快速掌握 React Testing Libarary,你需要知道两个核心概念:查询和用户动作。

1.查询

查询是 Testing Library 为你提供在页面上查找元素的方法。有三种 查询类型 ("get", "find", "query"),它们之间区别在于当查找不到元素时是否抛出异常或是否返回一个 Promise 和重试。根据不同的页面场景,选择不同的查询方式,具体可参考 优先级指引

2.用户动作

查询到元素之后,我们就可以通过 user-event 来操作元素,模拟用户在页面上的交互,最后使用 Jest 和 jest-dom 来对元素进行断言。

支持用户动作:鼠标,键盘,剪贴板等。

3. 测试步骤分解

以用户使用的方式来测试,React Testing Library(或Testing Library ) 将这个原则从 库 到 API 贯彻非常到位,例如查询的 API 中 getByRole 或 getByLabelText 或 getByPlaceholderText,它们都是在尝试模拟真正用户在页面找到元素的方式。

接下来我们分步和通用的3A(Arrage-Act-Assert)原则为 Fetch 组件 编写测试

第一步:导入依赖

// 导入 react( jsx/tsx 需要)
import React from 'react'
// import API mocking utilities from Mock Service Worker
import {rest} from 'msw'
import {setupServer} from 'msw/node'
// 导入 react-testing 的方法
import {render, fireEvent, waitFor, screen} from '@testing-library/react'
// 导入 jest-dom,增强 jest 的匹配器
import '@testing-library/jest-dom'
// 导入 待测试的组件
import Fetch from '../fetch'

第二步:Mock

尽量不要去 mock,mock 会让你的测试不稳定,因为 mock 依赖于实现细节,这里我们使用 mock ,这说明什么呢?没错,就是我们的组件设计有问题。这里我们使用 Mock Service Worker 来 mock 组件内 API 请求。

...
import {rest} from 'msw'
import {setupServer} from 'msw/node'
...
// mock /greeting 接口的返回
const server = setupServer(
  rest.get('/greeting', (req, res, ctx) => {
    return res(ctx.json({greeting: '你好 世界'}))
  }),
)
// 开始 mock
beforeAll(() => server.listen())
// 重置 mock
afterEach(() => server.resetHandlers())
// 关闭 mock
afterAll(() => server.close())

第三步:3A之Arrage

我们需要使用 React Testing Library 提供的方法来渲染Fetch 组件

...
import {render} from '@testing-library/react'
...
test('加载和显示问候语', () => {
   render(<Fetch url="/greeting" />)
   ...
})

第四步:3A之Act

模拟用户点击操作

...
import {screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event' 
...
// 找到按钮并点击
await userEvent.click(screen.getByText('加载问候语'))

第五步:3A之Assert

断言用户的期望,这里当用户点击按钮后,期待问候语出现

...
import '@testing-library/jest-dom'
...
// 等待 标题1 出现,这也是一种断言
await screen.findByRole('heading')
// 断言 问候语 出现
expect(screen.getByRole('heading')).toHaveTextContent('你好 世界')

至此,一个完整的的测试编写完成。完整代码如下:

import React from 'react'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import Fetch from './fetch'
const server = setupServer(
  rest.get('/greeting', (req, res, ctx) => {
    return res(ctx.json({greeting: '你好 世界'}))
  }),
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('loads and displays greeting', async () => {
  // 准备
  render(<Fetch url="/greeting" />)
  // 操作
  await userEvent.click(screen.getByText('加载问候语'))
  await screen.findByRole('heading')
  // 断言
  expect(screen.getByRole('heading')).toHaveTextContent('你好 世界')
})

4. 调试测试代码

当使用 getfind 开头的 查询时,如果查询失败,错误信息会自动在控制台打印,包括其 DOM 结构。

还有两个经常会用到的 API:

  • screen.debug

可以将任意元素的 DOM 结构格式化后打印到控制台中,方便调试

  • logRoles

当使用 ByRole 查询时,有时需要打印 DOM 结构的的 role,可以直接使用 logRoles 输出到控制台上。

5. 总结

以用户使用的方式来测试,时刻谨记这一原则,Testing Library 遵循这一原则,我们的测试代码更应该如此,这样的测试才能最终增加我们对组件代码的信心。

通过上面的测试例子,我们还可以看出,整个测试其实就是对组件需求的分解和组件的设计。例如我还可以增加对按钮是否被禁用的断言:

expect(screen.getByRole('button')).toBeDisabled()

就这样组件测试最大价值都被释放出来了,不但保证了代码质量,还保留了组件的设计,以及对需求的分解。

大家如果有问题可以通过评论或 github issues 反馈给我。

6. 你可能会感兴趣

1.如何从 Enzyme 迁移到 React Testing Library

https://testing-library.com/docs/react-testing-library/migrate-from-enzyme

2.如何测试 React Hook

https://github.com/testing-library/react-hooks-testing-library/

3.有用的相关 ESLint

https://github.com/testing-library/eslint-plugin-testing-library

https://github.com/testing-library/eslint-plugin-jest-dom

4.使用 React Testing Library 的常见错误:

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

5.Testing Library 中文文档(非官方)

https://testing-library.top/