# 8 Node.js 单元测试
# 8.1 单元测试
# 8.1.1 引子
对于一个程序员来说不仅要写代码,还要验证一下代码写得到底对不对,写单元测试就是一个通用且有效的解决方案。单元测试很重要,可以将错误扼杀在摇篮中,如果你认为没有写单元测试也过得很好,也许等我介绍完 mocha (opens new window) 之后,你会改变主意的。
下面给出一个栗子,假设有一个计算器的项目,不过这个项目周期比较长,后期需要增加更多的人手,所以对于每一个模块都要有相应的测试用例。下面是程序员小明开工后写的第一个函数,一个加法函数:
function add(a,b) {
return a+b;
}
2
3
代码 8.1.1.1 add函数
如果你之前没有接触过单元测试,可能写出的测试用例是这样的:
var s = add(1,2);
if (s == 3) {
console.log('恭喜你,加法测试成功了');
} else {
console.error('哎呦,加法测试出错了啊');
}
2
3
4
5
6
代码 8.1.1.2 不使用测试框架的测试用例
接着又需要写减法函数、乘法函数、除法函数,但是随着模块的增加,就会遇到一个问题,如果按照代码8.1.2的模式的话,一则输出格式比较乱,二则缺失测试结果统计。这时候大名鼎鼎的 mocha
就要闪亮登场了。
有了 mocha
,测试结果输出格式化和测试结果统计的需求就可以迎刃而解了。比如说对于上面那个加法测试用例,就可以这么写了:
var assert = require('assert');
describe('Calculator', function() {
describe('#add()', function() {
it('should get 3 when 1 add 2', function() {
assert.equal(3, add(1,2));
});
});
});
2
3
4
5
6
7
8
代码 8.1.3 使用mocha改写测试用例
mocha 在使用的时候,可以选择全局安装的模式,也可以安装为当前项目的开发依赖包(即在安装的时候使用
--save-dev
参数),不过每个项目中使用的版本可能会略有差异,并且部分 IDE 对于全局安装的支持并不是很好,所以推荐安装为当前项目的开发依赖。
我们将加法运算的代码封装在 calculator.js
中,并且提供一个 mocha 的测试文件 calculator_test.js
:
var assert = require('assert');
var calculator = require('./calculator');
describe('Calculator', function() {
describe('#add()', function() {
it('should get 3 when 1 add 2', function() {
assert.equal(3, calculator.add(1,2));
});
});
});
2
3
4
5
6
7
8
9
10
代码 8.1.4 文件calculator_test.js部分代码
接着运行node calculator_test.js
,不出意外会报错:
e:\kuaipan\code\node\myapp\chapter7\src\test\mocha>node calculator_test.js
e:\kuaipan\code\node\myapp\chapter7\src\test\mocha\calculator_test.js:4
describe('Calculator', function() {
^
ReferenceError: describe is not defined
at Object.<anonymous> (e:\kuaipan\code\node\myapp\chapter7\src\test\mocha\calculator_test.js:4:1)
at Module._compile (module.js:456:26)
at Object.Module._extensions..js (module.js:474:10)
at Module.load (module.js:356:32)
at Function.Module._load (module.js:312:12)
at Function.Module.runMain (module.js:497:10)
at startup (node.js:119:16)
at node.js:929:3
2
3
4
5
6
7
8
9
10
11
12
13
输出 8.1.1.1 命令 node calculator_test.js 的输出
这是由于函数 describe
只有通过 mocha 命令运行时才存在,换成使用 mocha calculator_test.js
来运行则能看到正确结构:
e:\kuaipan\code\node\myapp\chapter7\src\test\mocha>mocha calculator_test.js
Calculator
#add()
√ should get 3 when 1 add 2
1 passing (7ms)
2
3
4
5
6
输出 8.1.1.2 命令 mocha calculator_test.js 的输出
# 8.1.2 断言
这里之所以使用 node 自带的 assert 包,是由于 mocha 仅仅是一个测试框架,本身并没有提供断言(Assertion)库。很多第三方的断言库都可以和 mocha 完美结合:
1.should.js
他使用的格式是 (something).should
或者 should(something)
,更多使用方法还得参阅其 github文档 (opens new window) ,例如上面我们使用 assert 进行判断的代码就可以写成:
var should = require('should');
(calculator.add(1,2)).should.be.exactly(3).and.be.a.Number();
2
3
代码 8.1.2.1 should判断1
或者:
var should = require('should/as-function');
should(calculator.add(1,2)).be.exactly(3).and.be.a.Number();
2
3
代码 8.1.2.2 should判断2
上面仅仅是简单说明使用方法,完整的测试用例大家可以参见第七章源码
test/mocha/calculator_should1.js
和test/mocha/calculator_should2.js
。
2.expect.js
首先给出expect.js的 github地址 (opens new window) ,下面是用expect重写的测试用例:
var expect = require('expect.js');
expect(calculator.add(1,2)).to.be(3);
2
代码 8.1.2.3 expect判断
3.chai
chai (opens new window) 将前面提到的assert should expect融合到了一起,你仅仅需要使用 chai 这一个包就能享用以上三者的功能,所以前面讲到的三种判断在chai中是这么实现的:
var chai = require('chai');
var assert = chai.assert;//use assert
assert.equal(3, calculator.add(1,2));
chai.should();//use should
(calculator.add(1,2)).should.be.equal(3).and.be.a('number');
var expect = chai.expect;//use expect
expect(calculator.add(1,2)).to.equal(3);
2
3
4
5
6
7
8
9
10
代码 8.1.2.4 使用chai判断
这个 chai 还真是个大杀器呢。不过注意,在 chai 中下面关键字
to be been is that which and has have with at of same
只能作为属性使用,不能作为函数使用(除非你自己写代码把这些属性覆盖掉),所以 to.be(3)
要写作to.equal(3)
,另外 chai 中也没有 exactly
这个函数,所以这里也是用 equal
来替代,同时在 chai 中 a
只能作为函数使用,其函数声明为 a(type)
,所以这里用了 a('number')
,其他技术细节,请移步官方API BDD部分 (opens new window)。
# 8.1.3 HTTP 测试
上面演示的是对程序本地调用的测试,不过很多情况下,我门的 API 是通过 HTTP 的形式对外提供访问的,比如说我们将上面提到的计算器的功能改为使用 HTTP 方式对外提供访问,这样的项目依然可以给出测试用例。
HTTP请求和本地调用从流程是有很多不一样的地方的:当中隔着一个网络层,请求数据以 HTTP 报文的格式在网络上进行传输,服务器端解析 HTTP 报文内容后再调用本地 API 进行处理,然后再将处理结果通过 HTTP 报文返回给调用者。supertest (opens new window) 这个工具可以很好得在 mocha 中处理 HTTP 请求断言。
下面是一个 HTTP 接口:
exports.doAdd = function(req, res) {
var _body = req.body;
var a = parseInt(_body.a, 10);
if (isNaN(a)) {
return res.send({code:1,msg:'a值非法'});
}
var b = parseInt(_body.b, 10);
if (isNaN(b)) {
return res.send({code:2,msg:'b值非法'});
}
res.send({code:0,data:a+b});
};
2
3
4
5
6
7
8
9
10
11
12
代码 8.1.3.1 加法运算的 HTTP 接口
对应的supertest的测试用例代码就是这样的:
var request = require('supertest');
var app = require('../../app');
describe('POST /calculator/add', function() {
it('respond with json', function(done) {
request(app)
.post('/calculator/add')
.send({a: 1,b:2})
.expect(200, {
code:0,data:3
},done);
});
});
2
3
4
5
6
7
8
9
10
11
12
13
代码 8.1.3.2 加法运算HTTP接口的单元测试代码
此代码放置于第七章代码的目录 /src/test/http目录下,变量app其实是引用的项目根目录的app.js。
对于一个普通的网站来说,它的接口在访问之前,一定是经过授权的,否则就会将整个网站置于危险之中。用户名、密码登陆是我们常见的一种授权模式,为了简化我们的教程,我们姑且将 代码7.1.3 作为我们的登陆处理代码,那么登陆链接即为/user/login
,管理后台首页为/user/admin
。我们现在来测试登录到后台这个动作。
登录过程要牵扯到我们在第 6 章讲过的session的知识,但是 session 在前端对应的 sessionid 会写入 cookie 中,鉴于我们的测试运行环境是命令行,不是浏览器,这就需要我们自己来维护 cookie 的存储操作。同时我们还需要用到 mocha 中的钩子(Hook)函数,在这里用到的是 mocha 中的 before 函数,它表示在所有测试用例之前执行,当然还有 beforeEach 函数,它表示在每次测试用例执行前都执行一次。显然两者的区别是前者只在所有测试用例前执行一次,而后者要在每个测试用例执行前都要重新执行。先看这个钩子函数:
var request = require('supertest');
var app = require('../../app');
var cookie = exports.cookie = '';
before(function(done) {
request(app)
.post('/user/login')
.send({username:'admin', password:'admin'})
.expect(200,{code:0})
.end(function(err,res) {
if (err) {
return done(err);
}
var header = res.header;
var setCookieArray = header['set-cookie'];
for (var i=0,len=setCookieArray.length;i<len;i++) {
var value = setCookieArray[i];
var result = value.match(/^express_chapter7=([a-zA-Z0-9%\.\-_]+);\s/);
if (result && result.length > 1) {
exports.cookie = cookie = result[1];
break;
}
}
if (!cookie) {
return done(new Error('查找cookie失败'));
}
done();
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
代码 8.1.3.3 钩子函数
这里我们使用全局变量 cookie 来存储我们提到的 sessionid ,在 supertest 的 end 函数中,我们读取响应体变量res中的 set-cookie 头信息,通过正则把 sessionid 读取出来。注意我们这里将 cookie
这个变量还专门做导出了,这样子我们就可以在其他测试文件中引用这个变量了。
接着就可以使用这个读取到的cookie来进入后台了:
var request = require('supertest');
var app = require('../../app');
var before = require('./login_before');
describe('Backend',function() {
it('first test',function(done) {
request(app)
.get('/user/admin')
.set('Cookie','express_chapter7=' + before.cookie)
.expect(200,/<title>admin<\/title>/,done);
});
});
2
3
4
5
6
7
8
9
10
11
12
13
代码 8.1.3.4 请求中使用cookie
我们在模板 user/admin.ejs
有这么一句:<title><%=user.account%></title>
,所以我们这里在测试时使用正则 /<title>admin<\/title>/
来验证是否真的进入后台了。 还有就是在读取 cookie
变量的时候没有直接在 require 完成之后立即读取,因为那个时候,这个变量还没有被赋值,而是在测试用例内部通过 before.cookie
读取。
注意,由于我们使用了 redis 来存储 session 数据,所以如果你忘记启动 redis 服务器的话,我们的登录操作会失败,而且在 mocha 中给出的报错提示是请求超时,这个问题比较隐蔽,大家一定要注意。
# 8.1.4 仿真测试
上面讲的所有的断言都是对结果进行断言,执行某一个函数,查看返回值是否符合预期,或者请求某一个接口看看响应内容是否符合预期。但是某些情况下,我们想对中间步骤进行测试,比如说在执行 函数 A
的时候,正常情况下其内部应该调用了 函数 B
,我们想验证 函数 B
是否真的被调用了。
解决这个需求,就需要用到 sinon (opens new window) 这个包。将代码 代码 8.1.1.1 稍作改造:
function add(a,b) {
console.log(a, b);
return a+b;
}
2
3
4
代码 8.1.4.1
然后我们可以编写测试用例,用来验证 console.log 是否被调用了:
var assert = require('assert');
var sinon = require('sinon');
var calculator = require('./calculator');
describe('Calculator sinon', function () {
describe('#add()', function () {
it('should call console.log in add function', function () {
var spy = sinon.spy(console, 'log');
calculator.add(1, 2);
assert(spy.calledOnce);
assert(spy.calledWith(1, 2));
console.log.restore();
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
代码 8.1.4.2
上述代码的最后一行也可以写成 sion.restore()
, 这样的话,会将所有通过 sinon 改造的函数都做还原。
上面的 spy 函数相当于做了一个钩子,可以检测是否被调用,可以获取调用时传的参数。有了这个函数,就可以让我们的代码测试的覆盖面更广。但是有的时候,我们的代码在调用中间件时,肯定要书写中间件不可用时的处理逻辑,但是真实情况是,我们的中间件绝大多数情况下都是可用的,按照正常流程测试,这部分错误处理函数从来都不会被触发,这时候我们就要用到 stub 这个函数。
const {expect} = require('chai');
const sinon = require('sinon');
const {UserModel} = require('../../models');
const user = require('../../models/user_model');
describe('user check sinon', function() {
it('错误处理', function(done) {
const myFake = sinon.stub(UserModel, 'findOne').callsFake(function(filter, fields, option, callback) {
callback(new Error('error from sinon'));
});
user.loginCheck('','', function(err) {
expect(err).not.be.null;
myFake.restore();
done();
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
代码 8.1.4.3
这里面对于 UserModel.findOne
函数进行改写,强制让其 callback 触发时返回一个 Error 对象,这样我们就可以测试我们代码在数据库查询失败的逻辑处理了。当然在每次测试完成之后,按照惯例需要对于改造进行还原,也就是 myFake.restroe
这句代码的调用,如果不写这句话,UserModel.findOne
就会一直处在被改写的状态,很有可能影响下面的测试。
stub 这个函数不仅仅可以用来模拟中间件出错的场景,还可以用来模拟调用第三方服务返回数据的场景,模拟这种场景可以让你的测试代码脱离第三方服务的依赖。
# 8.2 测试覆盖率
我们写测试用例是为了及时发现开发中产生的 bug,这就引申出来一个问题,怎样写测试用例才能更好的将潜在的 bug 更好的暴漏出来,这也就是我们现在要讲的话题——测试覆盖率。
我们的测试代码是在源码的基础上编写的,那么它属于白盒测试,还有一种基于需求的测试,这种测试不接触源代码,属于黑盒测试。我们这一小节讲覆盖率,只讲白盒测试的覆盖率。
首先我们程序中有各种条件判断分支,理想的情况是,各个分支在测试用例中都有断言检测,但是实际情况中我们很难写出测试代码将所有分支都覆盖。将覆盖到的条件分支数 / 总的条件分支数
,得到的比例数,是测试覆盖率的一种算法。为什么说一种呢?因为实际情况中,一个程序的执行是几个分支依次运行的,这些程序中执行的分支组合就形成一条条路径,所以说还有一种覆盖率的算法是 覆盖的测试路径 / 总的路径数
。这种计算方法比较复杂,现在一些测试覆盖率的统计工具都是基于前者的,比如说我们下面要讲到的 nyc (opens new window)。
通过 npm 安装 nyc 后,修改 npm test
对应的命令,在 mocha
命令前添加一个 nyc
。比如说你之前的 npm run test
的命令是 mocha --recursive src/test/mocha --timeout 99999 --exit
, 那么就可以改为 nyc mocha --recursive src/test/mocha --timeout 99999 --exit
。同时新增一个 coverage
的 npm
命令:
"coverage": "nyc report --reporter=html"
之所以加一个 reporter 的参数,是因为默认的输出格式是文本格式的,可读性不强。
先后运行 npm run test
和 npm run coverage
,打开项目更路径下的文件夹 coverage
下的 index.html
,即可看到对于各个文件的测试分支覆盖情况。
图 8.2.1 测试覆盖率报告
最后说一下,在实践中的一些测试准测,由于覆盖率检测工具是基于分支的,所以即使覆盖率是 100%,依然无法保证所有的测试路径都会被涵盖到。并且当下的程序开发越来越依赖于团队协作,越来越倾向于往微服务的方向发展,单纯的一个服务 100% 没有问题,并不能保证整个系统是没有问题的。更有甚者,客户端存在的一些问题会倒逼服务器端去强行修改业务逻辑,保证线上业务的顺利进行。所有说,测试人员的集成测试还是至关重要的且无可替代的。
本章配套代码:https://github.com/yunnysunny/expressdemo/tree/master/chapter8