(三)React单测的工程落地

108 阅读1分钟

jest 纯函数单测流程

根据前面的介绍,如果你想开始写一个单测,简单的方案是先尝试不涉及dom的纯函数的测试。

下面以一个utils/tools文件为例,为这个文件写一个test文件。

import { isEmpty, get } from 'lodash'
import { intl } from '@ali/xconsole'
import { Message } from '@ali/xconsole/ui'
import { MY_CONSTANT } from '~/constants'

export * from '@ali/my/tools'

// 不能是数字开头的校验
export const isNotStartWithNumber = (text) => {
  const reg = /^[a-zA-Z_][a-zA-Z0-9_]*$/
  return reg.test(text)
}

// Message位置在中央
const showMessage = (type = 'success', content, otherParams) => {}
export const showSuccessMessage = (content, otherParams) => showMessage('success', content, otherParams)
export const showWarningMessage = (content, otherParams) => showMessage('warning', content, otherParams)
export const showErrorMessage = (content, otherParams) => showMessage('error', content, otherParams)

tools文件很简单,import了一些三方库以及export *了'@ali/my/tools'整个库,然后定义了几个方法。

下面我们来看怎么针对tools文件写测试。

先看一下需要关注的文件:

├── src
│   └── utils
│       └── tools
│           └── index.js    # 这里是tools的文件内容
├── tests
│   └── utils
│       └── tools.test.js   # 命名一定要为xxx.test.js,此时xxx就是jest找到测试文件的关键
└── coverage
│       lcov-report            
└──     └── index.html         # 从浏览器打开可以可视化查看测试覆盖率

其实你的tests文件夹内部的具体文件路径不会决定什么,毕竟最终还是要测试从测试文件中import内容,但是和src保持一致更加方便查看,所以尽量使src树和tests树一致。

建好了测试文件我们来编写第一个测试用例吧,上面这个isNotStartWithNumber函数逻辑较为简单,我们先从它开始。

下文给出了isNotStartWithNumber函数的一个单测示例。

describe和test都是jest中最基础的语法,上文已经对此有所解释。

expect().toBe()是jest的一种断言,语义化理解这行代码,就是期望expect中的内容能够与toBe后的内容一致,toBe就是expect的一种match匹配方式,你也可以通过其他的match来进行更加丰富的断言,其他的断言匹配方式可以参考官方文档 jest expect

import { isNotStartWithNumber } from '~/utils/tools'

// describe内就是一个单元测试
describe('isNotStartWithNumber函数测试', () => {
  // 随便写两个测试用例
  test('以数字开头返回false', () => {
    expect(isNotStartWithNumber('12323afdf')).toBe(false)
  })

  test('合法字符串返回true', () => {
    expect(isNotStartWithNumber('adsfdf341_')).toBe(true)
  })
})

运行一下npm run test,发现报错了:

虽然报的错时es语法'import'的错,但是可以发现,它对tools文件中的import并没有报错,而是对引用的@ali/xconsole包中的'import'报错。这是因为虽然我们配置了babel,但是node_modules是被babel忽略的,并不会转译node_modules中的内容。

不过它没有对'lodash'报错,为啥?因为绝大多数公共包都默认转义为es3/es5编译发布,@ali相关的包可能没有做过相关处理。

如果需要babel处理node_modules的代码需要使用babel.config.json来配置babel而不是.babelrc。参考:zhuanlan.zhihu.com/p/367724302

原因和解决方案都找到了,但是!单元测试很重要的一点思想是,不要去测试外部的功能。这里所有引入的包都是外部功能,他们的功能应该是默认有效的,我们根本不需要对它们进行任何测试。

除了lodash这种强依赖的工具包,由于它适配性比较强,也不存在es6语法的问题,可以保留引用,其他的外部包需要尽量全部避免测试。所以我们也不需要通过babelrc更改为babel.js来避免这个错误了,而是通过跳过外部func的方式来避免这个报错。

