Node.js
mocha
chai

Mocha, Chai, Sinon を使ったNode.js のテスト自動化 実践編

More than 1 year has passed since last update.

mocha で始めるnode のテスト自動化

前回nodeunit を使ってテストの自動化を行いましたが、今回はMocha を使ってやってみます。
テストするプログラムは前回と同様、与えられた数値を2 で割って小数点以下を切り捨てた値を計算するプログラムをBDD・テストファーストで作成していくプロセスを例に解説していきます。

環境構築

実施環境

今回は以下の環境で手順を実施しました。

  • システム構成
    OS Ubuntu 15.10
    Node v0.10.25
    mocha 2.4.5
    mocha-sinon 1.1.5
    chai 3.5.0

ディレクトリ構成は以下のようにします。

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

予めディレクトリを作成しておきます。

ディレクトリの作成
$ mkdir -p nodeprj
$ cd nodeprj
$ mkdir {lib,test}

パッケージのインストール

mocha 及びそれに関するパッケージをインストールします。

installmocha
$ sudo npm install -g mocha
$ sudo npm install -g chai
$ sudo npm install -g mocha-sinon

インストール後、バージョンを確認します。

バージョンの確認
$ mocha --version
2.4.5

NODE_PATH の設定

アサーションライブラリchai を使用するためにNODE_PATH 環境変数を予め設定しておくようにします。

$ export NODE_PATH=/usr/local/lib/node_modules

上記のコマンドを~/.bashrc ファイルなどに予め記載しておいてください。
代替方法として、~/.node_libraries シンボリックリンクを作成し、/usr/local/lib/node_modules ディレクトリにリンクさせることでも対応可能です。

テストの実施

テストの概要

本セクションでは、次のようなテスト方法について、実際の簡単なプログラムをテストすることで説明していきます。

  • 同期的な処理のテスト(基本的なテスト)
  • 例外がスローされるかどうかの処理のテスト
  • 非同期な処理のテスト
  • hook を使ったテストコードのリファクタリング

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

test/test-divided.js ファイルを新規に作成し、以下のプログラムを作成します。

test/test-divided.js
var chai = require('chai')
        , should = chai.should();

describe('divided', function() {
    describe('#calculate', function() {
        it('should return 2 when the value is 4', function() {
            divided.calculate(4).should.equal(2);
        });
    });
});

テストプログラムを作成したらmocha コマンドを実行してテストをしてみましょう。

$ mocha 

module.js:340
    throw err;
          ^
Error: Cannot find module '../lib/divided'
    at Function.Module._resolveFilename (module.js:338:15)
    ......
    at node.js:902:3

すると上記のように../lib/divided モジュールが見つからないため、テストがエラーとなって終了しました。
それではlib/divided.js ファイルを作成し、calculate メソッドを実装してみましょう。

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

上記のようにファイルにコードを記載したら、もう一度mocha コマンドを実行してみます。

mocha
$ mocha

NodeUnitMocha_0000.png

すると、想定した結果が2 であるのに対し、undefined が返ってきているためエラーとなっています。 もう一度divided.js ファイルを開き処理を追加してみましょう。

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

上記のコードを作成したらmocha コマンドを再度実行してみます。

mocha
$ mocha

NodeUnitMocha_0001.png

テストに成功しました。
それでは次のテストとして、計算結果が割り切れなかった場合に小数点以下を切り捨てられうかどうかのテストを追加してみます。

test/test-divided.js
var chai = require('chai')
        , should = chai.should();
var divided = require('../lib/divided');

describe('divided', function() {
    describe('#calculate', function() {
        it('should return 2 when the value is 4', function() {
            divided.calculate(4).should.equal(2);
        });
        it('should return 1 when the value is 3', function() {
            divided.calculate(3).should.equal(1);
        });
    });
});
mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      1) should return 1 when the value is 3


  1 passing (9ms)
  1 failing

  1) divided #calculate should return 1 when the value is 3:

      AssertionError: expected 1.5 to equal 1
      + expected - actual

      -1.5
      +1

      at Context.<anonymous> (test/test-divided.js:11:41)

すると上記のように、1 が返ることを想定していたところ、1.5 が返ってきたためエラーとなっています。
上記結果を参考に、プログラムに小数点以下を切り捨てる処理を追加します。

lib/divided.js
/** This is a function return a half of parameter, and round it to zero decimal places. */
exports.calculate = function(num) {
    return Math.floor(num / 2);
};

mocha を実行します。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3


  2 passing (6ms)

これで小数点以下を切り捨てる処理の実装は完了です。

should 記法とexpect 記法

chai ライブラリを使用することで、アサーション処理をshould で記載する方法とexpect で記載する方法があります。

should記法とexpect記法
var chai = require('chai')
        , should = chai.should()
        , expect = chai.expect;
