精华 在Nodejs中贯彻单元测试
发布于 2 年前 作者 JerryC8080 18753 次浏览 最后一次编辑是 10 个月前 来自 分享

在团队合作中,你写好了一个函数,供队友使用,跑去跟你的队友说,你传个A值进去,他就会返回B结果了。过了一会,你队友跑过来说,我传个A值却返回C结果,怎么回事?你丫的有没有测试过啊?

大家一起写个项目,难免会有我要写的函数里面依赖别人的函数,但是这个函数到底值不值得信赖?单元测试是衡量代码质量的一重要标准,纵观Github的受欢迎项目,都是有test文件夹,并且buliding-pass的。如果你也为社区贡献过module,想更多人使用的话,加上单元测试吧,让你的module值得别人信赖。

要在Nodejs中写单元测试的话,你需要知道用什么测试框架,怎么测试异步函数,怎么测试私有方法,怎么模拟测试环境,怎么测试依赖HTTP协议的web应用,需要了解TDD和BDD,还有需要提供测试的覆盖率。

本文的示例代码会备份到 Github : unittest-demo

目录

  1. 测试框架
  2. 断言库
  3. 需求变更
  4. 异步测试
  5. 异常测试
  6. 测试私有方法
  7. 测试Web应用
  8. 覆盖率
  9. 使用Makefile把测试串起来
  10. 持续集成,Travis-cli
  11. 一些观点
  12. 彩蛋
  13. 整理

测试框架

Nodejs的测试框架还用说?大家都在用,Mocha。

Mocha 是一个功能丰富的Javascript测试框架,它能运行在Node.js和浏览器中,支持BDDTDDQUnitExports式的测试,本文主要示例是使用更接近与思考方式的BDD,如果了解更多可以访问Mocha的官网



测试接口

Mocha的BDD接口有:

  • describe()
  • it()
  • before()
  • after()
  • beforeEach()
  • afterEach()

安装

npm install mocha -g

编写一个稳定可靠的模块

模块具备limit方法,输入一个数值,小于0的时候返回0,其余正常返回

exports.limit = function (num) {
  if (num < 0) {
    return 0;
  }
  return num;
};

目录分配

  • lib,存放模块代码的地方
  • test,存放单元测试代码的地方
  • index.js,向外导出模块的地方
  • package.json,包描述文件

测试

var lib = require('index');

describe('module', function () {
  describe('limit', function () {
    it('limit should success', function () {
      lib.limit(10);
    });
  });
});

结果

在当前目录下执行mocha

$ mocha

  ․

  ✔ 1 test complete (2ms)

断言库

上面的代码只是运行了代码,并没有对结果进行检查,这时候就要用到断言库了,Node.js中常用的断言库有:

  • should.js
  • expect.js
  • chai

加上断言

使用should库为测试用例加上断言

it('limit should success', function () {
  lib.limit(10).should.be.equal(10);
});

需求变更

需求变更啦: limit这个方法还要求返回值大于100时返回100。

针对需求重构代码之后,正是测试用例的价值所在了,

它能确保你的改动对原有成果没有造成破坏。

但是,你要多做的一些工作的是,需要为新的需求编写新的测试代码。

异步测试

测试异步回调

lib库中新增async函数:

exports.async = function (callback) {
  setTimeout(function () {
    callback(10);
  }, 10);
};	

测试异步代码:

describe('async', function () {
  it('async', function (done) {
    lib.async(function (result) {
      done();
    });
  });
});

测试Promise

使用should提供的Promise断言接口:

  • finally | eventually
  • fulfilled
  • fulfilledWith
  • rejected
  • rejectedWith
  • then

测试代码

describe('should', function () {
  describe('#Promise', function () {
    it('should.reject', function () {
      (new Promise(function (resolve, reject) {
        reject(new Error('wrong'));
      })).should.be.rejectedWith('wrong');
    });

    it('should.fulfilled', function () {
      (new Promise(function (resolve, reject) {
        resolve({username: 'jc', age: 18, gender: 'male'})
      })).should.be.fulfilled().then(function (it) {
          it.should.have.property('username', 'jc');
        })
    });
  });
});

