NodeJS中使用单元测试


因为下了决心要实践TDD,所以无论使用什么语言,首先总是要确定一个单元测试框架。NodeJS,或者说Javascript,并不像Python、Smalltalk等一开始就有比较成熟的测试框架,因此Javascript中的测试框架选择可谓是百花齐放、百家争鸣,看得我是眼花缭乱。竞争是好事,一则让大家都有动力不断完善自己,二则也给不同口味的人提供了各自喜欢的菜色。对于刚接触的人来说,选择太多,反而是一种困扰。这个Wiki页提供了一个Javascript测试框架的较详细列表。

主流测试框架

“测试框架”这个词在Javascript这边,似乎又是一个让人疑惑的名词。根据NodeJS的这个页面,排名前几的几个模块分别是:nodeunit、chai、vows和jasmine-node,全然没有Mocha的身影。而到网上查询NodeJS的单元测试框架,则基本都会提到Mocha。渐渐深入后才发现,chai、should等都是提供基本的断言(Assertion)工具,而Mocha能提高更高层次的功能。

下面列举几个在我搜索过程中出镜率计较高的测试框架:

Nodeunit

看名字就比较官方,支持异步调用和同步调用,直接浏览器和命令行两种调用方式。使用简单,文档也比较简单。

Mocha

官网上说是用于NodeJS的,同样支持异步和同步调用,直接浏览器和命令行调用方式;自己不带断言库,可以使用多种第三方断言库(如chai、should等)。直接多种风格的用例写法,包括TDD、BDD、QUnit等。

QUnit

没研究。

Jasmine

BDD风格的测试框架。分客户端和服务端两种,没怎么研究。

Vows

没研究。

怎样抉择

很多人都有这样的疑问,但不是每个人都会到论坛上问,而且问了也不一定有认真的回答。万幸,这篇帖子里,人家精确地问了问题,又有好心人详细地回答了:How do I decide what testing framework to use?

这篇帖子中推荐的是QUnit,我没去尝试,因为我已经有选择了。帖子中提到的几点都是很好的考虑因素,但我觉得还不是很详细,下面补充几点我自己具化的理解:

  1. 是否支持命令行和浏览器两种测试载体。有很多代码都是不依赖浏览器的(对于NodeJS尤其如此),这种情况下使用命令行运行测试更方便。但如果只支持命令行方式,则意味着还必须为前端代码另外找一个测试框架
  2. 支持什么样风格的测试用例。比如Nodeunit只支持TDD风格的,而Mocha支持TDD、BDD、QUnit等许多风格。
  3. 支持的报告格式。刚开始时报告是给人看的,所以格式上有些差异没有什么影响,毕竟人能接受就可以了;过了一段时间,测试需要自动化,这时报告更多是给代码看的,那就完全不是一回事了。所以,如果不是想自己写报告解析器的话,挑选一个有自己想要的报告格式(如xUnit格式的)的测试框架吧

我的尝试

我在开始写测试用例的时候,并没有做好预习功课,只是看了有个叫Nodeunit的东西,看名字觉得比较官方(可见产品名称在小白用户的推广中,起到了非常重要的作用啊!),就使用了。写好几个测试用例后,才回过头来仔细调研了一番,发现大家还是比较推荐Mocha(产品口碑的重要性!),所以又改用Mocha了。从Nodeunit转到Mocha比较简单,毕竟测试代码基本是一致的,就是写法上有所差别。

Nodeunit