// ......
divided.calculate(4).should.equal(2);        // should による記法
expect(divided.calculate(4)).to.equal(2);    // expect による記法

例外を判定するテストを作成する(数値じゃないものが渡された時に例外を投げる処理のテスト)

数値以外の値がメソッドの引数に渡されたら例外を投げるメソッドのテストケースを作成します。
例外がスローされるかどうかのテストケースを実装する場合はアサーションライブラリChai の構文should.Throw を使用します。

mocha
var chai = require('chai')
        , should = chai.should();
var divided = require('../lib/divided');

describe('divided', function() {
    describe('#calculate', function() {
        it('should return 2 when the value is 4', function() {
            divided.calculate(4).should.equal(2);
        });
        it('should return 1 when the value is 3', function() {
            divided.calculate(3).should.equal(1);
        });
        it('should throw exceptions when the value are other than numbers', function() {
            (divided.calculate).should.throw(Error);
            (function() {divided.calculate(null)}).should.throw(Error, 'Type of numeric is expected.');
            (function() {divided.calculate("abc")}).should.throw(Error, / numeric /);
            (function() {divided.calculate([])}).should.throw(Error, /^Type of numeric /);

            //// expect-style sentences are below.
            // var expect = chai.expect;
            // expect(divided.calculate).to.throw(Error);
            // expect(function() {divided.calculate(null)}).to.throw(Error, 'Type of numeric is expected.');
            // expect(function() {divided.calculate("abc")}).to.throw(Error, / numeric /);
            // expect(function() {divided.calculate([])}).to.throw(Error, /^Type of numeric /);
        });
    });
});

ここで注意したいのは、should.throw 内部ではtru/catch を使って例外の判定を行っているので、実行可能なメソッドを渡すようにしてください。
divided.calculate().should.throw(Error); と書いても想定したとおりにテストが行われないので注意してください。

それでは準備ができたらテストを実行します。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      1) should throw exceptions when the value are other than numbers


  2 passing (9ms)
  1 failing

  1) divided #calculate should throw exceptions when the value are other than numbers:
     AssertionError: expected [Function] to throw Error
      at Context.<anonymous> (test/test-divided.js:14:45)

想定した例外がスローされていないためテストが失敗しています。
lib/divided.js に数値以外の値が指定されたら例外をスローする処理を追加します。

lib/divided.js
/** This is a function return a half of parameter, and round it to zero decimal places. */
exports.calculate = function(num) {
    if (typeof num !== 'number') {
        throw new Error('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};

テストを再実行します。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      ✓ should throw exceptions when the value are other than numbers


  3 passing (7ms)

成功しました。

非同期処理のテストをする(標準入力から値を受け取ってコンソールに出力すること)

非同期で標準入力から値を読み込むプログラムをテストします。
今回は標準入力から値を受け取り、計算結果をconsole.log で結果をコンソールに出力するプログラムのテストケースを追記します。

test/test-divided.js
var chai = require('chai')
        , should = chai.should();
var divided = require('../lib/divided');
var EventEmitter = require('events').EventEmitter;
require('mocha-sinon');

describe('divided', function() {
    describe('#calculate', function() {
        it('should return 2 when the value is 4', function() {
            divided.calculate(4).should.equal(2);
        });
        it('should return 1 when the value is 3', function() {
            divided.calculate(3).should.equal(1);
        });
        it('should throw exceptions when the value are other than numbers', function() {
            (divided.calculate).should.throw(Error);
            (function() {divided.calculate(null)}).should.throw(Error, 'Type of numeric is expected.');
            (function() {divided.calculate("abc")}).should.throw(Error, / numeric /);
            (function() {divided.calculate([])}).should.throw(Error, /^Type of numeric /);

            //// expect-style sentences are below.
            // var expect = chai.expect;
            // expect(divided.calculate).to.throw(Error);
            // expect(function() {divided.calculate(null)}).to.throw(Error, 'Type of numeric is expected.');
            // expect(function() {divided.calculate("abc")}).to.throw(Error, / numeric /);
            // expect(function() {divided.calculate([])}).to.throw(Error, /^Type of numeric /);
        });
    });

    describe('#read', function() {
        it('should print "result: 4" when the value is 8 that given from the stdin', function(done) {
            var ev = new EventEmitter();
            var _console_log = console.log;
            this.sinon.stub(console, 'log');

            process.openStdin = this.sinon.stub().returns(ev);
            divided.read();
            ev.emit('data', '8');

            console.log.calledOnce.should.be.true;
            console.log.calledWith('result: 4').should.be.true;

            //// expect-style sentences are below.
            //var expect = chai.expect;
            //expect(console.log.calledOnce).to.be.true;
            //expect(console.log.calledWith('result: 4')).to.be.true;

            console.log = _console_log;
            done();
        });
    });
});

上記のサンプルコードでは、EventEmitter を使用してdata イベントを発生させることにより、標準入力をエミュレートしています。
divided は、この後記載しますが、標準入力から与えられた8 を2 で割って標準出力にresult: 4 と出力されることを想定しています。
また、mocha-sinon を使用し、stub を定義し、console.log メソッドが呼ばれたかどうかをチェックするようにしています。
stub (console.log) が呼ばれたかどうかをチェックするにはcalledOnce の値を確認し、どのような引数を使って呼ばれたかを確認するにはcalledWith('string') を使用することで確認することができます。

それではテストを実行してみましょう。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      ✓ should throw exceptions when the value are other than numbers
    #read


  3 passing (14ms)
  1 failing

  1) divided #read should print "result: 4" when the value is 8 that given from the stdin:
     TypeError: Object #<Object> has no method 'read'
      at Context.<anonymous> (test/test-divided.js:37:21)

lib/divided.jsread メソッドが定義されていないためテストに失敗しました。lib/divided.jsread メソッドを追加していきましょう。

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);
};

