用nodeunit 开始自动化Node.js的单元测试

单元测试 cè shì)

我将使用Node.js的单元测试库nodeunit,来解释有关单元测试自动化的基础知识。

考试概要

在本节中,我们将通过测试实际简单的程序来解释以下测试方法。

    • 同期的な処理のテスト(基本的なテスト)

 

    • 例外がスローされるかどうかの処理のテスト

 

    • 非同期な処理のテスト

 

    setUp, tearDown を使ったテストコードのリファクタリング

安装nodeunit

安装 Nodeunit。

$ sudo npm install -g nodeunit

当安装完成后,尝试执行nodeunit命令,确认能够正常运行。

$ nodeunit -v
0.9.1

如果找不到命令,请自行将PATH添加到nodeunit命令中(由于环境的差异,详细步骤略去)。

建立工作目录

这次的目录/文件结构如下所示。

nodeprj/                // <- ワークディレクトリ
  +--lib/
  | +--divided.js       // <- テスト対象のプログラム
  +--test/
    +--test-divided.js  // <- divided.js をテストするプログラム

请预先创建这些目录和文件。

$ mkdir -p nodeprj
$ cd nodeprj
$ mkdir {lib,test}

我打算以一个使用TDD开发的假设作为例子,来解释如何使用nodeunit来开发一个程序,该程序的功能是将给定的数字除以2,并舍弃小数点后的部分。

同时进行处理的测试

在test/test-divided.js文件中创建一个新文件,并编写以下测试程序。
在编写测试程序时,需要以将方法分配给在模块内被共享的exports对象的形式进行。
test.equal是一个进行断言的方法,在这里需要记录测试的预期结果。
另外,请确保每个测试用例结束时都调用test.done方法。

var divided = require('../lib/divided');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.done();
};

完成程序后,请使用nodeunit命令运行测试。执行nodeunit命令时,请使用nodeunit(包含测试程序的目录)的格式进行执行。由于本次测试程序位于test目录中,因此执行命令为nodeunit test。

~nodeprj$ nodeunit test

module.js:340
    throw err;
          ^
Error: Cannot find module '../lib/divided'
    at Function.Module._resolveFilename (module.js:338:15)
    at Function.Module._load (module.js:280:25)
    ...
    at Module.require (module.js:364:17)

根据结果可知,模块调用失败。根据这个测试结果,首先创建lib/divided.js文件并实现calculate方法。

/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
};
NodeUnite_Intro_0000.png

然后,由于返回的结果是undefined而不是预期的2,所以出现了错误。让我们再次打开divided.js文件,并添加处理步骤。

/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
    return num / 2;
};

我将执行测试。

$ nodeunite test
NodeUnite_Intro_0001.png

我已经成功通过了测试。接下来,我将添加一个测试来检查在计算结果不能整除的情况下是否可以舍去小数位。

var divided = require('../lib/divided');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.done();
};

执行 nodeunit 测试。

$ nodeunit test

test-divided
 calculate

AssertionError: 1.5 == 1
    at Object.equal (/usr/local/lib/node_modules/nodeunit/lib/types.js:83:39)
    at Object.exports.calculate (/opt/nodeprj/test/test-divided.js:5:10)
    ......
    at _concat (/usr/local/lib/node_modules/nodeunit/deps/async.js:512:9)


FAILURES: 1/2 assertions failed (7ms)

根据以上情况,预期会返回1,但实际返回了1.5,因此出现了错误。
根据以上结果作为参考,将在程序中添加截断小数点后位数的处理。

/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
    return Math.floor(num / 2);
};

执行nodeunit测试。

~nodeprj$ nodeunit test

test-divided
 calculate

OK: 2 assertions (5ms)

通过这个实现,小数点后面的部分被舍去的处理已经完成了。

创建用于确定异常的测试(当传递非数字时抛出异常的处理测试/实现)。

当传递给方法参数的值不是数字时,进行抛出异常的测试/实现处理。
如果要进行异常抛出测试,可以将一个抛出异常的方法作为 test.throws 方法的参数来执行。

var divided = require('../lib/divided');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};
$ nodeunit test