那怎么跳过这些外部功能呢?需要使用jest的mock功能:

关于不同情况下mock的写法,以后再介绍。

// 前面的配置
import { isNotStartWithNumber } from '~/utils/tools'

// 不论intl()的入参是什么,只要返回了'mock'我们都认为已经成功
jest.mock('@ali/xconsole', () => ({
  // jest.fn()是jest的函数模拟方式,它可以通过mock返回值来进行测试的合理简化
  intl: jest.fn().mockReturnValue('mock'),
}));
// 返回的只要是个dom就认为成功
jest.mock('@ali/xconsole/ui', () => ({
  Message: () => <div/>,
}))
// 直接忽略@ali/my/tools的内容,因为tools文件中并没有用到
jest.mock('@ali/my/tools', () => ({}))

// 在原本的函数中引用了Message.show这个方法,就要对它mock补全
Message.show = jest.fn(() => {})

// 一个单元测试
describe('isNotStartWithNumber测试', () => {
  // 随便写两个测试用例
  test('以数字开头返回false', () => {
    // expect().toBe()是expect断言的一种match匹配方式
    expect(isNotStartWithNumber('12323afdf')).toBe(false)
  })

  test('合法字符串返回true', () => {
    expect(isNotStartWithNumber('adsfdf341_')).toBe(true)
  })
})

上文中,jest.fn是jest的函数模拟方式,它可以通过mock特定的返回值来进行测试的合理简化,这里的mockReturnValue顾名思义就是将int这个函数的返回值指定为'mock',当然,你也可以进行更加复杂的mock,具体的返回值mock api可以参考官方文档 jest fn

对jest来说,mock的声明内容都会优先于原本的内容生效。所以这里相当于把三个引用的包都进行了mock函数的的替代。

mock完之后,运行npm run test发现测试成功了

运行一下,测试成功,但是打开覆盖率分析的文件,你会发现:

我们只测了if的情况,else的情况并没有被覆盖。

需要添加一下能够进入else的单测用例,这样就没有标红的了。

将每一个函数的测试都补全后,观察你的测试覆盖率分析:

指标说明
%stmts(statement coverage)语句覆盖率:是不是每个语句都执行了?
%Branch(branch coverage)分支覆盖率:是不是每个if代码块都执行了?
%Funcs(function coverage)函数覆盖率:是不是每个函数都调用了?
%Lines(line coverage)行覆盖率:是不是每一行都执行了?

添加你的测试用例,尽量让每一项都能够达到100%。

除此之外,将你平常对函数边界条件的思考放到测试用例中,这样你的测试就比较健壮了。

jest的使用还有许多技巧,可以多摸索,也可以找本人探讨Reactwe。重点是:不要测试外部功能,除非这个外部功能你无法保证是否正确,能mock的都mock掉。

react 自定义hook测试

啥是react/testing-library

目的:补充jest功能,方便进行dom、react state等等相关的非单纯js函数的测试

官网:testing-library.com/

react-testing-library: testing-library.com/docs/react-…

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

可以参考官方的一些examples:testing-library.com/docs/exampl…

安装

tnpm i --save-dev react-test-renderer
tnpm i --save-dev @testing-library/react
tnpm i --save-dev @testing-library/react-hooks

注意:一些版本需要对齐

  1. @testing-library/react版本13+仅支持ract18+
  2. react-test-renderer要和react版本一致