/** 標準入力から値を受け取り、計算結果を返すメソッド */
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);
    });
};

再度テストを実行してみます。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      ✓ should throw exceptions when the value are other than numbers
    #read
      ✓ should print "result: 4" when the value is 8 that given from the stdin


  4 passing (13ms)

テストに成功しました。

非同期処理に例外をスローする処理を追加する

標準入力から値を受け取り、数値以外のものが渡された場合に例外をキャッチし、エラーメッセージが表示されるという要件を追加します。

test/test-divided.js
var chai = require('chai')
        , should = chai.should();
var divided = require('../lib/divided');
var EventEmitter = require('events').EventEmitter;
require('mocha-sinon');

describe('divided', function() {
    describe('#calculate', function() {
        it('should return 2 when the value is 4', function() {
            divided.calculate(4).should.equal(2);
        });
        it('should return 1 when the value is 3', function() {
            divided.calculate(3).should.equal(1);
        });
        it('should throw exceptions when the value are other than numbers', function() {
            (divided.calculate).should.throw(Error);
            (function() {divided.calculate(null)}).should.throw(Error, 'Type of numeric is expected.');
            (function() {divided.calculate("abc")}).should.throw(Error, / numeric /);
            (function() {divided.calculate([])}).should.throw(Error, /^Type of numeric /);

            //// expect-style sentences are below.
            // var expect = chai.expect;
            // expect(divided.calculate).to.throw(Error);
            // expect(function() {divided.calculate(null)}).to.throw(Error, 'Type of numeric is expected.');
            // expect(function() {divided.calculate("abc")}).to.throw(Error, / numeric /);
            // expect(function() {divided.calculate([])}).to.throw(Error, /^Type of numeric /);
        });
    });

    describe('#read', function() {
        it('should print "result: 4" when the value is 8 that given from the stdin', function(done) {
            var ev = new EventEmitter();
            var _console_log = console.log;
            this.sinon.stub(console, 'log');

            process.openStdin = this.sinon.stub().returns(ev);
            divided.read();
            ev.emit('data', '8');

            console.log.calledOnce.should.be.true;
            console.log.calledWith('result: 4').should.be.true;

            //// expect-style sentences are below.
            //var expect = chai.expect;
            //expect(console.log.calledOnce).to.be.true;
            //expect(console.log.calledWith('result: 4')).to.be.true;

            console.log = _console_log;
            done();
        });

        it('should print "Type of numeric is expected." when the value is not a numeric', function(done) {
            var ev = new EventEmitter();
            var _console_log = console.log;
            this.sinon.stub(console, 'log');

            process.openStdin = this.sinon.stub().returns(ev);
            divided.read();
            ev.emit('data', 'abc');

            console.log.calledOnce.should.be.true;
            console.log.calledWithMatch('Type of numeric is expected.').should.be.true;

            console.log = _console_log;
            done();
        });
    });
});

内部で例外をスローして'Type of numeric is expected.' が表示されることを想定しています。
ではテストを実行して見ましょう。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      ✓ should throw exceptions when the value are other than numbers
    #read
      ✓ should print "result: 4" when the value is 8 that given from the stdin


  4 passing (17ms)
  1 failing

  1) divided #read should print "Type of numeric is expected." when the value is not a numeric:
     AssertionError: expected false to be true
      at Context.<anonymous> (test/test-divided.js:62:82)

例外をハンドリングする処理がまだ追加されていないためエラーとなりました。
例外をスローする処理を追加していきます。

lib/divided.js
/** 与えられた値を2 で割って小数点以下を切り捨てた結果を返す関数 */
exports.calculate = function(num) {
    if (typeof num !== 'number' || num !== num) {     // <- NaN を判定する条件をこっそり追加しています…(^^;)
        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.toString());
        }
    });
};

