Node.js 用のテスティングフレームワーク Vows

最近何かと話題の Node.js.

Node.js 日本ユーザグループもあります.

ドキュメントの翻訳もされてます (リリースされたばかりの 0.2.2 対応済み!).

そんな Node.js 向けのテスティングフレームワークもたくさんあります.

このモジュール一覧だけでも Node.js 界隈の盛り上がりっぷりが感じられますが,そんな中から Vows というテスティングフレームワークで遊んでみたので紹介します.

ちなみに,これだけたくさんある中から Vows を選んだのは,

  • ドキュメントがしっかりしてる方.
  • テストコードが (ちょっとだけ) カッコいい.

というのが理由です.7 月始めまで熱心に開発された後,ちょっと停滞してるように見えなくもないけど気にしない.
ちなみのちなみに,上記モジュールページを無作為にクリックしてどんなテスティングフレームワークが使われてるか眺めてみたのですが,テストを含まないモジュールと,自前で小さなテスト用モジュールを用意しているモジュールが半々でほとんどを占めてる感じ.あとは V8 に付いている mjsunit.js を使ってるのが少々.
今あるモジュールの多くはテスティングフレームワークがない時期に作られたりしたのかもしれませんが,まだこれといって広まっているものはないようです.

インストール

Node.js では npm (Node Package Manager) というパッケージマネージャがデファクトになってきているようです.

Vows も npm からインストールするのが簡単です.

npm install vows

環境によっては sudo が必要で,でもそれだと npm から「Running npm as root is not recommended!」なんて怒られたりしますが気にしない.

概要

まずは簡単なサンプルから.

var vows = require('vows'), assert = require('assert');

vows.describe('MyFirstVows').addBatch( {
    'はじめてのVows' : {
        topic : 'Hello',
        'topicをテストするよ' : function(topic) {
            assert.equal(topic, 'Hello');
        }
    }
}).run();

最初の行で Vows および標準の 'assert' モジュールを取り込んでいます.
Node.js のモジュールに関してはこの辺参照.

'assert'モジュールに関してはこの辺参照.

で,その後の 1 文が Vows のテストコード.
詳しくは後で見ることにして,まずは実行してみます.
上記の内容を my-first-vows.js というファイルに保存して,

$ node my-first-vows.js
·OK » 1 honored (0.002s)

ってな感じになります.
コードをちょっと修正して

            assert.equal(topic, 'Vows');

実行すると,

$ node my-first-vows.js
 

  はじめてのVows
    ✗ topicをテストするよ
      » expected 'Vows',
        got      'Hello' (==) // my-first-vows.js:7Broken » 1 broken (0.007s)

となります.
ということで,詳細を見ていきます.

テストスイート

先のコードのテスト本体をばらしながら見ていきます.
まずは

var suite = vows.describe('MyFirstVows');

で「テストスイート」を作ってます.引数はその説明ですね.
テストスイートは JUnit なんかと同じで,複数のテストをまとめたもののようです.


次に,そのテストスイートにテストを加えます.

suite.addBatch( {
    'はじめてのVows' : {
        topic : 'Hello',
        'topicをテストするよ' : function(topic) {
            assert.equal(topic, 'Hello');
        }
    }
});

テストスイートに追加されるテストの単位をバッチと呼ぶのですね.
そのバッチはオブジェクトで,後述する「コンテキスト」と呼ばれるオブジェクトを値とするプロパティを持っています.
この例は一つのコンテキストしか持っていませんが,当然複数のコンテキスを記述することもできます.

suite.addBatch( {
    'コンテキスト1' : {...},
    'コンテキスト2' : {...},
    'コンテキスト3' : {...},
    ...
});

さらに当然のことながら,テストスイートには複数のバッチを追加することができます.

suite.addBatch( {...} );
suite.addBatch( {...} );
suite.addBatch( {...} );
...

addBatch() はテストスイート自身を返すので,メソッドチェーンで

suite.addBatch( {...} ).addBatch( {...} ).addBatch( {...} )...;

と書くこともできます.
重要なポイントとして,それぞれのバッチは逐次的に実行されます.この後出てくるバッチの中のコンテキストは非同期に実行されるので,そこが大きな違い.
最後にテストスイートを実行します.

suite.run();


ちなみに,テストスイートを実行する代わりに,モジュールとして公開することもできます.

suite.export(module);

