说到程序开发,bug总是如影随形,开发过程中50%的时间在debug,30%是在修之前发布了的bug,毫不奇怪。
如何把bug见到最少,甚至是0 bug呢?看似遥不可及,但实际是可以追求的,方法就是完整科学的测试,事实上,测试和使用是证明代码没bug的唯一方式。
测试又分为白盒测试跟黑盒测试,一般来说,产品上线前经过测试同学测试做的功能测试都是黑盒测试,毕竟测试同学不可能了解所有的代码,而能做白盒测试的,都是最了解代码的人,也就是写代码的程序员。而只有最了解代码的人也才能写出最完善的测试用例。而单元测试就是白盒测试里面最重要的方法之一。
很多程序员,特别是前端程序员是没有写单元测试的习惯。诚然,写单元测试其实是比较费劲的事情,特别在前端领域,涉及大量ui及交互操作,写单元测试尤为困难。但是近年来js模块化越演越烈,模块化的同时也使得写单元测试变得更容易了。
本文就致力于探讨在当前的开发环境下做单元测试的一些实践方法。
先介绍下被测试的对象 —— 一个js工具库,这个公共库有几个比较重要的标签:ES6,移动端,浏览器端。
从最简单的讲起吧,js的测试框架其实不算少,比较有名的有mocha,Jasmine,Jest等,基本用法都比较简单明了,看看官方文档就大概能写出来一些测试用例了。下面我主要使用比较强大mocha来作为主要的测试框架。
下面是我项目中的一份test.js:
import * as lib from './index';
import chai from 'chai';
let expect = chai.expect;
describe('testcase',function(){
it('single one',function(){
let a = 'aer';
expect(a).to.be.a('string');
});
it('test /common/getUrlParam',function(){
let ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1');
expect(ret).to.equal('1');
ret = lib.common.utils.getUrlParam('a','http://vip.qq.com#a=1');
expect(ret).to.equal('1');
ret = lib.common.utils.getUrlParam('a','https://vip.qq.com?a=1');
expect(ret).to.equal('1');
ret = lib.common.utils.getUrlParam('b','http://vip.qq.com?a=1&b=2');
expect(ret).to.equal('2');
ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1#a=2');
expect(ret).to.equal('2');
});
});
ES6
这份test.js包含两个测试用例,第一用例是用于测试的,单纯试用下测试框架跟断言库的功能,第二个用例是对库文件中一个模块的一个方法的测试用例,估计前端大佬们看看方法名大概都能看懂是什么东西,我就不多说了。写好test.js后就跑了试试看,运行mocha,然后马上就报错了:
JUSTYNCHEN-MC0:gxh-lib-es6 justynchen$ ./node_modules/.bin/mocha
/path/to/your/project/somelib/test.js:1
(function (exports, require, module, __filename, __dirname) { import * as lib from './index';
^
SyntaxError: Unexpected token *
at new Script (vm.js:79:7)
at createScript (vm.js:251:10)
at Object.runInThisContext (vm.js:303:10)
at Module._compile (internal/modules/cjs/loader.js:656:28)
...
我当前的node版本是v10.13.0(对,近日node的LTS版本已经升级到10.x了,没升的同学赶紧玩玩吧),按理说是默认支持import的。仔细一看报错,原来mocha不是直接执行test.js,而是把test.js的内容放到了一个沙箱里面执行的。那就有点蛋疼了,就算node新版本已经支持了也没法直接使用。第一想法就是先把代码编译了,然后再做测试,可是测试的代码都是编译后的代码,就算测试出什么问题,还要经过sourcemap才能找到源码中出错的位置,想想都蛋疼。
官方当然是不会这么蠢的,稍微找了下官方的方案,不难找到对es6的支持。babel提供了一个register,给到不同的应用去做转换,mocha同样也可以使用这个register先转换然后再跑。命令就变成了这个:
mocha --require babel-core/register
同时,package.json里面的选项也要加上babel的选项:
"babel": {
"presets": [
"stage-3",
"latest"
],
},
当然,相关的包(babel,babel-core)也要同时装上,大家都懂的后面我就不提了,缺啥装啥就对了,后面提到的工具如没特别说明都是指npm包。
到这里相关的资料还比较好找,接下来就是干货了。
browser
运行上面改造过的mocha命令,是不是就ok了呢?当然没那么顺利,这里遇到了第二个坑:
./node_modules/.bin/mocha --require babel-core/register
/path/to/your/project/somelib/common/cache.js:15
var storage = window[storageType];
^
ReferenceError: window is not defined
at initStorage (/Users/justynchen/....../cache.js:10:16)
at Object.<anonymous> (/Users/justynchen/....../cache.js:41:16)
at Module._compile (internal/modules/cjs/loader.js:688:30)
前面也说了这份库是给移动端浏览器用的,其中就免不了使用一些浏览器的API,这些API在node里面都是不存在的。解决方案有两个:
- 直接不测试使用了浏览器API的代码,使用前先做检测并return掉。
- 找一个模拟浏览器的环境,让浏览器的API也能正常执行。
方案一是我们不愿意看到的,特别是一份浏览器用的库,不测试浏览器相关的特性那跟咸鱼有什么区别【手动狗头】。那就按方案二的思路想走吧,想到node模拟浏览器的环境,脑中浮现的第一个名词估计大部分人跟我都一样 —— electron。作为业界最著名的“没有界面的浏览器”,用在这里再合适不过了。但是该怎么用呢,稍作搜索,果然已经有前人做了相应的工作,有一个electron-mocha的工具刚好就是把这两个东西合了起来。
然后命令就变成了这样:
electron-mocha --renderer --require babel-core/register
然后终于得到了我们想要的结果:
JUSTYNCHEN-MC0:somelib justynchen$ ./node_modules/.bin/electron-mocha --renderer --require babel-core/register
testcase
✓ single one
✓ test /common/getUrlParam
2 passing (37ms)
完美~(请自动脑补金星脸)
可是,就这么完了是否有点意犹未尽?
是的,就是缺了点什么东西,说好的0 bug呢,写了测试用例就能保证0 bug了么?肯定不是的,如果有的地方就是有bug,只是用例没写好,并没有覆盖到有问题的地方怎么办?只有写“全”了的测试用例才能保证0 bug。如何确保用例写全了呢?请往下看。
代码测试覆盖率
这里引入一个概念,叫代码测试覆盖率,大概意思就是说,你的测试用例到底覆盖了多少的代码。理想情况下,肯定只有100%覆盖所有代码的用例,才能说自己经过测试的代码是0 bug的,当然现实中100%总是很难的,一般覆盖到90%以上已经是比较理想的情况了。
JS也有统计代码测试覆盖率的库 —— Istanbul。这库名也很有意思,库名直译是伊斯坦布尔,没错,就是那个正常中国人可能名字都没听说过的中东城市。这个地方有个特产是毯子,然后这个库的作者就想,覆盖就是毯子该做的事情嘛,脑洞一开就把库名起作Istanbul了。
继续“稍微读下文档”,哦,原来这个库有一个命令行工具,nyc,装上然后放到执行命令的前面就能做覆盖率统计了。然后就有了下面的命令
nyc --reporter=lcov --reporter=text electron-mocha --renderer --require babel-core/register
其中reporter是定制化报告的内容,默认是text,lcov就是生成一个网页版的覆盖率报告。
然而,跑完之后是酱婶儿的
JUSTYNCHEN-MC0:somelib justynchen$ ./node_modules/.bin/nyc ./node_modules/.bin/electron-mocha --renderer --require babel-core/register
testcase
✓ single one
✓ test /common/getUrlParam
✓ aidMaker test
3 passing (18ms)
----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files | 0 | 0 | 0 | 0 | |
----------|----------|----------|----------|----------|-------------------|
苍天大地啊,为啥啥都没有。。。。
这里就又开始苦逼的查资料环节,不得不吐槽一下,这个资料是真不好查,中文资料没有就算了,反正早习惯了,文档翻遍了还是没有。。。那就过分了。然后查了下别的资料,基本都是mocha跟Istanbul一起用的,也没有electron-mocha相关的。
最后还是找到了github的issue里面,果然有人是跟我有类似问题,找了几个提了没啥回音的,终于找到一个maintainer的回复。里面指向了一个插件 —— babel-plugin-istanbul。
皇天不负有心人,得益于之前已经引入了babel,这里只要加上这个插件就ok了,照例先装包,package.json里面的babel配置加上这个参数
"babel": {
"presets": [
"stage-3",
"latest"
],
"env": {
"test": {
"plugins": [ "istanbul" ]
}
}
},
然后按照插件的README改一下命令,最终得到了这个
cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text electron-mocha --renderer --require babel-core/register
然后一跑。。。发现,x你x的。。。跟前面的输出一毛一样,啥的没有!!!
冷静一下,再回头看看maintainer回复的原话
Basically, yes. Note that if you’re using babel already you can now just use the excellent istanbul-plugin to instrument your code. If you do this, you really only need to write out the __coverage__ object after the tests have run. (I write them out for both renderer and main thread tests, then combine them using nyc report from the command line).
里面提到了一个奇怪的参数__coverage__,又查一波资料,在这个项目的某些commit comment里面看到这个__coverage__的蛛丝马迹,这东西好像是一个全局参数,那这个参数有啥用咧?管他有用没用,打出来看看再说,然后就在test.js里面吧这个参数打了下,发现,卧槽,还真有,而且里面不就是覆盖率的数据么????
嗯,有数据。。。怎么生成报告呢?作者写的语焉不详。。。啥叫write out这个参数然后配合nyc report命令就能用了,write到啥地方啊,咋配合啊!!!
这个时候就想到了Istanbul的一些特性,其实它是会在测试后生成一个.nyc_output的文件夹的,打开一看,里面不就是一些json么!那是不是直接write进去就好了呢?文件该叫啥名字咧,原来的文件都是hash命名的,这hash哪来的呀。不管了写了再说,然后得到如下test.js
import * as lib from './index';
import chai from 'chai';
import fs from 'fs';
let expect = chai.expect;
describe('testcase',function(){
it('single one',function(){
let a = 'aer';
expect(a).to.be.a('string');
});
it('test /common/getUrlParam',function(){
let ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1');
expect(ret).to.equal('1');
ret = lib.common.utils.getUrlParam('a','http://vip.qq.com#a=1');
expect(ret).to.equal('1');
ret = lib.common.utils.getUrlParam('a','https://vip.qq.com?a=1');
expect(ret).to.equal('1');
ret = lib.common.utils.getUrlParam('b','http://vip.qq.com?a=1&b=2');
expect(ret).to.equal('2');
ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1#a=2');
expect(ret).to.equal('2');
});
after(function() {
fs.writeFileSync('./.nyc_output/coverage.json',JSON.stringify(__coverage__));
});
});
终于生效了
JUSTYNCHEN-MC0:somelib justynchen$ tnpm run test
> @tencent/[email protected] test /path/to/your/project/.....
> cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text electron-mocha --renderer --require babel-core/register
testcase
✓ single one
✓ test /common/getUrlParam
3 passing (26ms)
----------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------|----------|----------|----------|----------|-------------------|
All files | 5.96 | 3.3 | 3.6 | 6.02 | |
gxh-lib-es6 | 0 | 0 | 0 | 0 | |
index.js | 0 | 0 | 0 | 0 | |
gxh-lib-es6/business | 16.89 | 8.84 | 3.45 | 16.89 | |
aid-maker.js | 80 | 66.67 | 100 | 80 | 325,340,341,344 |
cgi-handler.js | 0 | 0 | 0 | 0 |... 57,159,160,161 |
index.js | 0 | 0 | 0 | 0 | |
pay.js | 0 | 0 | 0 | 0 |... 86,88,89,91,97 |
....
至此,终于可以说出那句
完美~
附录
整体架构图
后记:整个单元测试的技术其实都没什么困难的,基本上都有库可以用,主要把时间都花在了查询资料上面。写本文的时候好像是遇到问题马上就找到了解决方案,其实真实情况是,几乎遇到每个坑都会试了至少一两个走不通的方案,最后才找到正确的方案的,所以对于不经常关注社区的人来说,单靠文档是很难解决所有的问题的。单从前端领域来看,前端的技术日新月异,再完善的文档都很快会跟不上发展的步伐,还是要靠多关注社区的动向,甚至多参与社区的讨论和建设才不至于在需要用某些技术的时候无从下手。