tips:react的版本目前应用内可能会因为多层引用导致node_modules安装多个版本,从import点击跳转和npm info react获取到的都是最新版而非真正使用的版本。我自己是没法现非常好的方法去找这个版本的,不过我会通过手动触发一个报错来看看报错的react是什么版本。(没什么问题的话应该都是16,所以@testing-library/react要安装12版本,react-test-renderer要安装16版本

hooks测试

testing-library还是要基于jest来做,它相当于提供一些扩展包,方便我们测试异步、react hook、dom。

测试hooks,主要依靠@testing-library/react-hooks这个库。

因为我们自定义的hook肯定也都是在官方hook基础上进行进一步的封装,所以本质还是在测试一个我们针对官方hook的引用逻辑是否正确,只是这个函数由于涉及hook,只能在组件中运行。

所以此时,我们需要引入@testing-library/react-hooks来模拟将hook运行在组件中的结果。

参考文章:「React 深入」一文玩转React Hooks的单元测试 - 掘金

因为不同hook的差异较大,很难抽象出大家都有的共性,本章直接以现有的自定义hook为例进行讲解

涉及useState

比如这个hook:

import { useState } from 'react'

export const useChangeValue () => {
  const [value, setValue] = useState(0)
  const ChangeValue = (v) => {
    setValue(v+1)
  }
  return {
    value
    changeValue,
  }
}

它仅仅涉及了state的使用,我们只需要测试一下它透传出的changeValue函数能否正确改变state:

import useChangeValue from '~/hooks/usePagination'
import { renderHook, act } from '@testing-library/react-hooks'

describe('usePagination', () => {
  test('初始变量配置', () => {
    const { result } = renderHook(() => useChangeValue())
    expect(result.current.value).toEqual(0)
  })

  test('changeValue test', () => {
    const { result } = renderHook(() => useChangeValue())
    act(() => {
      result.current.changeValue(5)
    })
    expect(result.current.value).toEqual(6)
    act(() => {
      result.current.changeValue(10)
    })
    expect(result.current.value).toEqual(11)
  })
})

引入act就是进行了一个异步操作,实际上这里的作用就等于:

  test('changeValue test', async () => {
    const { result } = renderHook(() => useChangeValue())
  	await result.current.useChangeValue(5)
    expect(result.current.value).toEqual(6)
  	await result.current.useChangeValue(10)
    expect(result.current.value).toEqual(11)
  })

涉及useContext

比如useAppProps

import { useContext } from 'react'

import { AlfaMicroAppContext } from '@ali/xconsole/alfa'

export default () => {
  const {
    appProps,
  } = useContext(AlfaMicroAppContext)

  return appProps
}

这个hook就是为了通过AlfaMicroAppContext获取该Context.Provider传递的value

这样测:

import useAppProps from '~/hooks/useAppProps'
import { AlfaMicroAppContext } from '@ali/xconsole/alfa'
import React from 'react'
import { renderHook } from '@testing-library/react-hooks'

jest.mock('@ali/xconsole/alfa', () => {
  const React = require('react')
  return {
    AlfaMicroAppContext: React.createContext(),
  }
})

describe('useAppProps', () => {
  const renderValue = {
    appProps: {
      mockProp: 'mock',
    },
  }
  test('测试useAppProps结果是否正确', () => {
    const { result } = renderHook(() => useAppProps(), {
      wrapper: ({ children }) => (
        <AlfaMicroAppContext.Provider value={renderValue}>
          {children}
        </AlfaMicroAppContext.Provider>
      ),
    })
    expect(result.current).toEqual({
      mockProp: 'mock',
    })
  })
})

这里用到了renderHook的第二个参数option,api文档:testing-library.com/docs/react-…

wapper这参数,官方解释是:

Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating reusable custom render functions for common data providers. 

使renderHook的测试对象运行在wapper提供的环境中。

涉及useEffect

正常来说,render的时候会自动运行useEffect的内容。

如果你的useEffect里还有return的卸载操作,可以额外进行一次unmount来测试

const hook = renderHook(() => useMyHook)
expect...
hook.unmount()
expect...

涉及window

由于testing-library的renderHook本身是依赖window和document的,所以你无法直接将整个window或document进行mock。

如果你的hook内部存在这样的判断:

export const myHook = () => {
  ...
  if(window && window.document){
    ...
  }
  ...
}

无法在使用renderHook获取hook结果的同时直接mock window不存在的情况。

我的建议是:

StackOverFlow上找到的解决方法:stackoverflow.com/questions/7…,都不好使

  1. 绕过检测
  1. 可以接收针对这个测试文件设置单独的覆盖率threshhold;
  2. 或者,直接通过注释来跳过这里的覆盖率检测(推荐):
import { useEffect } from 'react'

export const myHook = () => {
  useEffect(() => {
    /* istanbul ignore else */
    if (window && window.document) 
      ...
    }
  }, [])
  return myHook
}
  1. 如果if的结果会强影响后续的代码,解决方案
  1. 改造hook,增加一个window判断函数,将原函数中的if条件改为直接引入函数,以方便直接mock if条件,剥离window存在和hook依赖的死循环(尝试后失败)
  2. 改造hook,将window作为入参传递进去,这样就可以模拟hook内没有window的情况了(测试确实可以成功,但是对于我们来说完全没有必要进行这种源代码改造)

