Jest + React 单元测试最佳实践

4,321 阅读12分钟

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

前言

单元测试是一种用于测试“单元”的软件测试方法,其中“单元”的意思是指软件中各个独立的组件或模块。开发者需要为他们的代码编写测试用例以确保这些代码可以正常使用。

在我们的业务开发中,通常应用的是敏捷开发的模型。在此类模型中,单元测试在大部分情况下是为了确保代码的正常运行以及防止在未来迭代的过程中出现问题。

测试目的

1、排除故障

每个应用的开发中,多少会出现一些意料之外的 bug。通过测试应用程序,可以帮助我们大大减少此类问题,并且增强应用程序的逻辑性。

2、保证团队成员的逻辑统一

如果您是团队的新成员,并且对应用程序还不熟悉,那么一组测试就好像是有经验的开发人员监视你编写代码,确保您处于代码应该执行的正确路线之内。通过这些测试,您可以确信在添加新功能或更改现有代码时不会破坏任何东西。

3、可以提高质量代码

当您在编写 React 组件时,由于考虑到测试,最好的方案将是创建独立的、更可重用的组件。如果您开始为您的组件编写测试,并且您注意到这些组件不容易测试,那么您可能会重构您的组件,最终起到改进它们的效果。

4、起到很好的说明文档作用

测试的另一个作用是,它可以为您的开发团队生成良好的文档。当某人对代码库还不熟悉时,他们可以查看测试以获得指导,这可以提供关于组件应该如何工作的意图的洞察,并为可能要测试的边缘部分提供线索。

规范

工具

在袋鼠云数栈团队,我们建议使用 jest + @testing-library/react 来书写测试用例。后者是为 DOMUI 组件测试的软件工具。

基础语法

  • describe:一个将多个相关的测试组合在一起的块

  • test:将运行测试的方法,别名是it

  • expect:断言,判断一个值是否满足条件,你会使用到expect函数。 但你很少会单独调用expect函数, 因为你通常会结合expect和匹配器函数来断言某个值

  • skip:跳过指定的describe以及test,用法describe.skip/test.skip

  • cleanup:在每一个测试用例结束之后,确保所有的状态能回归到最初状态,比如在 UI 组件测试中,我们建议在 afterEach 中调用 cleanup 函数

    import { cleanup } from '@testing-library/react';
    
    describe('For test', () => {
      afterEach(cleanup);
      test('...', () => {})
    })
    

注意事项

1、函数命名

关于是使用 test 还是使用 it 的争论,我们不做限制。但是建议一个项目里,尽量保持风格一致,如果其余测试用例中均为 test,则建议保持统一。

2、业务代码

我们建议尽量把业务代码的函数的功能单一化,简单化。如果一个函数的功能包含了十几个功能数十个功能,那我们建议对该函数进行拆分,从而更加有利于测试的进行。

3、代码重构

在重构代码之前,请确保该模块的测试用例已经补全,否则重构代码的风险会过于巨大,从而导致无法控制开发成本。

4、覆盖率

我们建议尽量以覆盖率 100% 为目标。当然,在具体的开发过程中会有各种各样的情况,所以很少有能够达到 100% 的情况出现。

5、修复问题

每当我们修复了一个 bug,我们应当评估是否有必要为这个 bug 添加一个测试用例。如果需要的话,则在测试用例中新增一条以确保后续的开发中不会复现该 bug。

评估的参考内容如下:

  • 是否会造成白屏或其他严重的问题
  • 是否会影响用户的交互行为
  • 是否会影响内容的展示

以上内容,满足一条或多条,则认为应当为该 bug 新增测试用例。

6、toBe or toEqual

这两者的区别在于,toBe 是相等,即 ===,而 toEqual 是内容相同,即深度相等。我们建议基础类型用 toBe,复杂类型用 toEqual

我们需要测试什么

包括但不限于以下几种:

  • Component Data:组件静态数据
  • Component Props:组件动态数据
  • User Interaction:用户交互,例如单击
  • LifeCycle Methods:生命周期逻辑
  • Store:组件状态值
  • Route Params:路由参数
  • 输出的dom
  • 外部调用的函数
  • 对子组件的改变

单元测试场景

1、快照测试

