React Native项目中的Unit Test配置

671 阅读9分钟

UT依赖

理论上说,当通过react-native init创建出一个RN项目后,Unit Test是开箱即用的。但是这也就是理论上的,因为此时默认的情况下,我只能做一个最简单的match snapshot,但凡多做一点,各种错误纷至沓来,所谓的UT是根本就跑不起来的。

不过在开始配置UT环境前,我们还是要检查一下UT依赖的那些包是否存在。以下是我的项目中,package.json里UT相关的包的依赖情况,请参考:

  "dependencies": {
    "react-native": "0.70.2"
  },
  "devDependencies": {
    "@testing-library/jest-native": "5.1.2",
    "@testing-library/react-hooks": "8.0.1",
    "@testing-library/react-native": "11.4.0",
    "babel-jest": "29.2.2",
    "eslint-plugin-jest": "27.1.4",
    "jest": "29.2.2",
    "jest-environment-jsdom": "29.2.2",
    "react-test-renderer": "18.2.0"
  }

Jest配置

RN的默认项目中,Jest的配置是放到了package.json里的,我们需要把它挪出来到一个独立的jest.config.js的文件中。在RN项目的根目录下,执行:

jest init

会有一些问题让你选择,其实怎么选都无所谓,全部默认就好。因为随后所有的选项都会在jest.config.js里罗列出来,你随时随地就可以很容易的修改它们。

当这条命令执行完后,package.json里的jest配置就没了,取而代之的是项目根目录下多出来一个jest.config.js的文件。

打开这个文件,里面绝大多数的内容都是注释掉的,只有刚才执行init时的选择会出现在这里。我们挑其中重要的配置,逐一说明:

moduleFileExtensions

moduleFileExtensions: [
    'js',
    'mjs',
    'cjs',
    'jsx',
    'ts',
    'tsx',
    'json',
    'node'
  ],

根据项目的具体情况,只保留使用的文件后缀即可,如果项目是TS的,那么需要把tstsx放到js前面,以便提高jest的效率。

preset: 'react-native'

RN项目中此处必须要写成这样,不然UT根本跑不起来。这个也是默认就生成好的,理论上是不需要你自己来改的。

setupFilesAfterEnv

这个选项,很容易被忽略,但是它非常好用。简单来说,就是每次你执行一个test case前,都需要引用的包(可以是第三方的,也可以是自己写的)就可以放到这里。比如:

// module1.test.js
import 'some-basic-setup'

describe('<Module1 />', () => {
... ...
})

// module2.test.js
import 'some-basic-setup'

describe('<Module2 />', () => {
... ...
})

上面的代码片段中,在执行test case前,都依赖了some-basic-setup这个包,当你项目中,成千上万个测试文件都引用同样的包的情况下,你就可以把这个共同的包放到setupFilesAfterEnv中,而不用每个文件都写一遍。

以我的项目为例,我的配置如下:

  setupFilesAfterEnv: [
    '@testing-library/jest-native/extend-expect
  ],

这个第三方的包,集成了很多RN测试用的API,基本上只要是写RN的测试用例,尤其是涉及到UI部分的,就无法不使用这个包,所以我把它放到setupFilesAfterEnv中。

testEnvironment: 'node'

我个人认为,RN项目此选项就选node就好,jsdom模拟了一个Dom和HTML的实现环境,对于Web测试我认为是有意义的,但是RN测试,我觉得没有什么必要(个人见解,不一定对,未来如有新的认知,再补充),而且node下的运行速度,是要比jsdom快的。

testMatch

此参数很好理解,就是jest运行哪些文件。一般来说,就是你项目中的,针对源代码的测试文件,才会应该被jest运行。我的配置(也是RN项目生成时的默认配置)如下:

testMatch: [
    '**/src/**/*.(spec|test).js'
  ],

testPathIgnorePatterns

此参数是配合testMatch一起使用的,让jest更加精准的定位,哪些测试文件是真正需要被运行的,排除掉一些没有必要的文件,以便提高jest的搜索效率。我的配置:

  testPathIgnorePatterns: [
    '<rootDir>/node_modules/',
    '<rootDir>/temp/',
  ],