总之,如果不得不进行这类测试,目前来看建议剥离window和hook的强关联,保证window从hook外部传入。

综合考虑这里也可以不测。

testing-library包讲解

补充说明一些官方文档没讲清楚的问题

react-hooks/testing-library

包含这几个方法:

renderHook, act, cleanup, addCleanup, removeCleanup, suppressErrorOutput

renderHook

renderHook(callback, options?)

renderHook这个函数顾名思义就是用来渲染hook的,它会在调用的时候渲染一个专门用来测试的TestComponent来使用我们的hook。renderHook的函数签名是renderHook(callback, options?),它的第一个参数是一个callback函数,这个函数会在TestComponent每次被重新渲染的时候调用,因此我们可以在这个函数里面调用我们想要测试的hook。renderHook的第二个参数是一个可选的options,这个options可以带两个属性,一个是initialProps,它是TestComponent的初始props参数,并且会被传递给callback函数用来调用hook。options的另外一个属性是wrapper,它用来指定TestComponent的父级组件(Wrapper Component),这个组件可以是一些ContextProvider等用来为TestComponent的hook提供测试数据的东西。

入参:

  1. callback

一般来说直接写为() => useCustomHook()即可。

  1. object
  1. initialProps,虚拟测试组件的initialProps
  2. wrapper,自定义一个hook运行的component环境

返回值:

result:result是一个对象,它包含两个属性,一个是current,它保存的是renderHookcallback的返回值,另外一个属性是error,它用来存储hook在render过程中出现的任何错误。

rerender: rerender函数是用来重新渲染TestComponent的,它可以接收一个newProps作为参数,这个参数会作为组件重新渲染时的props值,同样renderHook的callback函数也会使用这个新的props来重新调用。

unmount: unmount函数是用来卸载TestComponent的,它主要用来覆盖一些useEffect cleanup函数的场景。

act

这函数和React自带的test-utils的act函数是同一个函数,我们知道组件状态更新的时候(setState),组件需要被重新渲染,而这个重渲染是需要React进行调度的,因此是个异步的过程,我们可以通过使用act函数将所有会更新到组件状态的操作封装在它的callback里面来保证act函数执行完之后我们定义的组件已经完成了重新渲染。

其他对jest的扩展

jest-wrap

github.com/airbnb/jest…

一种jest的扩展插件,辅助构造每一个测试用例的内部环境

例子

比如,如果我想指定某个测试用例中,window不存在:

import { renderHook } from '@testing-library/react-hooks'
import wrap from 'jest-wrap'

wrap().withGlobal('document', () => undefined).test('window不存在的情况', () => {
  xxx
})

安装

tnpm i jest-wrap

babel-plugin-rewire

github.com/speedskater…

这个包,可以把引入的module内部的所有内容都进行覆盖、重写

例子

在这种情况下有用:

export const a = () => { return true }
export const b = () => (a() ? '1' : '2')