Nodeunit没有单独的官网,项目托管在GitHub中,地址是:https://github.com/caolan/nodeunit。除了README.md外,就没有其他详细文档了。源码中examples目录提供了少量的示例代码可以参考。

  • 安装

    有了npm,安装都非常简单:

    npm install -g nodeunit
    
  • 使用

    Nodeunit虽然很简单,但也有各种写法,包括suite、group等,目前我需要的测试用例还没用到这些,所以没有去研究。我的用法比较简单:

    // test/test.js
    module.exports = {
      setUp: function (callback) {
        callback();
      },
      tearDown: function (callback) {
        // clean up
        callback();
      },
      test1: function (test) {
        // the test function body here...
        test.done();
      }
    };
    

    然后在终端执行:

    # run the particular test file
    nodeunit test/test.js
    # or run all tests that in test/ folder
    nodeunit test
    

    其中,如果不需要做提前设置和清理动作,则setUp和tearDown可以不写。

  • 几点注意/技巧
    • 告诉Nodeunit一个方法真正完成了

      由于NodeJS是一个异步框架,很多调用都是异步调用,通过回调函数(Callback)来更新状态或返回结果,当一个方法(如setUp)中含有异步调用时,怎样保证Nodeunit等待这个方法真正结束后才继续下一个测试呢?

      对于setUp和tearDown,要利用其传入参数;对于测试方法,使用done方法:

      module.exports = {
        setUp: function (callback) {
          setTimeout(function() {
            // Nodeunit will continue to call other functions after we call callback()
            callback();
          }, 1000);
          // Nodeunit won't continue even the setUp function has returned
        },
        tearDown: function (callback) {
          // clean up, we must call callback() even if we do nothing
          callback();
        },
        testSomething: function (test) {
          setTimeout(function() {
            console.log('we call test.done() to notify Nodeunit that this test really finished');
            test.done();
          }, 100);
        }
      };
      

      在setUp和tearDown中必须要调用callback();在测试函数中必须调用test.done(),否则运行nodeunit跑测试时会提示出错(因为在异步调用下,Nodeunit无法知道我们的函数是否真正运行完了)。

    • expect

      Nodeunit提供了名为expect的方法,我们可以在测试函数开始时调用expect来指定这个测试中期望跑几个断言。这个方法在异步测试时特别有用。

      exports.testSomething = function(test){
        test.expect(1);
        setTimeout(function() {
          test.ok(true, "this assertion should pass");
          test.done();
        }, 1000);
      };
      

      expect告诉Nodeunit我们期望在这个测试中会进行多少次断言操作,因此实际执行的断言数一定要与expect中设置的数量相等,否则测试函数中肯定存在某些错误,因此Nodeunit会认为这个测试不通过。

    • 超时

      Nodeunit默认是没有超时的,如果在异步函数中忘记调用callback(对于setUp和tearDown)或test.done(对于其他测试函数),则会出现无限期阻塞。网上看应该有方法可以解决,不过我没去研究。

Mocha

