公司是做平台组件开发的,不同于一般的业务开发,对单元测试功能的需求会相对高点;在我入职之前项目是没有介入单元测试的,在产品经理要求下,我负责起了单元测试这一块,其实这也是我们今年的一个指标,话说,单元测试年底前没有达标还会……
哈哈哈,废话不多说,本篇文章会侧重于实践,我们是在后期介入单测的,不是项目初始化就开始进行,所以中途还是碰到一些了问题,我把碰到的问题记录下来,并附上解决方案,希望对有碰到类似的同学有所帮助~从项目的搭建到最后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 添加:
[](https://catherine201.gitlab.io/ci-test/lcov-report/index.html)
参考资料: