react项目Jest+Enzyme单元测试集成至gitlab

2,334 阅读11分钟

   公司是做平台组件开发的,不同于一般的业务开发,对单元测试功能的需求会相对高点;在我入职之前项目是没有介入单元测试的,在产品经理要求下,我负责起了单元测试这一块,其实这也是我们今年的一个指标,话说,单元测试年底前没有达标还会……

   哈哈哈,废话不多说,本篇文章会侧重于实践,我们是在后期介入单测的,不是项目初始化就开始进行,所以中途还是碰到一些了问题,我把碰到的问题记录下来,并附上解决方案,希望对有碰到类似的同学有所帮助~从项目的搭建到最后gitlab 的集成!

    环境要求: node(版本9+,8以下版本不支持,单测会跑不起来) 、yarn 或者npm 版本管理工具(本人喜好yarn,所以下面的内容都是用yarn)

    一、项目搭建:

        为了节省时间,我用create-react-app 快速生成一个项目;

        这里解释以下,creat-react-app 其实已经内置了单元测试的功能,你可以开箱即用,我这里用create-react-app 来搭建项目只是为了节省时间,一般来说,很多公司不会直接用create-react-app来搭建项目,而是从零公司自己搭建;

$ yarn create react-app react-jest

     二、 技术栈的选择

       我选择了目前市场主流的 jest+enzyme,为什么选择它,我就不展开了,能成为主流总有它独特的地方;

参考链接:

- Jest: jestjs.io/docs/en/api

- Enzyme: enzymejs.github.io/enzyme/

     三、依赖安装

enzyme、enzyme-adapter-react-16、enzyme-to-json如果你是直接用脚手架 create-react-app 搭建的项目,你就不用安装以下依赖,否则你需要安装以下依赖:
identity-obj-proxy、babel-jest、jest、jsdom

    四、jest单测配置

 在package.json script 新增测试命令:

    "test": "jest --config .jest.js",    "test:update": "jest --config .jest.js --no-cache --update-snapshot",    "test:watch": "jest --config .jest.js --watch",    "test:coverage": "jest --config .jest.js --runInBand --coverage --ci",    "test:report": "jest --config .jest.js --coverage && open ./coverage/lcov-report/index.html"

1、在项目的根目录下新建.jest.js 文件(单测配置文件)