それでは再度テストを実行してみましょう。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      ✓ should throw exceptions when the value are other than numbers
    #read
      ✓ should print "result: 4" when the value is 8 that given from the stdin
      ✓ should print "Type of numeric is expected." when the value is not a numeric


  5 passing (16ms)

上記のようになればテストは成功です。

フック(hook)を使ったテストケース前後に処理を定義する(before(), after(), beforeEach(), afterEach())

hook 機能を利用することでテストの開始、終了時及び各テストケースの開始、終了時に処理を呼び出すことができるようになります。
mocha で使用できるhook としては以下のようなものがあります。

  • mocha で使用できるhook 一覧
    beforeEach() 各テストケースが実行される直前に呼ばれる
    afterEach() 各テストケースが実行された直後に呼ばれる
    before() テスト全体が実行される前に呼ばれる
    after() テスト全体が実行された後に呼ばれる

実装例は次のようになります。

before(),after(),beforeEach(),afterEach()
describe('divided', function() {

    before(function() {
        // ......
    });

    after(function() {
        // ......
    });

    beforeEach(function() {
        // ......
    });

    afterEach(function() {
        // ......
    });

    // ......
});

それでは今までのテストをこれらの機能を使って各テストケースが開始されるタイミングでstub を初期化する処理を実行させてみましょう。
before(), after(), beforeEach(), afterEach() を追加し、整理したものが以下のようになります。

test/test-divided.js
var chai = require('chai')
        , should = chai.should();
var divided = require('../lib/divided');
var EventEmitter = require('events').EventEmitter;
require('mocha-sinon');

describe('divided', function() {
    describe('#calculate', function() {
        it('should return 2 when the value is 4', function() {
            divided.calculate(4).should.equal(2);
        });
        it('should return 1 when the value is 3', function() {
            divided.calculate(3).should.equal(1);
        });
        it('should throw exceptions when the value are other than numbers', function() {
            (divided.calculate).should.throw(Error);
            (function() {divided.calculate(null)}).should.throw(Error, 'Type of numeric is expected.');
            (function() {divided.calculate("abc")}).should.throw(Error, / numeric /);
            (function() {divided.calculate([])}).should.throw(Error, /^Type of numeric /);

            //// expect-style sentences are below.
            // var expect = chai.expect;
            // expect(divided.calculate).to.throw(Error);
            // expect(function() {divided.calculate(null)}).to.throw(Error, 'Type of numeric is expected.');
            // expect(function() {divided.calculate("abc")}).to.throw(Error, / numeric /);
            // expect(function() {divided.calculate([])}).to.throw(Error, /^Type of numeric /);
        });
    });

    describe('#read', function() {

        var _console_log;
        var _process_openStdin;
        var ev;
        before(function() {
            _console_log = console.log;
            _process_openStdin = process.openStdin;
        });

        after(function() {
            console.log = _console_log;
            process.openStdin = _process_openStdin;
        });

        beforeEach(function() {
            ev = new EventEmitter();
            this.sinon.stub(console, 'log');
            process.openStdin = this.sinon.stub().returns(ev);
        });

        afterEach(function() {
        });

        it('should print "result: 4" when the value is 8 that given from the stdin', function(done) {

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

            console.log.calledOnce.should.be.true;
            console.log.calledWith('result: 4').should.be.true;

            //// expect-style sentences are below.
            //var expect = chai.expect;
            //expect(console.log.calledOnce).to.be.true;
            //expect(console.log.calledWith('result: 4')).to.be.true;

            console.log = _console_log;
            done();
        });

        it('should print "Type of numeric is expected." when the value is not a numeric', function(done) {

            process.openStdin = this.sinon.stub().returns(ev);
            divided.read();
            ev.emit('data', 'abc');

            console.log.calledOnce.should.be.true;
            console.log.calledWithMatch('Type of numeric is expected.').should.be.true;

            console.log = _console_log;
            done();
        });
    });
});

テストを実行します。

mocha
  divided
    #calculate
      ✓ should return 2 when the value is 4
      ✓ should return 1 when the value is 3
      ✓ should throw exceptions when the value are other than numbers
    #read
      ✓ should print "result: 4" when the value is 8 that given from the stdin
      ✓ should print "Type of numeric is expected." when the value is not a numeric


  5 passing (15ms)

上記のように結果が出力されれば、成功です。
以上で基本的なmocha の使い方の説明は完了です。

おまけ:Reporter 機能について

-R オプションを使用してレポーター機能を使用することができます。
レポーター機能はテストの結果をレポーターがレポートしてくれるという機能です。
例えば、mocha -R nyan と実行すると有名なあれがテスト結果をレポートをしてくれるようになります…。何が出現するかは実際に使ってみてのお楽しみです。

参考