如果是一个纯渲染的页面或者组件,我们可以通过快照记录最终效果,下一次快照结果会去对比是否正确。 使用场景:对于一个已知的固定的结果,我们使用快照去记录结果,每次进行测试会将最新结果和记录结果进行对比,如果一致,则代表测试通过,反之,则不然。

通常在测试 UI 组件时,我们会建议进行快照测试,以确保 UI 不会有意外的改变。这里我们建议使用 react-test-renderer 进行快照测试。

yarn add react-test-renderer @types/react-test-renderer -D

安装完成后,建议在 UI 测试的首个测试用例进行快照测试。

import React from 'react';
import renderer from 'react-test-renderer';
import { Toolbar } from '..';

test('Match Snapshot', () => {
  const component = renderer.create(<Toolbar data={toolbarData} />);
  const toolbar = component.toJSON();
  expect(toolbar).toMatchSnapshot();
});

2、dom 结构测试

使用场景:对于当前组件接收到的参数或者数据,会对应渲染出一个固定结构,我们对结构进行解析,看是否与预期相符。比如表格的行数应该与接口返回的 list 长度一致,表格的表头应该固定是我们设定的文案,表格的对应某一格应该是接口返回的对应行和列的值。再比如组件内部根据接收的 props 的变量去判断显示 dom 结构,那我们在单测传入某一个值时,我们的预期应该是显示为什么样的。我们建议使用 @testing-library/jest-dom 做相关的测试

yarn add --dev @testing-library/jest-dom

测试例子如下:

import React from 'react';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

describe('Test Breadcrumb Component', () => {
  test('Should support to render custom title', async () => {
    const { container, getByTitle } = render(
       <MyComponent
         renderTitle={() => "I'm renderTitle";}
        />
     );

    const testDom = await waitFor(() =>
      container.querySelector('[title="test1"]')
    );
    const dom = await waitFor(() =>
      container.querySelector('[title="I\'m renderTitle"]')
    );

    expect(testDom).not.toBeInTheDocument();
    expect(dom).toBeInTheDocument();
  });
});

除了 toBeInTheDocument 外,还有其余接口,参见官方文档。

3、事件测试

使用场景:当组件或者页面上有点击事件,对于点击后发生的一系列动作是我们需要检测的,首先需要用 fireEvent 去模拟事件发生,然后测试事件是否正确触发,比如我的表单操作按钮,对于操作后的动作进行一一检测对应。

const btns = btnBox.getElementsByClassName('ant-btn');
// 取消
fireEvent.click(btns[0]);
await waitFor(() => {
  expect(API.getProductListNew).toHaveBeenCalled();
});

4、function测试

function add(a, b){
  return a+b;
}
it('test add function', () => {
  expect(add(2,2)).toBe(4);
})

5、异步测试

使用场景:当你的预期需要时间等待

  • waitFor:可能会多次运行回调,直到达到超时
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
  • useFakeTimers:指定 Jest 使用假的全局日期、性能、时间和定时器 API,通常需要和runAllTicksrunAllTimers配合。
test('should warn if not saved custom type but clicked custom button', () => {
  const { getByText, baseElement } = wrapper;

  jest.useFakeTimers();
  fireEvent.click(getByText('自定义类型'));
  fireEvent.mouseDown(getByText('自定义类型'));

  expect(getByText('名称不能为空')).toBeInTheDocument();
  jest.runAllTimers();

  const inputEle = baseElement.querySelector('.dt-input');
  fireEvent.change(inputEle, { target: { value: '1' } });
  jest.useFakeTimers();
  fireEvent.click(getByText('自定义类型'));

  expect(getByText('请先保存')).toBeInTheDocument();
  jest.runAllTimers();
});

6、模拟属性和方法的返回结果

使用场景:当访问的某些属性或者方法在当前环境不存在时。

// 已有属性:jest.spyOn,例子如下
jest.spyOn(document.documentElement, 'scrollWidth', 'get').mockImplementation(() => 100);