异步方法的超时支持

Mocha的超时设定默认是2s,如果执行的测试超过2s的话,就会报timeout错误。

可以主动修改超时时间,有两种方法。

命令行式

mocha -t 10000

API式

describe('async', function () {
  this.timeout(10000);
  it('async', function (done) {
    lib.async(function (result) {
      done();
    });
  });
});

这样的话async执行时间不超过10s,就不会报错timeout错误了。

异常测试

异常应该怎么测试,现在有getContent方法,他会读取指定文件的内容,但是不一定会成功,会抛出异常。

exports.getContent = function (filename, callback) {
  fs.readFile(filename, 'utf-8', callback);
};

这时候就应该模拟(mock)错误环境了

简单Mock

describe("getContent", function () {
  var _readFile;
  before(function () {
    _readFile = fs.readFile;
    fs.readFile = function (filename, encoding, callback) {
      process.nextTick(function () {
        callback(new Error("mock readFile error"));
      });
    };	
  });
  // it();
  after(function () {
    // 用完之后记得还原。否则影响其他case
    fs.readFile = _readFile;
  })
});

Mock库

Mock小模块:muk ,略微优美的写法:

var fs = require('fs');
var muk = require('muk');

before(function () {
  muk(fs, 'readFile', function(path, encoding, callback) {
    process.nextTick(function () {
      callback(new Error("mock readFile error"));
    });
  });
});
// it();
after(function () {
  muk.restore();
});

测试私有方法

针对一些内部的方法,没有通过exports暴露出来,怎么测试它?

function _adding(num1, num2) {
  return num1 + num2;
}

通过rewire导出方法

模块:rewire

it('limit should return success', function () {
  var lib = rewire('../lib/index.js');
  var litmit = lib.__get__('limit');
  litmit(10);
});

测试Web应用

在开发Web项目的时候,要测试某一个API,如:/user,到底怎么编写测试用例呢?

使用:supertest

var express = require("express");
var request = require("supertest");
var app = express();

// 定义路由
app.get('/user', function(req, res){
  res.send(200, { name: 'jerryc' });
});

describe('GET /user', function(){
  it('respond with json', function(done){
    request(app)
      .get('/user')
      .set('Accept', 'application/json')
      .expect('Content-Type', /json/)
      .expect(200)
      .end(function (err, res) {
        if (err){
          done(err);
        }
        res.body.name.should.be.eql('jerryc');
        done();
      })
  });
});

覆盖率

测试的时候,我们常常关心,是否所有代码都测试到了。

这个指标就叫做"代码覆盖率"(code coverage)。它有四个测量维度。

  • 行覆盖率(line coverage):是否每一行都执行了?
  • 函数覆盖率(function coverage):是否每个函数都调用了?
  • 分支覆盖率(branch coverage):是否每个if代码块都执行了?
  • 语句覆盖率(statement coverage):是否每个语句都执行了?

Istanbul 是 JavaScript 程序的代码覆盖率工具。

安装

$ npm install -g istanbul

覆盖率测试

在编写过以上的测试用例之后,执行命令:

istanbul cover _mocha

就能得到覆盖率:

JerryC% istanbul cover _mocha                                                                                                                                                                


  module
    limit
      ✓ limit should success
    async
      ✓ async
    getContent
      ✓ getContent
    add
      ✓ add

  should
    #Promise
      ✓ should.reject
      ✓ should fulfilled


  6 passing (32ms)


================== Coverage summary ======================
Statements   : 100% ( 10/10 )
Branches     : 100% ( 2/2 )
Functions    : 100% ( 5/5 )
Lines        : 100% ( 10/10 )
==========================================================

这条命令同时还生成了一个 coverage 子目录,其中的 coverage.json 文件包含覆盖率的原始数据,coverage/lcov-report 是可以在浏览器打开的覆盖率报告,其中有详细信息,到底哪些代码没有覆盖到。

覆盖率html

上面命令中,istanbul cover 命令后面跟的是 _mocha 命令,前面的下划线是不能省略的。

因为,mocha 和 _mocha 是两个不同的命令,前者会新建一个进程执行测试,而后者是在当前进程(即 istanbul 所在的进程)执行测试,只有这样, istanbul 才会捕捉到覆盖率数据。其他测试框架也是如此,必须在同一个进程执行测试。