test-divided
 calculate

AssertionError: undefined  "Missing expected exception.."
    at _throws (/usr/local/lib/node_modules/nodeunit/lib/assert.js:329:5)
    at assert.throws (/usr/local/lib/node_modules/nodeunit/lib/assert.js:346:11)
    ...
    at iterate (/usr/local/lib/node_modules/nodeunit/deps/async.js:123:13)

AssertionError: undefined  "Missing expected exception.."
    at _throws (/usr/local/lib/node_modules/nodeunit/lib/assert.js:329:5)
    at assert.throws (/usr/local/lib/node_modules/nodeunit/lib/assert.js:346:11)
    ...

FAILURES: 4/6 assertions failed (26ms)

由于未引发指定的异常,测试失败。
在lib/divided.js中添加引发异常的处理,以处理除数非数字值的情况。

/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
    if (typeof num !== 'number') {
        throw new Error('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};

当添加了引发异常的处理后,执行测试。

$ nodeunit test

test-divided
 calculate

OK: 6 assertions (5ms)

成功了。

进行异步处理的测试(从标准输入接收值并将其输出到控制台的处理的测试/实现)

我們將測試一個從標準輸入非同步讀取值的程式。
這次我們要新增一個測試案例,試著從標準輸入接收值並將計算結果輸出到控制台的程式。
首先,讓我們像之前一樣新增一個測試案例”read a number”。
我們假設這個處理是從標準輸入傳入數字、計算並將結果使用console.log輸出到標準輸出。

var divided = require('../lib/divided');
var events = require('events');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};

exports['read a number'] = function(test) {
    var ev = new events.EventEmitter();

    process.openStdin = function() { return ev; };
    process.exit = test.done;
    var _console_log = console.log;
    console.log = function(str) {
        console.log = _console_log;
        test.equal(str, 'result: 4');
    };

    divided.read();
    ev.emit('data', '8');
};

上述的示例代码通过使用EventEmitter来触发data事件,以模拟标准输入。divided变量假设在后面的内容中被赋予了一个从标准输入获得的数字8并将其除以2,最终将结果”result: 4″输出到标准输出。

因为要将输出发送到标准输出中,所以我们使用console.log进行输出。我们通过用stub方法覆盖console.log方法,并在stub内部断言console.log的参数是否与预期值相符来实现这一功能。此外,由于我们使用stub来重写了console.log的输出,所以在进行测试后,我们需要将console.log恢复到原始状态。因此,我们先将console.log方法的引用存储在”_console_log”变量中,并在stub内部将其恢复原始状态。

    var _console_log = console.log;
    console.log = function(str) {
        console.log = _console_log;
        ...

那么让我们执行一下测试吧。

~nodeprj$ nodeunit test

test-divided
✔ calculate
TypeError: Object #<Object> has no method 'read'
    at Object.exports.read a number (/path/to/nodeprj/test/test-divided.js:25:13)
    at Object.<anonymous> (/usr/local/lib/node_modules/nodeunit/lib/core.js:236:16)
    at /usr/local/lib/node_modules/nodeunit/lib/core.js:236:16
    ......

由于lib/divided.js没有定义read方法,测试失败了。
让我们在lib/divided.js中添加read方法。

/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
    if (typeof num !== 'number') {
        throw new Error('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};

/** 標準入力から値を受け取り、計算結果を返すメソッド */
exports.read = function() {
    var stdin = process.openStdin();
    stdin.on('data', function(chunk) {
        var param = parseFloat(chunk);
        var result = exports.calculate(param);
        console.log('result: ' + result);
        process.exit();
    });
};

我将进行测试。

$ nodeunit test

test-divided
 calculate
 read a number

OK: 7 assertions (6ms)

我成功地通过了考试。

然而,这次测试并不完善。这次刚好执行了所有7个断言(其中有6个是“calculate”,1个是“read a number”),并且结果是OK。但是,“read a number”这个测试是以异步方式执行的,因此在某些情况下,可能只执行了6个测试就结束了(在本次执行环境中无法复现)。而且,这样的情况下测试结果也会变成OK。如果遇到没有以异步方式执行的测试用例失败,就有可能无法在测试中发现故障。

为了解决这个问题,可以使用test.expect方法,在测试用例中预先通知”read a number 测试用例有一个断言!”。要通知测试用例中有多少个断言,可以使用expect方法。

那么,让我们来实际修改测试程序。
下面的测试程序在测试用例开头使用test.expect方法预先声明有一个断言,并且可以告知nodeunit当前测试用例中有多少个断言。

var divided = require('../lib/divided');
var events = require('events');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};

exports['read a number'] = function(test) {
    test.expect(1);
    var ev = new events.EventEmitter();

    process.openStdin = function() { return ev; };
    process.exit = test.done;
    var _console_log = console.log;
    console.log = function(str) {
        console.log = _console_log;
        test.equal(str, 'result: 4');
    };

    divided.read();
    ev.emit('data', '8');
};

让我们运行 nodeunit 测试。

$ nodeunit test

test-divided
✔ calculate
✔ read a number

OK: 7 assertions (19ms)

如果显示为“OK: 7 assertions”,那么可以确认已经成功执行了7个断言,并且一切正常。

如果assertion的数量少于test.expect指定的数量

让我们假设使用test.expect方法来通知这个测试案例有一个断言,但假如使用test.expect指定的数量与实际断言数量不同,我们来模拟确认一下会发生什么。

让我们尝试将上述测试用例中的test.expect和test.equal注释掉,并执行测试。

var divided = require('../lib/divided');
var events = require('events');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};

exports['read a number'] = function(test) {
    // test.expect(1);
    var ev = new events.EventEmitter();

    process.openStdin = function() { return ev; };
    process.exit = test.done;
    var _console_log = console.log;
    console.log = function(str) {
        console.log = _console_log;
        // test.equal(str, 'result: 4');
    };

    divided.read();
    ev.emit('data', '8');
};
$ nodeunit test

test-divided
✔ calculate
✔ read a number

OK: 6 assertions (21ms)

然后,执行了calculate和read a number的测试用例,总共执行了6个断言,并且测试结果返回了OK。然而,实际上这个测试中的read a number测试用例没有包含任何断言,但测试却通过了。

让我们继续保留text.expect的注释,并取消test.equal的注释来运行测试,看看会发生什么样的行为。

......
exports['read a number'] = function(test) {
    // test.expect(1);
    var ev = new events.EventEmitter();
    ......
    console.log = function(str) {
        console.log = _console_log;
        test.equal(str, 'result: 4');    // <-- コメントアウトを解除
    };
    ......
};
$ nodeunit test

test-divided
✔ calculate
✔ read a number

OK: 7 assertions (6ms)

然后,执行了7个断言并且结果为OK。
无论test.equal的断言是否执行,这与测试是否通过无关,即使该项由于异步处理未被执行,表面上看测试也仍然是通过的,这带来了风险。
为了避免这种风险,我们使用test.expect,在预期的断言数量未执行时将其标记为NG。

让我们取消 test.expect(1); 的注释来确认它,然后将 test.equals 的部分注释掉并运行测试用例。

......
exports['read a number'] = function(test) {
    test.expect(1);          // <-- コメントアウトを解除
    var ev = new events.EventEmitter();
    ......
    console.log = function(str) {
        console.log = _console_log;
        // test.equal(str, 'result: 4');    // <-- コメントアウト
    };
    ......
};
$ nodeunit test

test-divided
✔ calculate
✖ read a number

Error: Expected 1 assertions, 0 ran
    at process.test.done [as exit] (/usr/local/lib/node_modules/nodeunit/lib/types.js:121:25)
    at EventEmitter.<anonymous> (/opt/nodeprj/lib/divided.js:16:17)
    ......

FAILURES: 1/7 assertions failed (8ms)
✖ read a number
......

在这种情况下,尽管预期会执行1个断言,却会出现执行0个断言的错误。通过这种方式,即使由于异步处理取消了断言的执行,也能将测试用例检测为NG。

关于在多个测试用例中共享的变量

接下来,我想要说明一个关于添加多个测试用例时需要注意的共享数据问题。
如果在创建测试用例时没有意识到多个测试用例之间的共享数据,可能会导致意外的行为。
为了确认这一点,我想要添加一个这样的示例,即当异步方法从标准输入接收到非数字值时抛出异常的情况。

var divided = require('../lib/divided');
var events = require('events');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};

exports['read a value other than a number'] = function(test) {
    test.expect(1);
    var ev = new events.EventEmitter();

    process.openStdin = function() { return ev; };
    process.exit = test.done;
    divided.calculate = function() {
        throw new Error('Expected a number');
    };
    var _console_log = console.log;
    console.log = function(str) {
        console.log = _console_log;
        test.equal(str, 'Error: Expected a number');
    };
    divided.read();
    ev.emit('data', 'asdf');
};

exports['read a number'] = function(test) {
    test.expect(1);
    var ev = new events.EventEmitter();

    process.openStdin = function() { return ev; };
    process.exit = test.done;
    var _console_log = console.log;
    console.log = function(str) {
        console.log = _console_log;
        test.equal(str, 'result: 4');
    };

    divided.read();
    ev.emit('data', '8');
};

我們需要在測試目標程式中添加拋出異常的處理,然後執行測試。

/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
    if (typeof num !== 'number') {
        throw new Error('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};

/** 標準入力から値を受け取り、計算結果を返すメソッド */
exports.read = function() {
    var stdin = process.openStdin();

    stdin.on('data', function(chunk) {
        var param = parseFloat(chunk);
        try {
            var result = exports.calculate(param);
            console.log('result: ' + result);
        } catch(e) {
            console.log(e);
        }
        process.exit();
    });
};
$ nodeunit test

test-divided
✔ calculate
✔ read a value other than a number
✖ read a number

AssertionError: [Error: Expected a number] == 'result: 4'
    at Object.equal (/usr/local/lib/node_modules/nodeunit/lib/types.js:83:39)
    at Console.console.log (/opt/nodeprj/test/test-divided.js:41:14)
    at EventEmitter.<anonymous> (/opt/nodeprj/lib/divided.js:19:21)
    ...


FAILURES: 1/8 assertions failed (9ms)
✖ read a number
...

然后,计算机对于除数字以外的值的测试用例是成功的,但是对于数字的测试用例却失败了。
失败的原因是因为在使用EventEmitter将数字传递给标准输入时,抛出了一个[Error: Expected a number]的异常。

测试用例”read a number”是之前成功的测试用例,但是现在通过在”read a number”之前添加新的测试用例”read a value other than a number”,这个测试用例变得失败了。
失败的原因是因为在”read a value other than a number”测试用例中有部分存根设置,而这个存根设置会在”read a number”测试用例执行时保持不变。
在这里,我们需要在每个异步测试用例执行之后,在调用process.exit时执行将存根恢复到原始状态的操作,以确保恢复设置的变量。

var divided = require('../lib/divided');
var events = require('events');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};

exports['read a value other than a number'] = function(test) {
    test.expect(1);
    var ev = new events.EventEmitter();

    var _process_openStdin  = process.openStdin;
    process.openStdin       = function() { return ev; };
    var _process_exit       = process.exit;
    process.exit = function() {
        // テストケース終了時にstub を元に戻す
        process.openStdin   = _process_openStdin;
        process.exit        = _process_exit;
        divided.calculate   = _divided_calculate;
        console.log         = _console_log;
        test.done();
    };

    var _divided_calculate = divided.calculate;
    divided.calculate = function() {
        throw new Error('Expected a number');
    };

    var _console_log = console.log;
    console.log = function(str) {
        test.equal(str, 'Error: Expected a number');
    };

    divided.read();
    ev.emit('data', 'asdf');
};

exports['read a number'] = function(test) {
    test.expect(1);
    var ev = new events.EventEmitter();

    var _process_openStdin  = process.openStdin;
    process.openStdin       = function() { return ev; };
    var _process_exit       = process.exit;
    process.exit = function() {
        // テストケース終了時にstub を元に戻す
        process.openStdin   = _process_openStdin;
        process.exit        = _process_exit;
        console.log         = _console_log;
        test.done();
    }

    var _console_log = console.log;
    console.log = function(str) {
        test.equal(str, 'result: 4');
    };

    divided.read();
    ev.emit('data', '8');
};

当你修改了测试程序后,执行nodeunit测试。

$ nodeunit test

test-divided
 calculate
 read a value other than a number
 read a number

OK: 8 assertions (7ms)

所有测试用例都返回成功OK。
此外,从上述测试用例执行结果可以看出,即使测试用例中存在异步处理,在每个测试用例之间仍然是按顺序执行的。
为了保证测试用例之间是顺序执行的,在每次运行测试用例之前,通过初始化测试用例的值,可以确保下一个测试用例不会受到影响,并可以在独立的stub环境中运行测试。
如果测试用例之间被并行执行,可能会出现在恢复stub前执行了读取数字之前的读取非数字的测试用例,从而导致无法正确进行测试。

在测试用例中实施setUp和tearDown

在之前创建的测试用例中,我们在每个测试用例的开始和结束时直接在测试用例的内部编写了所需的处理(例如创建存根等)。但是,通过使用setUp和tearDown方法,我们可以将其集中到一个地方进行共享。

setUp方法是在每个测试用例运行之前执行的方法,而tearDown方法是在每个测试用例结束时执行的方法。

在使用setUp和tearDown之前,您需要将nodeunit库的路径加入到环境中。
为了能够使用nodeunit库,请在~/.node_libraries目录下创建nodeunit库的副本/符号链接,或者将NODE_PATH环境变量设置为包含nodeunit库的目录,请提前进行设置。

那么,我们尝试使用setUp和tearDown方法来创建测试程序。
要实现setUp和tearDown方法,请在开头定义require(‘nodeunit’)并导入nodeunit。

var divided     = require('../lib/divided');
var events      = require('events');
var nodeunit    = require('nodeunit');

exports['calculate'] = function(test) {
    test.equal(divided.calculate(4), 2);
    test.equal(divided.calculate(3), 1);
    test.throws(function() { divided.calculate(); });
    test.throws(function() { divided.calculate(null); });
    test.throws(function() { divided.calculate("abc"); });
    test.throws(function() { divided.calculate([]); });
    test.done();
};

exports['read'] = nodeunit.testCase({
    setUp: function(callback) {
        // 各メソッドの参照を保持しておく
        this._process_openStdin = process.openStdin;
        this._console_log       = console.log;
        this._divided_calculate = divided.calculate;
        this._process_exit      = process.exit;

        var ev = this.ev = new events.EventEmitter();
        process.openStdin = function() { return ev; };
        callback();
    },
    tearDown: function (callback) {
        // 全てのオーバーライドしたメソッドを元に戻す
        process.opensStdin  = this._process_openStdin;
        process.exit        = this._process_exit;
        divided.calculate   = this._divided_calculate;
        console.log         = this._console_log;
        callback();
    },
    // 数値以外の数値が渡された時のテスト
    'a value other than a number': function(test) {
        test.expect(1);

        process.exit = test.done;
        divided.calculate = function() {
            throw new Error('Expected a number');
        };
        console.log = function(str) {
            test.equal(str, 'Error: Expected a number');
        };
        divided.read();
        this.ev.emit('data', 'abc');
    },
    // 数値が渡された時の正常系テスト
    'a number': function(test) {
        test.expect(1);

        process.exit = test.done;
        console.log = function(str) {
            test.equal(str, 'result: 4');
        };
        divided.read();
        this.ev.emit('data', '8');
    }
});

创建测试程序后,请尝试运行它。

$ NODE_PATH=/usr/local/lib/node_modules nodeunit test

test-divided
✔ calculate
✔ read - a value other than a number
✔ read - a number

OK: 8 assertions (9ms)

如果能通过上述的测试用例,就算是成功了。

请参考

Unit testing in node.js

http://caolan.org/posts/unit_testing_in_node_js/

[node.js] Getting started with nodeunit

http://www.ipreferjim.com/2011/08/node-js-getting-started-with-nodeunit/

Nodeunit

https://github.com/caolan/nodeunit

广告
将在 10 秒后关闭
bannerAds