transform

非常的参数,没有它在,UT就不要想了,肯定跑不起来。不过也是一个RN项目生成时就自动给你设置好的。不过最初是放到package.json里的,当你通过jest init生成jest.config.js后,要检查它是否被注释掉了,如果没有了,要记得自己重新加上。

  transform: {
    '^.+(js)$': 'babel-jest',
  },

这个参数的意义,就是在jest运行的时候,让babel转码,现代的前端代码,几乎不存在直接用原生js写,所以想让jest能运行js,也得靠babel来transform这些代码,就和RN中用metro,原生React用webapck一样,本质上,js代码编译这块,都是用的babel。只不过社区大神们,已经针对jest,单独做出来给它使用的babel库,就是这个babel-jest。如果自己配置一套原生的babel或者其他的babel解决方案也是可以的,此处不一定非得用babel-jest。

transformIgnorePatterns

又是一个容易被忽略且绕人的配置,其实它是和transform配对使用的。babel-jest不光是针对项目中的源代码,而且也会transform在node_modules里的第三方包,而很多第三方包都是没必要被transform的,我们其实指期待能transform哪些我们在test case里用到的包。这个选项,就是告诉jest,transform哪些包。但是这个选项本身,又是ignore的,是一种否定的表达。意思是说,匹配它的那些patterns是要忽略的,不匹配的那些才会真正transform。一句话,否的的否定是肯定。所以,patterns里的正则表达式,需要用到否定匹配的?!

请参考我的配置:

  transformIgnorePatterns: [
    'node_modules/(?!react-native|@react-native/.*|@rneui/.*)',
  ],

我的test case中,目前用到了三个包:

  • react-native
  • @react-native/polyfills
  • @rneui/themed

所以,我就在patterns里,通过否定匹配?!来指定这三个包,告诉transformIgnorePatterns,当遇到这三个包的时候,不要忽略,其余的node_modules下的包,都忽略吧。

verbose: true

很容易被忽略的选项,但是我认为,开发环境下,这个必须选true,尽可能多的在出错时,拿到更多的信息,有助于定位问题。

Jest Mock

Jest的运行环境,仅仅是一个模拟环境,并不是真实的App环境,更不要说那些需要异步的API请求和DB请求了,所以各种和App相关的环境信息,API信息,DB信息,Jest都是拿不到的,如果想让代码顺利跑通,就必须针对这些拿不到的信息,做一个Mock处理。说白了,就是用假数据,让Jest只关注逻辑处理和UI显示本身,对于外界的依赖,应该由外界保证,而这些都不是项目本身应该关注的。比如外界DB坏了,那么我们项目本身的test case写出花来也没用,上线肯定不好用。DB本身是DBA或者数据服务商负责的,根本就不是项目测试的一部分。针对这一类的数据,使用Mock是最恰当的。

Mock的定义规则要求在某个需要Mock的模块所在的位置的同一层级下,新建一个__mocks__的文件夹,在这个文件夹里,定义一套和真正模块一模一样的接口。

请参考如下代码和配置:

// device-info.js
import { NativeModules } from 'react-native'

export const AppleLocale = NativeModules.SettingsManager.settings.AppleLocale

// lang-module.js
... ...
import locale from './device-info'
... ...
return function LangModule(){
  ... ...
  const [currentLocale, setCurrentLocale] = useState(locale)
  ... ...
  return <View>
  ... ...
  </View>
}


// lang-module.test.js
import LangModule from './lang-module'
... ...

上面的例子中,lang-module.test.js在运行时,一定会报错,因为Jest环境下,是不可能拿到IOS的native环境折哦功能才有的变量SettingsManager,所以这个test case是无法顺利跑通的。但是我们要测试的内容,并不是SettingsManager本身,它只是作为一个输入参数而存在,而这个输入参数,是IOS系统提供的,是不可能出错的,不需要我们来测试。我们只需要利用Jest的Mock机制,给一个假的参数,让Jest以为自己拿到了这个参数,从而顺利执行test case就好了。