前面提到我改用Mocha是因为推荐的人多,这只是一个原因;另一个重要原因,是我在查找资料过程中发现了BDD这个东西(Mocha允许使用BDD,另外还有其他用例写法;而另一个测试框架Jasmine则完全是BDD的)。BDD其实也算TDD,是TDD的一种特化变种,是Dan North为了解决在教授TDD过程中遇到的一些问题而开发出来的,基本理念是在测试代码中用人类语言描述用例,从而使得测试代码更加易读,也更容易将用例转化为测试代码,从而使得最终验收也很简单。很诱人,不是吗?

  • 安装

    比起Nodeunit,Mocha稍微复杂点,因为还需要先挑选好断言库。除了Nodeunit自动的assert,还可以使用chai、should等(参考这个:Assertions)。如果不是使用自带的assert,还需要额外安装其他断言库:

    # I use chai as my assertion library
    npm install -g mocha chai
    
  • 示例程序

    因为想要用BDD方式,所以用例的写法比较不同。这篇文章比较详细地介绍了基本的使用方法。

    // food.js
    var Food = {
      FISH: 'fish',
      SHIT: 'shit'
    };
    
    exports.Food = Food;
    
    // cat.js
    var Food = require('./food.js').Food;
    
    (function(exports) {
      'use strict';
    
      function Cat(name) {
        this.name = name || 'General';
      }
    
      exports.Cat = Cat;
    
      Cat.prototype = {
        getName: function() {
          return this.name;
        },
        eat: function(food_type) {
          if (food_type === Food.FISH)
            return true;
          if (food_type === Food.SHIT)
            return false;
        }
      };
    })(this);
    
    // cat_test.js
    var Cat = require('../src/cat.js').Cat;
    var Food = require('../src/food.js').Food;
    
    var expect = require('chai').expect;
    
    describe('Cat', function() {
      before(function(done) {
        // code that runs only once before all tests
        console.log('before');
        this.cat = new Cat('Kinka');
        done();
      });
    
      after(function(done) {
        // code that runs only once after all tests
        console.log('after');
        done();
      });
    
      beforeEach(function(done) {
        // code that runs every time before a test begin
        console.log('beforeEach');
        done();
      });
    
      afterEach(function(done) {
        // code that runs every time after a test begin
        console.log('afterEach');
        done();
      });
    
      describe('constructor', function() {
        before(function() {
          console.log('constructor::before');
        });
    
        after(function() {
          console.log('constructor::after');
        });
        beforeEach(function() {
          console.log('constructor::beforeEach');
        });
    
        afterEach(function() {
          console.log('constructor::afterEach');
        });
    
        it('should be named by the name parameter', function() {
          var aCat = new Cat('Rex');
          expect(aCat.getName()).to.equal('Rex');
        });
    
        it('should use default name: General', function() {
          var aCat = new Cat();
          expect(aCat.getName()).to.equal('General');
        });
      });
    
      describe('.eat()', function() {
        describe('when given fish', function() {
          it('should eat the fish', function() {
            expect(this.cat.eat(Food.FISH)).to.equal(true);
          });
        });
    
        describe('when given shit', function() {
          it('should not eat the shit', function() {
            expect(this.cat.eat(Food.SHIT)).to.equal(false);
          });
        });
      });
    });
    
  • 示例解析

    创建一个目录mocha_example,目录中分别建src和test子目录,将上面的代码复制后分别保存到src/food.js、src/cat.js和test/cat_test.js中,终端进入mocha_example目录,执行测试:

    mocha test
    

    我执行的结果是:

    before
    constructor::before
    beforeEach
    constructor::beforeEach
    ․constructor::afterEach
    afterEach
    beforeEach
    constructor::beforeEach
    ․constructor::afterEach
    afterEach
    constructor::after
    beforeEach
    ․afterEach
    beforeEach
    ․afterEach
    after
    
    
      4 passing (6ms)
    
    • before/after和beforeEach/afterEach的使用

      上面的示例特别演示了嵌套的before/after和beforeEach/afterEach。从上面的执行结果可以看出:

      1. 对于每个定义在describe中的before/after,执行这个describe时都执行一次,而无论其中有多少个嵌套的describe或者item(it)
      2. 嵌套的describe中如果也定义了before/after,则它们都会被执行,同样是在本describe开始和结束时被执行
      3. 对于在describe中定义的beforeEach/afterEach,则这个describe中定义的所有item(包括其嵌套的describe中定义的item)执行前后都会被调用
    • done函数的使用

      任何一个函数都可以传入一个参数(上例中命名为done),这个参数是一个回调函数,用来告诉Mocha该函数已经执行结束了。在上例中外层的before等传入了done参数,但实际上可以不传的(后面的before等后就没传);但是一旦有传入参数,则一定要调用done函数,否则Mocha会一直在等待(直到超时,默认超时是2秒,可以在执行时Mocha时用–timeout参数变更)。done这个回调函数主要是在函数中含有异步代码时使用,相当于Nodeunit的callback以及test.done调用。

    • 我认为比较好的编写方法

      上例中演示了两种describe写法,我认为是比较好的方式。第一种是constructor的写法,对于构造函数,传入不同参数有不同的处理方式;第二种是对eat函数的测试代码的写法,对于每一个测试,都单独写一个子的describe,子describe中写前置条件(“when …”),在测试item中写期望结果(“should …”),这种写法使得测试用例非常容易读,也能够精准地对应用例(Use Case)。

Author: Rex Shen

Created: 2014-07-17 Thu 14:46

Emacs 24.3.1 (Org mode 8.2.7b)

Validate

Leave a comment

Your email address will not be published. Required fields are marked *