vows に追加されているアンドキュメントな機能
JavaScript Advent Calendar 2011 (Node.js/WebSocketsコース) 12日目の記事です。
Node.js におけるテストフレームである、vows について書いてみました。
はじめに
この記事では、README.md や 本家 Web サイトのドキュメントに載っていないことを主に書いています。
最新バージョンである 0.6.0 の vows を元に、おさらいした後、以下の3つについて説明します。
- assert
- teardown
- sub-events
0. vows についておさらい
vows は、Node.js 向けの RSpec のような BDD できるフレームワーク です。
vows は非同期なコードをテストするために作られたフレームワークとなっています。
インストール
vows は npm を使って下記コマンドでインストールできます。
$ npm install -g vows
テストの基本構造
vows では、基本、以下に示したような構造でテストを書きます。
var vows = require('vows'); var assert = require('assert'); var suite = vows.describe('Array'); suite.addBatch({ // batch 'An array with 3 elements': { // context topic: [1, 2, 3], // topic 'has a length of 3': function (topic) { // vow assert.equal(topic.length, 3); } }, 'An array': { // context 'with zero elements': { // sub-context topic: [], // topic 'has a length of 0': function (topic) { // vow assert.equal(topic.length, 0); }, 'returns *undefined*, when `pop()`ed': function (topic) { // vow assert.isUndefined(topic.pop()); } } } }).export(module);
まず、vows.describe で テストスイートの説明である subject を指定して suite を作ります。上記のコードでは、'Array' を指定して suite を作っています。suite は JUnit でいう複数のテストをまとめたものです。
次に、suite.addBatch で batch を suite に追加します。batch はテストの単位で、複数の context で構成することができます。上記のコードでは、2つの context を持つ batch として追加しています。suite の addBatch は batch を追加すると、その suite 自身返します。
context は対象となるテストの説明を表したものです。上記のコードのように、context とは 1つの topic と 複数の vow を持つことができます。また、上記のように、context に context を記述してネストすることもできます。
topic は context におけるテストの対象です。topic では、上記のように、直接値を記述したり、振舞いを記述した関数を記述することができます。topic で関数を記述した場合、context の中に複数の vow があったとしても評価されるのは、該当 context が実行されたとき一度だけです。
vow は topic がどうなるべきか記述された明言です。上記のように、vow は引数に topic を受け取る関数として記述し、関数内では、assert で topic をチェックします。
最後に、suite の export で module を指定することで、ファイルに記載されたテストが vows で実行できるよう公開します。
書いたテストの実行
上記コードをテストするには、以下のコマンドを実行します。
$ vows basic.js
実行結果としては、以下のように出力されます。
'--spec' オプションを指定してコマンドを実行すると以下のようになります。
context の説明と vow の説明、そしてテスト結果が、一覧で出力されます。テストの出力結果がカラーづけされているので、どこがテスト通らなかったか、分かりやすいです。
非同期イベントのテスト
Node.js では、非同期イベントをバシバシ使うので、非同期イベントのテストを書く必要があります。vows で非同期イベントをテストするには、
- topic の this.callback 関数
- EventEmitter インスタンスによるプロミス
の2種類の方法があります。以下に、上記2種類の方法で書いたテストを示します。
var vows = require('vows'); var assert = require('assert'); var EventEmitter = require('events').EventEmitter; var fs = require('fs'); vows.describe('async').addBatch({ 'this.callback : ': { 'hoge.txt': { topic: function () { fs.stat('./hoge.txt', this.callback); }, 'can be accessed': function (err, stats) { assert.isNull(err); assert.isObject(stats); }, 'is not empty': function (err, stats) { assert.isNotZero(stats.size); } } }, 'EventEmitter : ': { 'hoge.txt': { topic: function () { var promise = new EventEmitter(); fs.stat('./hoge.txt', function (err, stats) { if (err) { promise.emit('error', err); } else { promise.emit('success', stats); } }); return promise; }, 'can be accessed': function (err, stats) { assert.isNull(err); assert.isObject(stats); }, 'is not empty': function (err, stats) { assert.isNotZero(stats.size); } } } }).export(module);
this.callback で非同期イベントをテストするには、topic の関数内で対象となる非同期イベントのコールバックの部分に this.callback を与えてやるだけです。これで、vows が その topic の中で同じ context の vow を呼び出すようになります。this.callback による vow の呼び出しした際のパラメータの形式は、 function (err, arg1, arg2 ...) の形式になります。
EventEmitter によるプロミスの場合は、topic の関数内で EventEmitter のインスタンスを作って、topic の関数は、その作ったインスタンスを戻り値として返す必要があります。同じ topic の関数内で、作成したインスタンスが対象となる非同期イベントのコールバック内で、'success' イベントを生成するようにしておくことで、vows が topic と同じ context の vow を呼び出すようになります。'success' イベントは、promise.emit('success', arg1, arg2 ...); のように指定することで、vow でパラメータを受け取ることができます。非同期イベントのエラーの場合は、'error' イベントを生成するようにします。'error' イベントの場合も promise.emit('error', err, arg1 ...); のように指定すると、vow でパラメータを受け取ることができます。
以上が、vows についておさらいです。
1. assert
vows で提供する assert は Node.js で提供している assert を拡張しています。その assert の中で、一部ドキュメントの reference に記載されていないものがあります。reference の一覧にないものを順に以降挙げて説明します。
assert.greater(actual, expected, [message])
actual が expected より大きいかどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.greater(5, 4);
assert.lesser(actual, expected, [message])
actual が expected より小さいかどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.lesser(4, 5);
assert.inDeleta(actual, expected, delta, [message])
actual が expected - delta 〜 expected + delta の範囲内であるかどうかチェックします。
(数式で表現すると、expected - delta < actual < expected + delta)
条件が合致しない場合は、message を出力します。
assert.inDelta(42, 40, 5); assert.inDelta(42, 40, 2); assert.inDelta(42, 42, 0); assert.inDelta(3.1, 3.0, 0.2);
assert.lengthOf(actual, expected, [message])
actual の長さが expected であるかどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.lengthOf([0, 1], 2);
昔から vows を使っている人は、assert.length を使っているかと思いますが、assert.length は今後、assert.lengthOf にとって代わるので、今後はこちらの assert.lengthOf を使う必要があります。
(軽く調べた感じですと、0.5.12 から assert.lengthOf 追加されているようです。)
assert.isNotEmpty(actual, [message])
actual が 空かどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.isNotEmpty({ hoge: 4 });
assert.isDefined(actual, [message])
actual が undefined でないかどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.isDefined(null); assert.isDefined(1); assert.isDefined({});
assert.isZero(actual, [message])
actual が 0 かどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.isZero(0);
assert.isNotZero(actual, [message])
actual が 0 でないかどうかチェックします。
条件が合致しない場合は、message を出力します。
assert.isNotZero(1);
assert.includes(actual, expected, [message])
actual に expected が含まれているかどうかチェックします。
assert.includes は assert.include のエイリアスですので、assert.include と同じです。
条件が合致しない場合は、message を出力します。
assert.includes([0, 4, 33], 4); assert.includes('hello world', 'hello'); assert.includes({ foo: 4 }, 'foo');
assert.deepInclude(actual, expected, [message])
actual に expected が含まれているかどうか、深い同値性でチェックします。
条件が合致しない場合は、message を出力します。
assert.deepInclude([0, 4, 33], 4); assert.deepInclude([{ foo: 4 }, { bar: 33 }], { foo: 4 }); assert.deepInclude('hello world', 'hello'); assert.deepInclude({ foo: 4 }, 'foo');
内部の動作として、github のソースを見た感じですと、actual が配列であった場合は、各要素を assert.deepEqual で actual に対して expected であるかどうかチェックしているようです。actual が配列以外の場合は、assert.include でチェックしているようです。
1. teardown
vows でも、JUnit などのフレームワークのように、後処理を行う teardown をサポートしています。
使い方
teardown は、context の中に書きます。コードで書くと実際こんな感じになります。
var vows = require('vows'); var assert = require('assert'); vows.describe('teardown').addBatch({ 'context': { topic: function () { // do something ... return { flag: true }; }, 'vow1': function (topic) { assert.isTrue(topic.flag); }, 'vow2': function (topic) { assert.isTrue(topic.flag); }, teardown: function (topic) { console.log('context teardown !!'); topic.flag = false; } }, }).export(module);
上記コードには、動作が分かりやすくするため、console.log 入れてます。teardown は batch の 全てcontext の中の vow が全て実行された後に実行されます。この動作は、RSpec でいう after(:all) に近い感じです。上記コードを実行すると以下のようになります。
$ vows teardonw0.js --spec ♢ teardown context ✓ vow1 ✓ vow2 context teardown !! ✓ OK » 2 honored (0.004s)
ネストした context にも書ける
teardown はネストした context 、つまり、subcontext 中にも書くことができます。また、subcontext の親の context にも teardown を書くこともできます。上記コードを少し改造したコードを以下に示します。
var vows = require('vows'); var assert = require('assert'); vows.describe('teardown').addBatch({ 'context': { topic: function () { // do something ... return { flag: true }; }, 'vow1': function (topic) { assert.isTrue(topic.flag); }, 'vow2': function (topic) { assert.isTrue(topic.flag); }, teardown: function (topic) { console.log('context teardown !!'); topic.flag = false; }, 'subcontext': { 'nested vow': function (topic) { assert.isTrue(topic.flag); }, teardown: function (topic) { console.log('subcontext teardown !!'); } }, }, }).export(module);
上記コードを実行すると以下の結果になります。
$ vows teardown1.js --spec ♢ teardown context ✓ vow1 ✓ vow2 context subcontext ✓ nested vow subcontext teardown !! context teardown !! ✓ OK » 3 honored (0.004s)
上記結果より、subcontext の teardown と、 subcontext の 親の context の teardown が実行されていることが分かります。上記コードのように、複数の teardown が batch 内にある場合は、github のソースを見た感じですと、最後に実行した context の teardown から舐めるよう実行するようです。
また、context は非同期で実行されるため、teardown の実行順序は毎回記述した順序で発生するとは限りません。このため、他の teardown に依存するようなテストのコーディングは避けた方がよいでしょう。
teardown で非同期な処理に対応する
teardown は後始末をするには最適なので、socket のサーバの close 処理のような非同期イベントを発生させる処理を書くことがあるかと思います。
teardown は、batch 内にある context が全て完了してから開始し、すべてのteardown が実行されたときに、次に batch に進みます。
もし、teardown に非同期な処理がある場合は、どうなるんでしょうか。では、以下のコードでその動作を確認してみましょう。
var vows = require('vows'); var assert = require('assert'); var flag = false; vows.describe('teardown').addBatch({ 'context1': { topic: function () { // do something ... flag = true; return true; }, 'vow1': function () { assert.isTrue(flag); }, teardown: function (topic) { var asyncDoSomething = function () { setTimeout(function () { console.log('teardown !!'); flag = false; }, 10); }; asyncDoSomething(); } } }).addBatch({ 'context2': { 'vow2': function () { assert.isFalse(flag); } } }).export(module);
上記コードの context1 の teardown 内の asyncDoSomething 関数は、非同期処理をエミュレートした関数です。この関数の中では、10 ms 経過すると、console.log でメッセージを出力し、flag を false で初期化します。
context2 の vow2 では、flag が false であるかどうかをチェックしています。
上記コードを実行すると以下のようになります。
$ vows teardown2.js ♢ teardown context1 ✓ vow1 context2 ✗ vow2 » expected false, got true // teardown2.js:29 ✗ Broken » 1 honored ∙ 1 broken (0.018s)
テストが失敗してしまいました。。。
asyncDoSomething 関数での console.log の内容は出力されていません。このことから、teardown に非同期処理に構わず、次の batch に進んでしまっていることが分かったかと思います。
socket の close ような、非同期イベントでリソース解放の完了通知を受け取るような非同期処理がある場合は、これでは都合が悪いです。
実は、これに対応する方法はあります。それは、vows で非同期イベントのテストと同じく this.callback を利用します。以下は上記コードを少し改造した、teardown での this.callback の使い方のコードです。
var vows = require('vows'); var assert = require('assert'); var flag = false; vows.describe('teardown').addBatch({ 'context1': { topic: function () { // do something ... flag = true; return true; }, 'vow1': function () { assert.isTrue(flag); }, teardown: function (topic) { var asyncDoSomething = function (cb) { setTimeout(function () { console.log('teardown !!'); flag = false; cb(); }, 10); }; asyncDoSomething(this.callback); } } }).addBatch({ 'context2': { 'vow2': function () { assert.isFalse(flag); } } }).export(module);
このコードでは、teardown で、asyncDoSomething 関数にコールバック関数として this.callback を指定しています。
そして、asyncDoSomething 関数では、パラメータで指定されたコールバック関数が実行されるようにしています。
上記のコードを実行すると以下の結果になります。
$ vows teardown3.js --spec ♢ teardown context1 ✓ vow1 teardown !! context2 ✓ vow2 ✓ OK » 2 honored (0.016s)
テストが通りました!
10ms 経過して、console.log が出力しているので、きちんと非同期処理に対応できたということになります。このことから、teardown で非同期な処理をしたい場合は、this.callback を利用すると幸せになるでしょう。
3. sub-events
バージョン0.6.0では、sub-events という機能がサポートされました。
sub-events により以下のようなことができるようになります。
- topic 内の処理で発生した複数の非同期イベントをテストすることができます
- 非同期イベントの発生順もテストすることができます
では、具体的にコードでどう使うのか説明していきましょう。
カウントダウンタイマーについて
まずは、以降のコードで利用する、1秒間隔でカウントダウンするタイマーのコードを以下に載せておきます。
var EventEmitter = require('events').EventEmitter; var createTimer = function () { var timer = Object.create(EventEmitter.prototype, { run: { value: function (sec, cb) { var self = this; var count = sec; var id = setInterval(function () { if (count === 0) { clearInterval(id); return; } if (count <= 3) { self.emit(count.toString(), count); } count--; }, 1000); cb && cb(); } } }); return timer; }; exports.createTimer = createTimer;
createTimer 関数は、カウントダウンタイマーを作成する関数です。この関数で作成されたカウントダウンタイマーは、run メソッドでタイマーを開始します。
run メソッドにはカウントダウンを開始秒数であるパラメータ sec と、カウントダウンが開始された際に呼び出されるコールバック関数をパラメータ callback に指定します。
カウントダウンタイマーは、カウントダウンを開始してから、3秒前に、'3' イベント、2秒前に '2' イベント、1秒前に '1' イベントを生成します。各イベントのパラメータ ret には、それぞれのイベントのカウントダウン値('2'イベントだったら、2)が渡ってくるという仕様です。
run メソッドとこれらイベントの実装内容については、ここでは本質的な内容ではないので説明割愛します。
sub-events の使い方
上記カウントダウンタイマーを利用した、sub-events のコードの例を以下に示します。
var vows = require('vows'); var assert = require('assert'); var EventEmitter = require('events').EventEmitter; var createTimer = require('./timer').createTimer; vows.describe('sub-events').addBatch({ 'calling `run` with 5 sec': { topic: function () { var promise = new EventEmitter(); var timer = createTimer(); timer.on('3', function (ret) { promise.emit('three', ret); }); timer.on('2', function (ret) { promise.emit('two', ret); }); timer.on('1', function (ret) { promise.emit('one', ret); }); timer.run(5, function () { promise.emit('success', timer); }); return promise; }, on: { 'three': { 'will emit `3` value': function (ret) { assert.equal(ret, 3); } }, 'two': { 'will emit `2` value': function (ret) { assert.equal(ret, 2); } }, 'one': { 'will emit `1` value': function (ret) { assert.equal(ret, 1); } } } } }).export(module);
この例では、カウントダウンを5秒前で開始して run によって発生する各非同期イベントのテストをするというものになっています。
部分的にコードを示しながら順に内部を詳しく見て行きましょう。
topic での振る舞いの定義
'calling `run` with 5 sec': { topic: function () { var promise = new EventEmitter(); var timer = createTimer(); timer.on('3', function (ret) { promise.emit('three', ret); }); timer.on('2', function (ret) { promise.emit('two', ret); }); timer.on('1', function (ret) { promise.emit('one', ret); }); timer.run(5, function () { promise.emit('success', timer); }); return promise; }, }
topic では、プロミス (promise) とカウントダウンタイマー (timer) を作成しています。sub-events においても、非同期イベントのテストでプロミスを利用するのは従来どおりです。
続いて、カウントダウンタイマーの非同期な'3'、'2'、'1' イベントをハンドリングしています。それぞれの非同期イベントのハンドリング処理としては、プロミスで各非同期イベントに対応するイベント名が生成されるよう、取得したパラメータ ret を指定して emit しています。具体的に'3'イベントの場合ですと、プロミスで emit でイベント名 'three'、パラメータに '3'イベントで取得した ret をそのまま emit でパラメータとして渡してイベントを生成するようにいった感じです。
カウントダウンタイマーの run メソッドでは、 5秒前からカウントダウンし、カウントダウンが開始された際にコールバックするようしています。run メソッドのコールバック内の処理では、先程作成したプロミスの emit で 'success' イベントを生成するにしています。sub-events において、topic 内の非同期イベントの振舞いをテストするには、プロミスによる 'success' イベントを生成するよう書く必要があります。
topic の最後では、プロミスを返すようにします。
非同期イベントのハンドリング
on: { 'three': { 'will emit `3` value': function (ret) { assert.equal(ret, 3); } }, 'two': { 'will emit `2` value': function (ret) { assert.equal(ret, 2); } }, 'one': { 'will emit `1` value': function (ret) { assert.equal(ret, 1); } } }
topic 内で発生する非同期イベントをハンドリングするには、on を利用します。on には、topic 内で発生するイベント名のプロパティで、vow を持つ context として設定します。ここで指定する vow は従来どおりfunction(arg1, arg2 ...) のような promise.emit で指定されたパラメータを受け取るコールバック形式で指定します。
上記のコードでは、topic 内のプロミスによって発生する 'three'、'two'、そして'one'イベントをハンドリングするため、'three'、'two'、'one'の context を on に設定しています。それぞれの context の vow では、パラメータとして、ret をうけとって、assert.equal でテストするようにしています。
実行結果
このサンプルを実行してみましょう。
$ vows subevents1.js --sepc ♢ sub-events calling run with 5 sec on three ✓ will emit 3 value calling run with 5 sec on two ✓ will emit 2 value calling run with 5 sec on one ✓ will emit 1 value ✓ OK » 3 honored (5.003s)
カウントダウンタイマーにより、3秒、2秒、1秒と経過する順に、テストが実行されて、最終的には上記のような結果が出力されるはずです。
on の description の部分は、'on xxxx' という風な感じで vows 側で出力してくれます。
sub-events を使わないでテストコードを書いた場合
sub-events を使わない、従来の方法で、このケースをテストした場合は、
多分、下記のようなコードになるでしょう。
var vows = require('vows'); var assert = require('assert'); var EventEmitter = require('events').EventEmitter; var createTimer = require('./timer').createTimer; vows.describe('sub-events-no-use').addBatch({ 'calling `run` with 5 sec': { topic: function () { var promise = new EventEmitter(); var timer = createTimer(); this.timer = timer; timer.run(5, function () { promise.emit('success', true); }); return promise; }, 'on `three` event': { topic: function () { var promise = new EventEmitter(); var timer = this.timer; timer.on('3', function (ret) { promise.emit('success', ret); }); return promise; }, 'will emit `3` value': function (topic) { assert.equal(topic, 3); }, 'on `two` event': { topic: function () { var promise = new EventEmitter(); var timer = this.timer; timer.on('2', function (ret) { promise.emit('success', ret); }); return promise; }, 'will emit `2` value': function (topic) { assert.equal(topic, 2); }, 'on `one` event': { topic: function () { var promise = new EventEmitter(); var timer = this.timer; timer.on('1', function (ret) { promise.emit('success', ret); }); return promise; }, 'will emit `1` value': function (topic) { assert.equal(topic, 1); } } } } } }).export(module);
通常の方法では、topic では、'success' か 'error' かの1つしか非同期イベントをテストできません。vows でいう macro でも書かなきゃ、上記のような非同期イベントごとに七面倒臭く context を作ってどんどん右にネストしていくのでメンテナンスしづらいテストコードになるのが普通ではないんでしょうか。sub-events を使ったほうが断然キレイなメンテナンスしやいテストコードであることは間違いないでしょう。
非同期イベントの発生順をテストする
sub-events では非同期イベントの発生順も容易にテストすることができます。sub-events の説明で利用したコードを発生順に対応させたコードと実行結果を以下に示します。
var vows = require('vows'); var assert = require('assert'); var EventEmitter = require('events').EventEmitter; var createTimer = require('./timer').createTimer; vows.describe('sub-events-order').addBatch({ 'calling `run` with 5 sec': { topic: function () { var promise = new EventEmitter(); var timer = createTimer(); timer.on('3', function (ret) { promise.emit('three', ret); }); timer.on('2', function (ret) { promise.emit('two', ret); }); timer.on('1', function (ret) { promise.emit('one', ret); }); timer.run(5, function () { promise.emit('success', timer); }); return promise; }, on: { 'three': { 'will emit `3` value': function (ret) { assert.equal(ret, 3); }, on: { 'two': { 'will emit `2` value': function (ret) { assert.equal(ret, 2); }, on: { 'one': { 'will emit `1` value': function (ret) { assert.equal(ret, 1); } } } } } } } } }).export(module);
$ vows subevents2.js --spec ♢ sub-events-order calling run with 5 sec on three ✓ will emit 3 value calling run with 5 sec on three on two ✓ will emit 2 value calling run with 5 sec on three on two on one ✓ will emit 1 value ✓ OK » 3 honored (5.005s)
イベントの発生順は、上記コードのように、event に対して on を設定して event をネストして記載していきます。ネストされた event は、上位の event よりも後にイベントが発生しないといけないということを意味します。上記コードでは、イベントが 'three' 、'two' 、'one' という順でネストしていますが、'two' は 'three' よりも後に、'one' は 'two' とりも後に、イベントが発生しないといけないということになります。
イベント発生順序のテストに失敗した場合は、どうなるか。上記コードの 'one' イベントと 'two' イベントを入れ替えみて実行させると以下のような、結果になるはずです。
var vows = require('vows'); var assert = require('assert'); var EventEmitter = require('events').EventEmitter; var createTimer = require('./timer').createTimer; vows.describe('sub-events-order').addBatch({ 'calling `run` with 5 sec': { topic: function () { var promise = new EventEmitter(); var timer = createTimer(); timer.on('3', function (ret) { promise.emit('three', ret); }); timer.on('2', function (ret) { promise.emit('two', ret); }); timer.on('1', function (ret) { promise.emit('one', ret); }); timer.run(5, function () { promise.emit('success', timer); }); return promise; }, on: { 'three': { 'will emit `3` value': function (ret) { assert.equal(ret, 3); }, on: { 'one': { 'will emit `1` value': function (ret) { assert.equal(ret, 1); }, on: { 'two': { 'will emit `2` value': function (ret) { assert.equal(ret, 2); } } } } } } } } }).export(module);
$ vows subevents3.js --spec ♢ sub-events-order calling run with 5 sec on three ✓ will emit 3 value calling run with 5 sec on three on one ✓ will emit 1 value calling run with 5 sec on three on one on two ✗ will emit 2 value » two emitted before one ✗ Broken » 2 honored ∙ 1 broken (5.005s)
on の 文法構造
sub-events の最後の説明して、on 文法構造について説明します。on 文法構造ですが、github のここによると以下のような構造になっています。
- on は context のプロパティで、オブジェクトリテラルとして、context に1つ存在することができる
- on は最低1つ event を持つ必要があり、複数 event を持つことができる
- event は topic を持つことができない context でもある
以上の on 文法構造を整理すると、vows での文法構造は以下のようになります。
suite → batch*
batch → context*
context → topic? vow* context* on?
on → event+
event → context
on は実は、events のエイリアスで、上記の on を events に変更しても動作します。