如果要向 mocha 传入参数,可以写成下面的样子。

$ istanbul cover _mocha -- tests/test.sqrt.js -R spec

上面命令中,两根连词线后面的部分,都会被当作参数传入 Mocha 。如果不加那两根连词线,它们就会被当作 istanbul 的参数(参考链接12)。

使用Makefile串起项目

TESTS = test/*.test.js
REPORTER = spec
TIMEOUT = 10000
JSCOVERAGE = ./node_modules/jscover/bin/jscover

test:
    @NODE_ENV=test ./node_modules/mocha/bin/mocha -R $(REPORTER) -t $(TIMEOUT) $(TESTS)

test-cov: lib-cov
    @LIB_COV=1 $(MAKE) test REPORTER=dot
    @LIB_COV=1 $(MAKE) test REPORTER=html-cov > coverage.html

lib-cov:
    @rm -rf ./lib-cov
    @$(JSCOVERAGE) lib lib-cov

.PHONY: test test-cov lib-cov

make test
make test-cov

用项目自身的jscover和mocha,避免版本冲突和混乱

持续集成,Travis-cli

  • Travis-ci
    • 绑定Github帐号
    • 在Github仓库的Admin打开Services hook
    • 打开Travis
    • 每次push将会hook触发执行npm test命令

注意:Travis会将未描述的项目当作Ruby项目。所以需要在根目录下加入.travis.yml文件。内容如下:

language: node_js
node_js:
  - "0.12"

Travis-cli还会对项目颁发标签,

/or /

如果项目通过所有测试,就会build-passing,

如果项目没有通过所有测试,就会build-failing

一些观点

实施单元测试的时候, 如果没有一份经过实践证明的详细规范, 很难掌握测试的 “度”, 范围太小施展不开, 太大又侵犯 “别人的” 地盘. 上帝的归上帝, 凯撒的归凯撒, 给单元测试念念紧箍咒不见得是件坏事, 反而更有利于发挥单元测试的威力, 为代码重构和提高代码质量提供动力.

这份文档来自 Geotechnical, 是一份非常难得的经验准则. 你完全可以以这份准则作为模板, 结合所在团队的经验, 整理出一份内部单元测试准则.

单元测试准则

彩蛋

最后,介绍一个库:faker

他是一个能伪造用户数据的库,包括用户常包含的属性:个人信息、头像、地址等等。

是一个开发初期,模拟用户数据的绝佳好库。

支持Node.js和浏览器端。

生成用户

整理

Nodejs的单元测试工具

  1. 测试框架 mocha
  2. 断言库:should.js、expect.js、chai
  3. 覆盖率:istanbul、jscover、blanket
  4. Mock库:muk
  5. 测试私有方法:rewire
  6. Web测试:supertest
  7. 持续集成:Travis-cli

参考

66 回复

测试上次我写的被喷惨了 先mark慢慢看

基本测试一定要有,更重要的是代码本身的质量,我的习惯是尽量每个函数写得语义清晰,比较关键的函数的旁边都附带针对该函数的测试函数。有两个作用:1)测试;2)给人看,对应函数怎么用。

然并卵。。。建议总结的时候加上测试用例花费整个项目时间比例。

@haozxuan 要不要加上重构时间呢?

@i5ting :| 很久之前看的时候的确跟如获至宝一样,也尝试过将公司项目完善单元测试,但是用例少了起不到作用,多了又没时间。除非是自己的小项目,要不然测试用例的时间要花费整个项目的40%左右,而真正项目是不会给这么多时间的。

有空的时候测试用例还是得加上的~~项目紧,代码变动大的时候就没办法了~

@okoala 有空的时候,基本都是不会再写的

写测试主要是基于降低维护成本考虑的吧,实际开发过程中确实不一定有时间写 TDD BDD,实践起来不容易。

@DevinXian @haozxuan 看对代码质量的要求程度

依赖第三方的项目,每次手动测试都花很多时间,不得不用 karma + jasmine 进行单元测试提高效率

我一般用mocha做一些功能的测试。看大家的回复,其实情况都很类似,项目紧张没时间写,有时间也不愿写。其实,我个人还是蛮赞同写测试的,只是说先写哪些。我们项目服务端开发,不止要写代码,在写代码之前要做需求概要设计,然后是服务端接口打桩,然后是接口文档写到wiki上,然后才是编码,在是自测,最后是前后端联调。最后提测。

项目多的团队,确实没有过多时间去编写单元测试,往往都被需求强奸。但若团队有一两个主打并且长期维护的项目,是很有必要增加单元测试,当看到终端一屏幕的绿色pass,和绿茫茫的覆盖率报告页面,都会对我们维护的项目增加一点信心。我们团队也是刚引入单元测试,深知付出的代价会很大,但是我们肯付出,换回来程序的稳定和可维护,增加程序的信心。

上次前辈提醒,原来我写的全是集成测试。 前辈还说“如果测试写的很头疼,那是代码风格结构不好” 让我自己去悟= = 单元测试不是那么容易写。。。。

mark, 看完这文章,抓紧完善了下单元测试,感觉对自己写的代码更有信心了

写的很赞啊

先赞一个!!! 涉及数据库操作的测试 不知有没有好的实战方案。。。 考虑 每次跑完测试 尽量不对数据库造成影响

@sunshine1988 我们的实现方案是,单元测试,会连接独立为单元测试准备的数据库。里面会有一些基本的初始化数据。而写单元测试的时候,也会插入一些数据,完了之后就删掉。

必须赞一个!一直想尝试学写单元测试,看了此篇,收获颇多。

推荐Jasmine这才是神器

赞,手上的项目要求必须写单元测试,写着写着就感觉写不下去了,单元测试是真不好写啊,而且写测试的时间代价也是蛮高的

有些复杂的业务真的不好测,测试代码太多,逻辑又多变

基本都在工作用到了,但我的感觉是,测试必须得有,因为测试确实能够帮助思维没那么严谨的同学提高代码的质量,能够通过测试发现肉眼无法发现的漏洞,不过,问题的重点还是在于测试应该怎么写,范围如何裁定,应该包含哪些基本case,数据该如何伪造等等,工作中,我按照TDD模式来写的,深坑是在造数据这块,导致我一个接口的测试能达到1000+行,其实,挺浪费时间的,一直在想这个测试到底应该如何优化。

@wfsovereign 在造数据这一块,我也有一个不错的解决方案,自己写了一套基于Sails的数据伪造模块,在数据伪造几行代码就搞定。现在不完善,还没有开源出来,基于的是chancejs,这是一个随机生成指定值的库,挺强大的,你可以参考一下。

每当设计测试的时候依然非常难过。。。特别是从来没做过的东西。。。

@JerryC8080 感觉会发现新世界,哈哈,我会去看看的~

最近在弄一个基于NW的测试代码生成器,有空整理一下弄出来@wfsovereign

不错,像Go自带单元测试,让我也慢慢习惯了写一个测试一个

@backsapce NW?new world?感觉有点厉害啊,还能生成测试代码,期待~

@JerryC8080 那个chancejs我用过了,不过只是简单用了下,确实能方便生成各种数据,感觉还是有些不太智能,后来就搁置了…我觉得应该去看看sails框架自动生成代码的那部分,好厉害的样子0 -

shouldjs的写法真的是太讨人喜欢了。话说私有方法就不需要测试了吧。

讲真,每次都要刷到下面才能看到回复框。有点烦呢 = =

我表示体会到写单测的好处了,建议单测函数职责尽量单一这样单个函数的逻辑简单清晰,不过真要上的话,代码结构一定要理好了!

我之前的公司也是不写测试,但现在的公司会严格要求写包括Unit test 和集成测试,没有是不能够发布的。果然是外企对代码质量要求高。

马克加索尔,谢谢楼主

我们公司让我一个人专门写单元测试。。。表示单元测试不是那么好写的。例如:一个模块调用另外一个模块;模块等待机器服务器的响应。。。。

@sunfeng90 测试不好写呀,这可是门艺术

@semicoyoung 确实,我也感觉到了!

回到顶部