b方法引入了内部的a方法,jest.mock只能针对外部的import进行mock,对内部的变量/方法(相当于private)没有办法(官方答复:github.com/jestjs/jest…

如果你真的非常需要mock掉一个内部的方法的话,使用这个插件进行module内部重写:

import { b, __RewireAPI__ as aApi } from 'Utils/a'

describe('test b', () => {
  test('a is true', () => {
    const aMock = jest.fn().mockReturnValue(true)
    aApi.__Rewire__('a', aMock)
    expect(b()).toBe('1')
  })

  test('a is false', () => {
    const aMock = jest.fn().mockReturnValue(false)
    aApi.__Rewire__('a', aMock)
    expect(b()).toBe('2')
  })
})

安装

tnpm i babel-plugin-rewire

然后再babelrc中添加plugin

{
  "plugins": [
    "babel-plugin-rewire",
  ]
}

工程完善

往往在工程中,我们需要设置一些eslint、git拦截等,检测单测文件是否合格。

自动补全

为了和开发代码有一样的书写体验/风格统一,可以增加eslint和代码自动补全的功能。

配置

# 自动补全
tnpm i -D @types/jest
# 针对jest的eslint插件
tnpm i -D eslint-plugin-jest

自动补全安装后直接生效。

eslint配置

  1. 找到.eslintignore文件,删除tests这一行。
  2. package.json中,修改lint命令
scripts: {
  "lint": "eslint src/ tests/",
}
  1. 在.eslintrc文件中增加overrides配置,指定在tests文件夹中进行一些特定的eslint检查。
module.exports = {
  overrides: [
    {
      files: ['tests/**/*.js'],
      env: {
        'jest/globals': true,
      },
      plugins: [
        'jest',
      ],
      rules: {
        // jest测试文件允许重复导入,因为mock内部无法获取外部import
        'no-shadow': 'off',
      },
    },
  ],
};

husky拦截

  1. 想要进行git拦截,首先要有拦截指标,在jest.config.js中进行如下配置:
// 增加jest对覆盖率的最低要求,不满足这个覆盖率的话jest测试无法通过
coverageThreshold: {
  global: {
    branches: 100,
    functions: 100,
    lines: 100,
    statements: 0,
  },
  // // 这里特殊情况下如果真的无法达到100%覆盖率,可以设置单个文件的threshold,但最好不要设置
  // // 如果全局或路径与全局一起指定,则匹配路径的覆盖率数据将从总体覆盖率中减去,并且将独立应用阈值。通配符模式设置的阈值将应用到所匹配的所有文件上并单独计算。 如果找不到路径指定的文件,则返回错误。
  // './src/utils/tools': {
  //   branches: 90,
  //   functions: 90,
  //   lines: 90,
  //   statements: 10,
  // },
},

// 检测覆盖率时会检测整体,而非测试文件中的引用文件,以数组形式将所有需要被测试的文件放进来
collectCoverageFrom: [
  // 目前我们只测utils,如果增加了测试范围,可以在此添加
  'src/utils/**/*.js',
  // 有特殊情况而无需被测的文件可以通过!排除
  '!src/utils/fake/**/*.js',
],

ps:如果某个方法无法通过补充测试用例达到100%branch覆盖率,说明这个方法的分支写的大概率是有问题的,需要修改。尽量不要通过修改覆盖率threshold来逃避检测。

  1. 修改package.json的script
script: {
  "test": "jest",
}
  1. 在husky文件夹中添加脚本文件
#!/bin/bash
 
# utils文件有变动时执行jest检测
changedFiles=$(git diff --name-only --cached --diff-filter=d src/utils | grep -v 'src/utils/fake/')
 
if [[ -n "$changedFiles" ]]; then
  echo "Changes detected in src/utils. Running test command..."
  npm run test
  # 当覆盖率不通过时或单测不通过时,退出
  if [ $? -eq 0 ]
  then
    echo "jest检测通过"
  else
    echo "jest检测未通过,请检查覆盖率或测试正确率"
    exit 1
  fi
else
  echo "No changes in src/utils."
fi
  1. 修改./husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run lint
sh .husky/jest-check.sh