乐趣区

关于前端:Jest-React-单元测试最佳实践

咱们是袋鼠云数栈 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 报错

起因:当引入内部库是 es 模块时, jest无奈解决导致报错,能够通过 babel-jest 进行解决,依据官网文档:https://jestjs.io/zh-Hans/docs/26.x/getting-started,还有一种就是批改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 版本未对应

报如下谬误

降级后版本(仅供参考)

3、toBeInTheDocument、toHaveClass 等报错

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

有以下两种计划,都须要批改tsconfig.json

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

// 计划二,增加 types
"types": ["@testing-library/jest-dom"]

参考链接:https://stackoverflow.com/questions/57861187/property-tobeinthedocument-does-not-exist-on-type-matchersany

4、Cannot find namespace ‘NodeJS’

批改 tsconfig.json,往 types 中退出 node

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

5、module ‘tslib’ cannot be found

报错信息如下

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

"importHelpers": true,

编译文件会引入 tslib 能够参考

https://juejin.cn/post/6953554051879403534

https://github.com/microsoft/TypeScript/issues/37991

解决方案如下:
计划一:

"importHelpers": false,

计划二:

pnpm add tslib

并且批改 tsconfig

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

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

例如:

解决方案如下:

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

7、多个单测文件缺失某一个办法,能够采纳如下配置

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

那么首先在 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 报错。

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

在对该组件进行单测时,因为引入了第三方的组件 CodeMirrorEditor,编译时呈现了以下问题,起因是试图导入 jest 无奈解析的文件,而从实际上来说咱们对以后组件的测试其实并不必去编译 dt-react-codemirror-editor。

因而,在 jest.config.js 文件退出编译时须要疏忽的文件。

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

好吧,又失败了进入 index 查看,提醒找不到 style 文件然而文件夹里又是存在的,初步尝试是否因为文件扩展名起, 保留测试通过,然而批改 node_modules 里的文件扩展名无奈从基本解决该问题,依照举荐提醒在测试覆盖文件扩展名 moduleFileExtensions 内退出 css。

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

发现曾经配置了当匹配到 css 文件时映射到一个空对象里,并不会去编译原款式文件,起因是因为退出到了编译笼罩的文件扩展名数组里 moduleFileExtensions,因而无奈采纳举荐办法。

再次回顾问题产生的起因,jest 无奈找到 style 文件然而找到了 style.css 文件,然而 style 文件咱们并不需要进行编译,退出 moduleNameMapper 当找到 style 文件时映射到一个空对象的文件里。

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')
  })
})

参考文献

  • jest 官网文档
  • React Testing Library 官网文档
退出移动版