module.exports = {  setupFiles: [    './tests/setUpTests.js', // 启动文件    'jest-canvas-mock' // 如果项目里面使用了canvas,需要配置这个  ],  setupFilesAfterEnv: ['./tests/setupAfterEnv.js'],  // testEnvironment: 'node',  默认是jsdom  moduleFileExtensions: [    'js',    'jsx',    'json'  ],  testPathIgnorePatterns: [    '/node_modules/'  ],  testRegex: '.*\\.test\\.js$',  collectCoverage: false, // 是否生成测试报告  verbose: true, // 是否展示每条测试case 的结果  silent: false, // 是否展示console.log 打印的东西  lastCommit: false, // 是否只执行上次commit 文件  collectCoverageFrom: [   // 需要进行测试的文件集    'src/component/**/*.js',    'src/component/**/*.jsx',    'src/action/**/*.js',    'src/service/**/*.js',    'src/page/**/*.js',    'src/page/**/*.jsx',    'src/util/*.js',    '!src/component/web/base/DateTimePicker/locale/**'  ],  coverageThreshold: {    global: {      branches: 60,      functions: 60,      lines: 60,      statements: 60    },    "src/util/*.js": {      branches: 80,    },  },  moduleNameMapper: {    '^@/(.*)': '<rootDir>/src/$1',  // 在测试js 文件中使用的别名    '^react-tests/(.*)': '<rootDir>/tests/$1'  // 别名  },  transform: {    '^.+\\.(js|jsx)$': 'babel-jest'  },  snapshotSerializers: ['enzyme-to-json/serializer'],  globals: {    // '$': function(){},    // 'jsbarcode': {}  }}

2、在项目的根目录下新建tests 文件夹,文件夹里面新建setUpTests.js,内容如下:

import 'raf/polyfill' // 安装一下requestAnimation的模拟,react需要import { configure } from 'enzyme'import Adapter from 'enzyme-adapter-react-16'import * as jsdom from 'jsdom'import jQuery from '../static/thirdjs/jquery-1.12.4.min.js' // 如果项目使用了jqueryimport 'whatwg-fetch'const { window } = new jsdom.JSDOM('<!doctype html><html><body></body></html>')
// 在global 变量挂载变量,那么项目里面直接使用的变量才不会报错
global.window = windowglobal.document = window.documentwindow.$ = window.jQuery = jQueryglobal.$ = global.jQuery = jQueryglobal.console = {  log: console.log,  warn: jest.fn(),   // 这样设置项目里的warn 在跑单测时候console.warn 的内容不会打印出来  error: console.error,  info: console.info,  debug: console.debug}configure({ adapter: new Adapter() })

3、 文件夹里面新建setupAfterEnv.js(这个文件的内容是在运行setUpTests文件之后,单测case 运行之前执行,我在这个文件使用expect.extend将自己的匹配器添加到Jest)内容如下:

参考链接: jestjs.io/docs/zh-Han…

import { toMatchMountSnapshot, toMatchShallowSnapshot } from './matchers/rendered-snapshot'expect.extend({  toMatchMountSnapshot,  toMatchShallowSnapshot})

4、在tests文件夹下新增matchers文件夹,里面用来放置需要添加的匹配器,我这里添加了rendered-snapshot.js 文件

import { mount, shallow } from 'enzyme'export function toMatchMountSnapshot (  jsx) {  try {    expect(mount(jsx).render()).toMatchSnapshot()    return {      message: () => 'expected JSX not to match snapshot',      pass: true    }  } catch (e) {    return {      message: () => `expected JSX to match snapshot: ${e.message}`,      pass: false    }  }}export function toMatchShallowSnapshot (  jsx) {  try {    expect(shallow(jsx).render()).toMatchSnapshot()    return {      message: () => 'expected JSX not to match snapshot',      pass: true    }  } catch (e) {    return {      message: () => `expected JSX to match snapshot: ${e.message}`,      pass: false    }  }}

到了这里,貌似完成了最简单的配置,我们来运行以下yarn run test,然而出现了这样的报错:

根据报错的提示,原来是我们没有babel 的配置文件,解决方法,在根目录下新建.babelrc

{    "presets": [      [        "@babel/preset-env",        {          "targets": {            "chrome": "50",            "ie": "11"          },          "modules": "commonjs",          "useBuiltIns": "usage",          "corejs": { "version": 3, "proposals": true }        }      ],      "@babel/preset-react"     ]}

好了,我们重新运行一下,还是报了以下错误:

这是什么意思呢? 单测只能识别js 文件类型,但是你引入了svg 类型,所以报错了,解决方法:在.jest.js 配置文件 moduleNameMapper 中添加

  moduleNameMapper: {      '^@/(.*)': '<rootDir>/src/$1',  // 在测试js 文件中使用的别名      '^react-tests/(.*)': '<rootDir>/tests/$1',      '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/tests/__mocks__/fileMock.js'    },

无法识别svg类型,反应快的同学应该可以想到样式文件(css/less/scss),也是需要经过特殊的处理,为了验证我们的想法,我们先不做处理,重新运行一下,和预料的一样,报错:

解决方法:

    moduleNameMapper: {      '^@/(.*)': '<rootDir>/src/$1',  // 在测试js 文件中使用的别名      '^react-tests/(.*)': '<rootDir>/tests/$1',      '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/tests/__mocks__/fileMock.js',      '\\.(css|less|scss)$': 'identity-obj-proxy'    },

这样总可以了吧?我们来运行一下:

如果项目里面引入都是使用相对路径,这样的确是没问题,但是,往往我们使用别名@来引入其他文件,现在我把app.js 文件里面的import './App.css' 改为import '@/App.css',然后重新运行一下

当时碰到这个问题的时候,我也挺好奇的,我明明已经配置了样式文件的moduleNameMapper,为啥还会报错了,后面发现了是因为我使用了@这个别名,所以它无法识别,需要在moduleNameMapper 添加:

    moduleNameMapper: {      '^@/(.*).(css|less|scss)': 'identity-obj-proxy', // 注意这个配置需要在'\\.(css|less|scss)$' 的配置前面,否则会被覆盖,不生效      '^@/(.*)': '<rootDir>/src/$1',  // 在测试js 文件中使用的别名      '^react-tests/(.*)': '<rootDir>/tests/$1',      '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/tests/__mocks__/fileMock.js',      '\\.(css|less|scss)$': 'identity-obj-proxy'    },

👌,到了这里,我们的单测配置差不多都已经ok 了,我在app.test.js。里面生成了一个快照,但是生成的结构貌似不是想要的

我们在.jest.js 文件里面添加

snapshotSerializers: ['enzyme-to-json/serializer'],

然后重新运行一下:

嗯的,达到理性效果了,但是我们会发现各种报变量未定义的提示(eslint对Jest关键字报错)

我们可以通过在package.json添加下面的配置:

"standard": {    "env": ["jest"]}

完美~

   jest 单测配置到这里也就差不多了,接下来我们来讲讲测试目录结构

五、 测试目录结构

- 一般来说单测文件和待测试文件同名,以`.test.js`后缀结束

- 可把单测文件都放到统一的测试文件目录下方便管理

- 或者把单测文件分散到和同名js文件同目录下方便查找修改

六、 测试用例

- UI测试

 除了生成快照是检测UI的方式之一,也可以通过查找相应的元素,进行断言来检测

const wrapper = mount(<MyComponent  />const button = wrapper.find('.icon-container')expect(button.length).toBe(1)

比较细心的同学可能会发现,这里的例子和enzyme 里面的例子不一样,enzyme里面的语法是这样的

expect(button.length).toBe(1)to.equal(1)

其实这个我也解释不清,但是按照enzyme 上面的语法是会报错的,断言是需要follow Jest Expect 里面的语法: jestjs.io/docs/zh-Han…

- 周期函数的测试

 在调用enzyme的mount方法 会把整个组件执行一遍,其中就包含,组件初始化的生命周期(compomentWillMount, componentDidMount 等), 但是并不会执行组件卸载的生命周期,如果你在组件卸载周期函数里面有执行一些逻辑操作,需要你手动执行 unmount方法

- 组件内部函数的测试

 组件内部函数可以模拟点击、鼠标移入移出等事件来触发,eg:

wrapper.find('.clear').simulate('click')

注意事项:

1、我们可以通过wrapper对象的setProps 和setState 方法 修改props 和state 值,但是在修改之后,我们需要调用wrapper.update() 方法来更新,之后的断言才能准备拿到想要的结果

2、我们可以mock 函数,然后来判断mock 函数有没有被调用来判断是否被准备执行

const callback = jest.fn()
expect(callback).toHaveBeenCalled();
expect(callback.mock.calls.length).toBe(1)

3、instance 方法

wrapper 调用instance 方法可以获得这个组件的实例,这样就可以获得实例的state、props、里面的方法等;

4、getDOMNode

通过getDOMNode 方法可以获得js元素DOM对象

expect(wrapper.find('button').getDOMNode().hasAttribute('ant-click-animating-without-extra-node')).toBe(false)
expect(wrapper.find('.ant-alert').getDOMNode().getAttribute('data-test')).toBe('test-id')
expect(wrapper.find('button').getDOMNode().className).toBe('')

5、以上测试皆是基于class component,众所周知,hook在18年底推出之后,目前很多公司也是用hook 来重构项目,但是hook 写法对单测不是那么友好,目前为止也没有一个特别完善的方案,以下是我的一些替代方法:

- 测试状态

Hook函数组件是没有instance()方法,无法拿到state状态,只能通过拿dom节点里面的text来替代

- @testing-library/react-hooks

因为没有instance 方法,想要获得组件里面的状态方法,不能直接获得;我们需要对原本组件进行修改,写个方法把state 和一些方法暴露出去,然后再在组件里面进行引用;

详细可以参考:www.npmjs.com/package/@te…

- 测试函数副作用

目前没有找到执行组件useEffect 的方法,只能通过模拟执行里面的方法来测试,但是这样就需要把函数定义在外面……

到这里,测试的编码阶段差不多已经接近尾声了,具体怎么写单测case ,还是需要结合自己项目的功能,我这里只是类出一些常用的api以及可能碰到的问题;

七、 添加debug 调试功能

最开始我都是用console.log 来进行调试,后面发现有点慢,就添加了debug 调试功能,具体实现:

一开始我以为添加插件就可以实现,后面发现用插件,是可以去跑单个文件的单测,但是这样不会先去跑初始化文件,这样就会各种报错,最后还是通过在项目根目录添加.vscode文件夹,在这个文件夹下面添加launch.json 文件

{    "version": "0.2.0",    "configurations": [                        {            "name": "Jest Debug AllFile",            "type": "node",            "request": "launch",            "protocol": "inspector",            "program": "${workspaceRoot}/node_modules/jest/bin/jest",            "stopOnEntry": false,            "args": ["--runInBand", "--env=jsdom", "--config=.jest.js"],            "runtimeArgs": [                "--inspect-brk"            ],            "cwd": "${workspaceRoot}",            "sourceMaps": true,            "console": "integratedTerminal",            "port": 9229        },        {            "name": "Jest Debug File",            "type": "node",            "request": "launch",            "protocol": "inspector",            "program": "${workspaceRoot}/node_modules/jest/bin/jest",            "stopOnEntry": false,            "args": ["--runInBand", "--env=jsdom", "${fileBasename}", "--config=.jest.js"],            "runtimeArgs": [                "--inspect-brk"            ],            "cwd": "${workspaceRoot}",            "sourceMaps": true,            "console": "integratedTerminal",            "port": 9229        }    ]  }

调试方法,shift+F5

八、测试报告,代码覆盖率

写单测很重要的一个指标是代码覆盖率,虽然其不是最重要的,但是这也是检测你单测的一个重要指标,我这里简单介绍一下各个维度:

- 代码覆盖率,有四个测量维度

- 行覆盖率(line coverage):是否测试用例的每一行都执行了

- 函数覆盖率(function coverage):师傅测试用例的每一个函数都调用了

- 分支覆盖率(branch coverage):是否测试用例的每个if代码块都执行了

- 语句覆盖率(statement coverage):是否测试用例的每个语句都执行了

- 我们一般看的是`语句覆盖率`这个维度,我们可以通过命令行,或者在.jest.js 配置文件设置各个覆盖率的门槛,然后再检查测试用例是否达标,只要有一个不达标,就会报错

    collectCoverageFrom: [   // 代码覆盖率计算的依据: 需要计算覆盖率的进行文件集合      'src/component/**/*.js',      'src/component/**/*.jsx',      'src/action/**/*.js',      'src/service/**/*.js',      'src/page/**/*.js',      'src/page/**/*.jsx',      'src/util/*.js',      '!src/component/web/base/DateTimePicker/locale/**'    ],    coverageThreshold: {      global: {        branches: 60,  // 覆盖率达到多少才算合格        functions: 60,        lines: 60,        statements: 60      },      "src/utils/*.js": {        statements: 80,      },    },

执行yarn run test:coverage 或者yarn run test:report ,将会在项目根目录生成coverage 文件夹,里面lcov-report的index.html 就是测试报告

nice~ 

能够浏览到这里真的很不容易,文章也即将到尾声,但是整个流程其实还没走完,最后的一步也是很重要的一步,你的单测要在什么进行呢?在每一次commit 之前进行一次,通过了单测才允许提交,还是说在代码提交之后打包之前去跑单测呢?这里我把这两种都实现:

一、在每次commit之前跑一遍相应文件的单测,过了才允许提交

1、下载依赖 husky 和 lint-staged(这两个的作用如果不清除,可以先google 一下,哈哈哈)
2、 在package.json 添加代码:
  "husky": {    "hooks": {      "pre-commit": "lint-staged"    }  },  "lint-staged": {    "src/component/**/*.js": ["jest --config .jest.js --bail --findRelatedTests"]  },

这样在你每次commit 之前,都会先跑一遍你修改的那个文件的单测,过了才能提交,否则提交失败~

二、集成到gitlab

作为个人项目,我是直接把项目放在gitlab 进行管理,打开gitlab 页面,点击CI/CD,目前pipeline 并无东西,那是因为项目我们并没有配置.gitlab-ci.yml 文件

我先放个.gitlab-ci.yml 的一个简单例子

stages:  - testtest:  script:    - echo 'testing'    - if [ ! -d node_modules ]; then npm i; fi    - npm run test:coverage  stage: test  only:    - master  image: node

这里的stages 就是我们CI/CD 里面看到的Jobs,每次代码push 上去的时候,就会触发CI Pipeline, gitlab-ci.yml这个文件就是告诉GitLab runner该怎么做;

我这里提交一个简单的gitlab-ci.yml文件,界面就变成这样

   要实现CI自动化集成,首先你需要有Runner 运行器,如何配置runner ,你可以百度,资料一大堆,不过这主要是运维的工作啦(有兴趣的也可以自己了解一下),我这里直接用的是gitlab本身的共享runner,所以这里不需要任何操作,你代码push 上去,runner 就会去触发pipeline ,进而去执行你yml 文件定义的各个job,首先它会先去拉取你image 镜像(这个不了解的可以去了解一下docker),然后去执行script 里面的脚本,如果你yml 文件有定义before-script,这里面的脚本会在script 之前执行,关于yml 其他的内容,由于时间关系,大家 可以自行google~

ci 成功跑完之后会有一个passed 的标志

但是我们发现coverage栏没有相应的值

这和我们的理想还是有点差距的,最后我还是看了gitlab 官方文档和多次尝试才找出最正确的配置姿势:

stages:  - testtest:  script:    - echo 'testing'    - if [ ! -d node_modules ]; then npm i; fi    - npm run test:coverage  stage: test  only:    - master  image: node  artifacts:    paths:      - coverage/lcov-report/  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'  // 这里的正则是关键,哈哈哈哈

快见到曙光了,小伙伴们坚持一下~

我们会发现github 很多项目都会有一个徽标,上面展示一个项目对应的覆盖率,点击进去就是相应的报告地址,github 很多都是结合codecov 来做的,还需要两个账号进行绑定,gitlab 就不用这么麻烦,不过由于没经验,网上针对性的相关资料比较少,我还是花了挺多时间才搞出最终的配置

yml 添加 pages

stages:  - test  - pagestest:  script:    - echo 'testing'    - if [ ! -d node_modules ]; then npm i; fi    - npm run test:coverage  stage: test  only:    - master  image: node  artifacts:    paths:      - coverage/lcov-report/  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'pages:  stage: pages  dependencies:    - test  script:    - mv coverage/lcov-report/ public/  artifacts:    paths:      - public    expire_in: 30 days  only:    - master

readme 添加:

[![Coverage report](https://gitlab.com/catherine201/ci-test/badges/master/coverage.svg?job=test)](https://catherine201.gitlab.io/ci-test/lcov-report/index.html)

参考资料:

about.gitlab.com/blog/2016/1…