module ってのは Node.js が用意してくれるグローバルオブジェクトで,現在のモジュールすなわちこのファイル自身を表します.

テストスイートがモジュールとして公開されるとはどういうことかというと,別の誰かがこのファイルを取り込んで実行できるということです.誰が?
そんなわけで (どんなわけで?),Vows 自身がその誰かさんを用意してくれてます.
それを使うとテストスイートの実行は次のようにすることができます.

$ vows my-first-vows.js
·OK » 1 honored (0.001s)

vows ってのは Vows が用意してくれているものですが,シェルスクリプトとかではなく,Node.js で実行される JavaScript ファイルです.
その先頭の行はこうなってます.

#!/usr/local/bin/node

そうか,Node.js では #! が使えるんだ...
JavaScript の文法では # で始まる行がコメントになったりするわけではないのですが,Node.js は最初の行が #! で始まっている場合に限り,それをコメントと見なしてくれるようです.
最初の行が # で始まっているだけで後に ! がなかったり,2 行目以降に #! があってもコメントとは見なされず文法エラーになります.


ともあれ (JW),vows コマンドを使うとワイルドカードを使って

$ vows test/*.js

とかってテストスイートをまとめて実行できるようになります.

コンテキスト

続いてバッチの中に記述されていたコンテキストです.

    'はじめてのVows' : {
        topic : 'Hello',
        'topicをテストするよ' : function(topic) {
            assert.equal(topic, 'Vows');
        }
    }

コンテキストは名前というか説明 ('はじめてのVows') が付けられたオブジェクトです.
コンテキストはその内容として,一つの「トピック」と,そのトピックに対するテストを記述した複数の「vow」を持つことができます.
トピックというのは topic という名前のプロパティで,コンテキストにおけるテストの対象です.RSpec の subject みたいなものでしょうか.上記の例では 'Hello' という文字列をこれからテストしようとしていることになります.
vow という単語は「明言する」「約束する」「公約する」などの意味があるそうです.知らなかったよ.トピックがどうなっていなければいけないのかを明言するわけですね.
vow は名前が topic 以外のプロパティで,引数にトピックを受け取る関数として記述します.


コンテキストはネストすることができます.

    'はじめてのVows' : {
        topic : 'Hello',
        'topicをテストするよ' : function(topic) {
            assert.equal(topic, 'Hello');
        },
        'ネストしたコンテキスト' : {
            topic : 'Vows',
            'ネストしたコンテキストのtopicをテストするよ' : function(topic) {
                assert.equal(topic, 'Vows');
            }
        }
    }

あるいは,

    'はじめてのVows' : {
        'ネストしたコンテキストその1' : {
            topic : 'Hello',
            'ネストしたコンテキストその1のtopicをテストするよ' : function(topic) {
                assert.equal(topic, 'Hello');
            }
        },
        'ネストしたコンテキストその2' : {
            topic : 'Vows',
            'ネストしたコンテキストその2のtopicをテストするよ' : function(topic) {
                assert.equal(topic, 'Vows');
            }
        }
    }

とすることもできます.


ここまでをまとめると,Vows におけるテストスイートの構造は,

  • テストスイートは複数のバッチを持つ.
  • バッチは複数のコンテキストを持つ.
  • コンテキストは
    • 0 または 1 個のトピックを持つ.
    • 複数の vow を持つ.
    • 複数のサブコンテキストを持つ.

ということになります.


そして,各コンテキストは非同期に実行されます.
これは,コンテキストを記述した順番に実行されるとは限らないことを意味します.また,各コンテキストが一つずつ実行されるわけではないことも意味します.非同期なイベント駆動の Node.js らしいテスティングフレームワークですね.

トピック

これまでの例ではトピックに文字列リテラルしか指定していませんでしたが,関数を使うこともできます.

        topic : function() {
            return 'Hello';
        },

関数なので,いろいろなことができます.普通はこの中でテスト対象のオブジェクトを生成して返したり,関数を呼び出してその結果を返したりすることになるでしょう.
トピックが関数の場合,その関数を評価した結果が vow に渡されます (ちょっと嘘,詳細は後ほど).


コンテキストに複数の vow があっても,トピックが評価されるのは一度だけです.例えば

var i = 0;
...
        topic : function() {
            return i++;
        },
        'topicをテストするよ0' : function(topic) {
            assert.equal(topic, 0);
        },
        'topicをテストするよ1' : function(topic) {
            assert.equal(topic, 1);
        }

という具合に,vow ごとにトピックが評価されることを期待したテストは失敗します.

 

  はじめてのVows
    ✗ topicをテストするよ
      » expected 1,
        got      0 (==) // my-first-vows.js:14Broken » 1 honored ∙ 1 broken (0.005s)

両方の vow に同じ値 0 が渡されたことが分かります.
そこでコンテキストを分けてみます.

    'コンテキスト1' : {
        topic : function() {
            return i++;
        },
        'topicをテストするよ' : function(topic) {
            assert.equal(topic, 0);
        }
    },
    'コンテキスト2' : {
        topic : function() {
            return i++;
        },
        'topicをテストするよ' : function(topic) {
            assert.equal(topic, 1);
        }
    }

この場合,異なるコンテキストのトピックは独立に実行されますが,記述した順で実行されるとは限りません.
自分の環境ではこのテストは常に成功してしまいますが,もっと複雑なテストでは失敗することがあるかもしれないので注意が必要です.
まぁ,他のテストに依存するようなテストを書かなければいいだけですが.

ネストしたコンテキストとトピック

前述のように,コンテキストはネストすることができます.そしてそれぞれのコンテキストはトピックを持つことができます.
トピックを関数とした場合,親のコンテキストのトピックを引数として受け取ることができます.

    '親コンテキスト' : {
        topic : "Foo",
        '子コンテキスト' : {
            topic : function(parent) {
                return parent + 'Bar';
            },
            'topicをテストするよ' : function(topic) {
                assert.equal(topic, 'FooBar');
            }
        }
    }

親だけでなく,祖先のトピックも受け取ることができます.

    'おじいちゃんコンテキスト' : {
        topic : 'Foo',
        '親コンテキスト' : {
            topic : "Bar",
            '子コンテキスト' : {
                topic : function(parent, grandpa) {
                    return grandpa + parent + 'Baz';
                },
                'topicをテストするよ' : function(topic) {
                    assert.equal(topic, 'FooBarBaz');
                }
            }
        }
    }

ひいおじいちゃんコンテキストがあるなら

                topic : function(parent, grandpa, grandgrandpa) {

というように,いくつでも上位のトピックを受け取ることができます.
ちなみに上位とか祖先とかっていうのはあくまでもトピックのことであってコンテキストのことではありません.
例えば

    'おじいちゃんコンテキスト' : {
        topic : 'Foo',
        '親コンテキスト' : {
            '子コンテキスト' : {
                topic : function(parent) {
                    return parent + 'Bar';
                },
                'topicをテストするよ' : function(topic) {
                    assert.equal(topic, 'FooBar');
                }
            }
        }
    }

このように途中にトピックのないコンテキスト (親コンテキスト) が挟まっていても関係なく,引数の数だけトピックを持ったコンテキストを辿っていってくれます.

非同期イベントのテスト

Node.js は非同期なイベント駆動のプラットフォームなので,そのアプリケーションはイベントハンドラを多用することになりがちです.
そこで Vows は非同期イベントのテストをするために 2 つの方法が提供されています.

  • this.callback を使う.
  • EventEmitter による「プロミス」を使う.

いずれもトピックの特殊な使い方になります.

this.callback

トピック関数の this.callback には,そのトピックと同じコンテキストの vow を呼び出してくれる関数が設定されています.
これをトピックの中でコールバック関数として与えると,vow があたかもコールバック関数であるかのようになります.
例えば

        topic : function() {
            setTimeout(this.callback, 1000, null, 'timedout');
        },
        '1秒後にtopicをテストするよ' : function(err, topic) {
            assert.equal(topic, 'timedout');
        }

setTimeout() は Node.js が用意してくれているグローバル関数です.

これを使って,1 秒後にタイムアウトすると this.callback が二つの引数を伴って呼び出されるようにしています.なので,1 秒経過すると

this.callback(null, 'timedout');

という呼び出しが (Node.js によって) 行われます.そして Vows はその引数そのままで vow を呼び出してくれます.
this.callback は主にファイル入出力などで使われる

  • function(err, arg1, arg2...)

な形式のコールバックを想定しているようです.

つまりこの例では,this.callback に渡される最初の引数はエラーがないことを示す null,次の引数が本来のトピックである文字列というわけです.ちなみに最初の引数が null や undefined でない場合,this.callback はテストが失敗したと判断します.エラーが起きた場合のテストは this.callback ではできない... ということだろうか?
ともあれ (JW),vow はこの this.callback の引数をそのまま受け取ることができます.ただし,最初の引数 err が不要であれば (普通は不要です),

        '1秒後にtopicをテストするよ' : function(topic) {

とすることもできます.
このように,this.callback を使うと「こんな風にコールバックされること」なテストを簡単に記述することができます.テスト対象がイベントソースの場合に有効ですね.


トピックの関数が値を返していないことにも注目.値を返すとそれが vow に渡されてしまいます.

プロミス

this.callback よりさらに強力で柔軟なのがプロミスです.
プロミスというのは非同期に実行されていつか結果が得られるというもので.Java でいうと FutureTask みたいな.
Vows 固有の用語ではなく非同期界隈ではよく使われていて,CommonJS でも提案されてます.

オブジェクト指向でも 80 年代の青木淳さんの Smalltalk 本なんかに出てきてた気がするので,かなり古くからある概念だと思われます.
最近出たばかりの書籍「Test-Driven JavaScript Development」には「Chapter 14. Server-Side JavaScript with Node.js」という章がさっそくあって,そこに「Section 14.4. Promises」という節もあります.かなり普通に使われてるようですね.

Test-Driven JavaScript Development (Developer's Library)

Test-Driven JavaScript Development (Developer's Library)

<追記>
70 年代からあったようです.またヒューイットさんか...

</追記>


Node.js においてイベントを発生させるのは EventEmitter です.

トピックでプロミスを使うというのは,この EventEmitter を戻り値として返すということです.そして EventEmitter が 'success' イベントを生成すると,それによって vow が呼び出されます (エラーイベントのテストは...?).
例えば

        topic : function() {
            var promise = new (events.EventEmitter);
            setTimeout(function() {
                promise.emit('success', 'timedout');
            }, 1000, promise);
            return promise;
        },
        '1秒後にtopicをテストするよ' : function(topic) {
            assert.equal(topic, 'timedout');
        }

ちなみに vow の引数では発生したイベントの引数を全て受け取ることができます.

                promise.emit('success', arg1, arg2, arg3, arg4);

とすると,

        '1秒後にtopicをテストするよ' : function(type, arg1, arg2, arg3, arg4) {

のようにすることができます.先頭の type はイベントの種類で,ここでは 'success' です.普通はいらない ('success' の場合しか呼ばれないから) ので省略します.


実はトピックの戻り値が EventEmitter 以外の場合,Vows の内部で EventEmitter が作られています.プロミス以外の方法は,プロミスを隠蔽してくれている高水準なやり方というだけなのですね.

トピックのまとめ

多少不正確かもしれませんが,トピックを整理すると次のようになります.

  • トピックが関数の場合はその関数を評価し,
    • 戻り値が undefined の場合は,this.callback の第1引数が null または undefined で呼び出されると,その引数をトピックとして vow が呼び出される.
    • 戻り値が undefined 以外の場合はその値をトピックとして以下を継続.
  • トピックがプロミス (EventEmitter) の場合,
    • プロミスが 'success' イベントを生成すると,イベントリスナーに渡された引数をトピックとして vow が呼び出される.
  • それ以外の場合
    • トピックの値を引数として vow が呼び出される.

表明

Vows は標準の assert モジュールを拡張して,いくつかの関数を追加してくれます.詳細はこちらをどぞー.

Vows でも Node.js でもなく CommonJS の Unit Testing 1.0 の話になりますが,

assert.equal(actual, expected) は JUnit なんかと逆で,第 1 引数が実際の値,第 2 引数が期待値です.JUnit に慣れた人は注意が必要かも.個人的には「○○は××であること」の方が自然なのでありがたいかな.
それから,上の方に書いたモジュール一覧には表明だけのモジュールとか,モック (? こっちでは spy とか呼ぶらしい) 関連のモジュールなんかもあるので要チェックです.

まとめ

ということで,ざっくりと Vows の機能について紹介しました.とはいえ,断片ばかりでテストコードの全体像とか本物の非同期イベントを使ったテストがどうなるのか伝わらないですよね.
明日はもうちょっとまともな (とはいっても簡単な) 例を使って Vows のテストコードを書いてみたいと思います.