// 未知属性:Object.defineProperty,例子如下
Object.defineProperty(window, 'getComputedStyle', { value: jest.fn(() => ({ paddingLeft: '0px'})

// 方法的返回结果:jest.mock
function = jest.mock(() => {})

7、Drag

有时候,我们需要去测试拖拽功能,我们建议用以下函数来执行模拟拖拽的操作

import { fireEvent } from '@testing-library/react';

function dragToTargetNode(source: HTMLElement, target: HTMLElement) {
  fireEvent.dragStart(source);
  fireEvent.dragOver(target);
  fireEvent.drop(target);
  fireEvent.dragEnd(source);
}

8、test.only

在出现测试用例无法通过,但是又判断代码的逻辑没有问题之后,将该条测试用例设置为 only 再跑一遍测试用例,以确保不是其他测试用例导致的该测试用例的失败。这类问题经常出现自代码中欠缺深拷贝,导致多条测试用例之中修改了原数据从而使得数据不匹配。

例如:

// mycode.ts
function add(record: Record<string, any>){
  Object.assign(record, { flag: false});
}

// mycode.test.ts
const mockData = {};
test('',() => {
  add(mockData)
  ...
  ...
})

test.only('',() => {
  add(mockData) // the mockData is modified by add function here
  ...
  ...
})

在项目中遇到的一些问题

1、执行 pnpm test 报错

file

原因:当引入外部库是es模块时, jest无法处理导致报错,可以通过 babel-jest 进行处理,根据官方文档:jestjs.io/zh-Hans/doc…,还有一种就是修改jest.config.js 加入preset: 'ts-jest' ,会让部分测试成功但是还是会存在一些问题。

方案一:采用了 babel-jest 进行处理

pnpm add -D babel-jest @babel/core @babel/preset-env

安装完以后在工程的根目录下创建一个babel.config.js

module.exports = {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};

修改jest.config.js,增加transform

transform: {
  "^.+\\.js$": "babel-jest",
  "^.+\\.(ts|tsx)$": "ts-jest",
},

方案二:仍然采用 ts-jest ,把引起报错文件的后缀,如 js 改为 ts 即可

2、ts-jest和jest版本未对应

报如下错误

file

升级后版本(仅供参考)

file

3、toBeInTheDocument、toHaveClass等报错

file

file

类型检查错误,应该是@testing-library/jest-dom类型没被引入导致的

有以下两种方案,都需要修改tsconfig.json

// 方案一,删除typeRoots
"typeRoots": ["node", "node_modules/@types", "./typings"]

// 方案二,添加types
"types": ["@testing-library/jest-dom"]

参考链接:stackoverflow.com/questions/5…

4、Cannot find namespace 'NodeJS’

file

修改 tsconfig.json ,往 types 中加入 node

"types": ["node", "@testing-library/jest-dom"]

5、module 'tslib' cannot be found

报错信息如下

file

原因是在 tsconfig.json 中开启了如下配置

"importHelpers": true,

编译文件会引入tslib可以参考

juejin.cn/post/695355…

github.com/microsoft/T…

解决方案如下: 方案一:

"importHelpers": false,

方案二:

pnpm add tslib

并且修改 tsconfig

"paths": {
  "tslib" : ["./node_modules/tslib/tslib.d.ts"] //在paths下添加tslib路径
}

6、由于单测的运行环境问题,当遇到某些方法没有的时候尝试mock下

例如:

file

解决方案如下:

(global as any).document.createRange = () => ({
  selectNodeContents: jest.fn(),
  getBoundingClientRect: jest.fn(() => ({
    width: 500,
  })),
});

7、多个单测文件缺失某一个方法,可以采用如下配置

例如:多个单测文件有如下报错:

file

那么首先在 jest.comfig.js 中添加配置

module.exports = {
  setupFilesAfterEnv: ['./setupTests.ts'],
  // ...
}

然后在 setupTests.ts 文件中:

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

8、The error below may be caused by using the wrong test environment;Consider using the "jsdom" test environment

依赖版本:

"ts-jest": "^28.0.8",
"jest": "^28.1.2",

解决方法: 在 jest.config.js 中添加配置

module.exports = {
  verbose: true,
  testEnvironment: 'jsdom',
  // ...
}

并安装 jest-environment-jsdom (注意: 仅 jest 28 及更高版本需要安装此依赖项)

{
  "devDependencies": {
    "jest-environment-jsdom": "^28.1.2",
  }
}

9、Echarts 单元测试 canvas 报错

在写 Echarts 单元测试的时候,会有 canvas 报错。原因很明显,Echarts 依赖了 canvas。

解决办法:使用 jest-canvas-mock,参考:Error: Not implemented: HTMLCanvasElement.prototype.getContext

注意:直接引入 canvas 虽然可以解决单元测试的报错,但是会导致安装依赖会有偶发性 canvas 报错。

file

10、引入了第三方的组件CodeMirrorEditor写单测报错

在对该组件进行单测时,由于引入了第三方的组件 CodeMirrorEditor ,编译时出现了以下问题,原因是试图导入 jest 无法解析的文件,而从实际上来说我们对当前组件的测试其实并不用去编译 dt-react-codemirror-editor。

file

file

因此,在 jest.config.js 文件加入编译时需要忽略的文件。

file

再次运行测试,然而。。。。。。

file

好吧,又失败了进入 index 查看,提示找不到 style 文件但是文件夹里又是存在的,初步尝试是否由于文件扩展名起,保存测试通过,但是修改 node_modules 里的文件扩展名无法从根本解决该问题,按照推荐提示在测试覆盖文件扩展名 moduleFileExtensions 内加入 css。

file

再次尝试,然而。。。。。。jest 去编译了 style.css 文件,然后它无法解析失败了,查看配置。

file

发现已经配置了当匹配到 css 文件时映射到一个空对象里,并不会去编译原样式文件,原因是由于加入到了编译覆盖的文件扩展名数组里 moduleFileExtensions,因此无法采用推荐方法。

file

再次回顾问题产生的原因,jest 无法找到 style 文件但是找到了 style.css 文件,但是 style 文件我们并不需要进行编译,加入 moduleNameMapper 当找到 style 文件时映射到一个空对象的文件里。

file

11、Route && Link

在测试面包屑组件BreadCrumb时,因为面包屑组件中只用了 Link 标签,最终会被转成 a 标签,用来路由导航。如下写法是将 Link 和 route 放在一个组件之中。然后报错:Invariant Violation: <Link>s rendered outside of a router context cannot navigate

import React from 'react'
import BreadCrumb from '../index';
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect';
import { Router, Switch, Route } from 'react-router-dom';
import { createMemoryHistory } from 'history'
const testProps = {
  breadcrumbNameMap: [
    {
       name: 'home',
       path: '/home'
    },
    {
       name: 'home/about',
       path: '/home/about'
    }
  ],
  style: {
    backgroundColor: '#dedede'
  }
}
const Home = () => <h1>home</h1>
const About = () => <h1>about</h1>
const App = () => {
  const history = createMemoryHistory();
  return (
     <>
       <Router history={history}>
         {< BreadCrumb {...testProps} />}
          <Switch>
             <Route exact path="/main" component={Home} />
             <Route path="/main/home" component={About} />
          </Switch>
        </Router>
      </>
    )
}
describe('test breadcrumb', () => {
  test('should navigate to home when click ', () => {
    const { container, getByTestId } = render(<App />);
    expect(container.innerHTML).toMatch('about')
    fireEvent.click(getByTestId('/home-link'))
      expect(container.innerHTML).toMatch('home')
  })
})

主要原因是版本原因:3.0版本路由不支持这种写法。3.0是将react-routerreact-router-dom分开的;而4.0路由将其合并成了一个包,在具体使用时应该基于不同的平台要使用不同的绑定库。例如在浏览器中使用 react router,就安装 react-router-dom 库;在 React Native 中使用 React router 就应该安装 react-router-native 库,但是我们不会安装 react-router了。项目中用的是3.0版本路由,于是改为3.0写法,将linkrouter分开写在两个组件中,通过测试

const testProps = {
  breadcrumbNameMap: [
    {
      name: 'home',
      path: '/home'
    },
    {
      name: 'about',
      path: '/about'
    }
  ],
  style: {
    backgroundColor: '#dedede'
  }
}
const App = (props) => {
  return (
    <div>
      {<BreadCrumb {...testProps} />}
      {props.children}
    </div>
  )
}
const About = () => <h1>about page</h1>
const Home = () => <h1>home</h1>

describe('test breadcrumb', () => {
  afterEach(() => {
    cleanup();
  })
  test('should navigate to home router when click ', () => {
    const history = createMemoryHistory()
    const { container, getByTestId } = render(
    <Router history={history}>
      <Route path="/" component={App}>
        <IndexRoute component={About} />
        <Route path="/about" component={About} />
        <Route path="/home" component={Home} />
      </Route>
    </Router>
  );
    expect(container.innerHTML).toMatch('about')
    fireEvent.click(getByTestId('/home-link'))
    expect(container.innerHTML).toMatch('home')
  })
})

参考文献

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情