我们要做如下改动:

  1. device-info.js的同一层次建文件夹__mocks__,在其中创建文件device-info.js
/ -
  __mocks__
      -
      device-info.js
  lang-module.js
  lang-module.test.js
  device-info.js
  1. __mocks__/lang-module.js中export一个同样的接口
//__mocks__/device-info.js
export const NativeModules = {
  SettingsManager: {
    settings: {
      AppleLocale: 'zh-cn'
    }
  }  
}
  1. 在lang-module.test.js中,告诉jest,使用device-info的mock数据
// lang-module.test.js

// jest会自动从'./device-info'的位置找对应的__mocks__/device-info
jest.mock('./device-info')

describe('<LangModule />', () => {
...
})

两个RN项目的UT库

上面说的Jest配置,指的是UT中的框架,和Jest平级的框架还有其他的,比如Karma和Jasmine。而这里要说的,是基于Jest框架下,使用的UT的测试库,这些库提供了一些API,让使用者可以方便的定位元素和校验元素。

贴一个我项目中的UI的代码片段,然后引出这两个库:

import * as React from 'react'
import renderer from 'react-test-renderer'
import { render, cleanup } from '@testing-library/react-native'
import Icon from './Icon'
import BackButton from './BackButton'

jest.mock('./device-info')

describe('<BackButton />', () => {
  beforeEach(() => {
  })

  afterEach(() => cleanup())

  it('matches snapshot', () => {
    const onPress = () => {}
    const tree = renderer.create(<BackButton onPress={onPress} />).toJSON()
    expect(tree).toMatchSnapshot()
  })

  it('renders correctly', () => {
    const onPress = () => {}
    const elem = render(<BackButton onPress={onPress} />)
    expect(elem).toBeDefined()
    const view = elem.getByRole('button')
    expect(view).toBeDefined()
    const icon = elem.UNSAFE_getByType(Icon)
    expect(icon).toBeDefined()
    expect(icon).toHaveProp('color', primaryTextColor)
  })
})

上面的代码中,使用了2个库:

  • react-test-renderer
  • @testing-library/react-native

前者是React官方提供的测试库,方法比较少,我一般用它来所snapshot match,后者是大名鼎鼎的@testing-library的react-native版,提供了很多用来定位和校验元素的API。比如我代码中用到的getByRole、toBeDefined等。

测试框架和测试库相辅相成,前者负责整个测试系统的搭建,包括前期环境准备和后期测试覆盖率的收集,而后者负责具体的test case怎么写,通过API来模拟人手操作元素和人眼看操作结果,从而达到自动测试的目的。

UT参考文档

我们要知道,Jest和@testing-library都不仅仅是针对RN设计的框架和测试库,而是通过的解决方案,所以如果我们从这两个官网上找适合RN的API的时候,往往会发现,费了很多劲,找到的API不能用,因为你找到的API,十有八九是针对Web的定位API或者是和UI无关的API,而不是针对RN的UI元素的API。这里给出了一些参考网站和资源,让你精准定位RN所需的测试资源:

RN元素的定位API

Jest针对RN元素的校验API

Jest针对非UI的校验API

Jest针对测试环境的配置API

常见的Jest的报错

SyntaxError: Cannot use import statement outside a module

没有正确配置transformtransformIgnorePatterns,以至于Jest无法识别module。transformIgnorePatterns的配置中,要把用到的第三方库都配置上,可以通过错误信息,一个一个配置,直到不再出现这个错误为止。

SyntaxError: Invalid or unexpected token

同上

ReferenceError: __DEV__ is not defined

配置globals,具体如下:

  globals: {
    __DEV__: true
  },

TypeError: Cannot read properties of undefined (reading 'xxxxxx')

如果代码没有问题,那么通常是因为Jest环境下,无法拿到正常RN运行时拿到的数据(比如App Native信息,第三方API或者DB信息),此时需要配置__mocks__来克服它。

TypeError: expect(...).toHaveTextContent is not a function

toHaveTextContent只是其中一个API,此类错误都类似。需要在test文件中,引入正确的测试库,通常来说,RN中它是: @testing-library/jest-native/extend-expect。