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

昨日のエントリ「その 2」で ReadableStream からの 'data' イベントを行単位にコールバックしてくれる read-line モジュールを作成し,EventEmitter を使って擬似的にイベントを発生させるテストを Vows を使って書きました.
今回はその続きとして,本物のファイルを使ったテストを追加します.


ファイルを扱うには Node.js 標準のファイルシステムモジュールを使います.

今回はファイルをストリームとして読み込むので,「fs.ReadStream」を使います.

この fs.createReadStream(path) を呼び出すと ReadableStream が返ってきます.(それは EventEmitter でもあります).こいつを read-line に渡せばいいだけですね!
でもでも,擬似的なテストで使った自前の EventEmitter と違い,ReadableStream に対して emit() を呼び出すわけにはいきません.実際に ReadableStream からの読み込みが終了して,行ごとの配列が完成するまでは vow で検証することができないのです.どうしよう?


そこで「その 1」で紹介した「プロミス」を使ってみましょう.

プロミスを使うのは簡単で,トピックで (またしても!) EventEmitter を作って返せばいいだけです.そして ReadableStream がクローズしたらプロミス (EventEmitter のことです) にイベントを生成してあげれば vow が呼び出されて検証が行われます.


一つずつ順番にいきましょう.
まずはテスト用のファイルを用意します (test1.txt).

abc

ファイルシステムモジュールを使うので,

-var vows = require('vows'), assert = require('assert'), events = require('events');
+var vows = require('vows'), assert = require('assert'), events = require('events'), fs = require('fs');
 var read_line = require('./read-line');

が必要です.
「その 2」で作成した,EventEmitter を偽のストリームとして扱うコンテキストとは別に,本当のファイルを使ったテスト用のコンテキストを用意します.

                 }
             }
         }
+    },
+    '実際に読み込んだファイルが' : {
+        '1行のファイルの場合' : {
+
+        }
     }
 }).export(module);

子のコンテキストのトピックと vow はイメージしやすいので先に作成します.

     },
     '実際に読み込んだファイルが' : {
         '1行のファイルの場合' : {
-
+            topic : function(topic) {
+                return topic('test1.txt');
+            },
+            '1回だけコールバックされること' : function(topic) {
+                assert.length(topic, 1);
+            },
+            '行全体がコールバックされること' : function(topic) {
+                assert.equal(topic[0], 'abc');
+            }
         }
     }
 }).export(module);

この子コンテキストのトピックが返すのがプロミスです.つまり,親コンテキストのトピックが返す関数の戻り値がプロミスであって欲しいということになります.
そんなわけで (どんなわけで?),親コンテキストのトピックはこうなるはずです.

         }
     },
     '実際に読み込んだファイルが' : {
+        topic : function() {
+            return function(fileName) {
+                var promise = new (events.EventEmitter);
+
+                return promise;
+            };
+        },
         '1行のファイルの場合' : {
             topic : function(topic) {
                 return topic('test1.txt');

そしてこの中で fs.createReadStream(path) を呼び出して,それを read-line の onDataByLine() に渡します.

         topic : function() {
             return function(fileName) {
                 var promise = new (events.EventEmitter);
+                var stream = fs.createReadStream(fileName);
+                stream.setEncoding('utf-8');
+                read_line.onDataByLine(stream, function(line) {
 
+                });
                 return promise;
             };
         },

ちなみに ReadableStream から文字列を読み込むには setEncoding() してあげないといけません.

read-line からコールバックされたら文字列を配列に貯め込みます.

     '実際に読み込んだファイルが' : {
         topic : function() {
             return function(fileName) {
+                var lines = [];
                 var promise = new (events.EventEmitter);
                 var stream = fs.createReadStream(fileName);
                 stream.setEncoding('utf-8');
                 read_line.onDataByLine(stream, function(line) {
-
+                    lines.push(line);
                 });
                 return promise;
             };

ファイルがクローズしたイベントを受け取らなくてはいけません.

                 read_line.onDataByLine(stream, function(line) {
                     lines.push(line);
                 });
+                stream.on('end', function() {
+
+                });
                 return promise;
             };
         },

ファイルがクローズしたならプロミスに通知して vow が呼び出されるようにします.

                     lines.push(line);
                 });
                 stream.on('end', function() {
-
+                    promise.emit('success', lines);
                 });
                 return promise;
             };

まとめると,親コンテキストのトピックはこうなりました.

        topic : function() {
            return function(fileName) {
                var lines = [];
                var promise = new (events.EventEmitter);
                var stream = fs.createReadStream(fileName);
                stream.setEncoding('utf-8');
                read_line.onDataByLine(stream, function(line) {
                    lines.push(line);
                });
                stream.on('end', function() {
                    promise.emit('success', lines);
                });
                return promise;
            };
        },

案外シンプルですね.非同期イベントを扱うテストって大変そうなイメージでしたが,これなら怖くないかも?
実行します.

$ vows read-line-test.js
·······························OK » 31 honored (0.007s)



テストデータを増やしてみます.
空のファイル (test0.txt) と,2〜4 行のデータを持ったファイルを作成します.
test2.txt (最後に改行なし)

abc
def

test3.txt (最後に改行あり)

abc

def

test4.txt (最後に改行あり)

abc

def

Eclipse のエディタなんかだと最後に改行があるとその下にもう一行あるかのように表示されるけど,そういうものなのかな? とりあえずここでは改行を行の終端として扱うことにしているので,その後に行はないものとして扱います.
最終形ということで,テストを増やした read-line-test.js 全体を掲載.

var vows = require('vows'), assert = require('assert'), events = require('events'), fs = require('fs');
var read_line = require('./read-line');

vows.describe('read-line').addBatch( {
    '偽のEventEmitterを使って' : {
        topic : function() {
            return function(data) {
                var lines = [];
                var emitter = new (events.EventEmitter);
                read_line.onDataByLine(emitter, function(line) {
                    lines.push(line);
                });
                if (!Array.isArray(data)) {
                    emitter.emit('data', data);
                } else {
                    for ( var i = 0; i < data.length; ++i) {
                        emitter.emit('data', data[i]);
                    }
                }
                emitter.emit('end');
                return lines;
            };
        },
        '1回だけデータ受信イベントが発生し' : {
            'データがundefinedの場合' : {
                topic : function(topic) {
                    return topic(undefined);
                },
                '1回もコールバックされないこと' : function(topic) {
                    assert.length(topic, 0);
                }
            },
            'データがnullの場合' : {
                topic : function(topic) {
                    return topic(null);
                },
                '1回もコールバックされないこと' : function(topic) {
                    assert.length(topic, 0);
                }
            },
            'データが空文字列の場合' : {
                topic : function(topic) {
                    return topic('');
                },
                '1回もコールバックされないこと' : function(topic) {
                    assert.length(topic, 0);
                }
            },
            'データが改行を含まない文字列の場合' : {
                topic : function(topic) {
                    return topic('abcdef');
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                'データ全体を文字列としてコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            'データが改行で始まる文字列の場合' : {
                topic : function(topic) {
                    return topic('\nabc');
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に空行がコールバックされること' : function(topic) {
                    assert.equal(topic[0], '');
                },
                '次に改行の後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'abc');
                }
            },
            'データが改行で終了する文字列の場合' : {
                topic : function(topic) {
                    return topic('abcdef\n');
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                '改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            'データが途中に改行を含む文字列の場合' : {
                topic : function(topic) {
                    return topic('abc\ndef');
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に改行の後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'def');
                }
            },
            'データが途中に改行を二つ含む文字列の場合' : {
                topic : function(topic) {
                    return topic('abc\ndef\nghi');
                },
                '3回コールバックされること' : function(topic) {
                    assert.length(topic, 3);
                },
                '最初に一番目の改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に一番目の改行と二番目の改行の間の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'def');
                },
                '最後に二番目の改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[2], 'ghi');
                }
            }
        },
        '2回データ受信イベントが発生し' : {
            'どのデータにも改行が含まれない場合' : {
                topic : function(topic) {
                    return topic( [ 'abc', 'def' ]);
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                '一番目のデータと二番目のデータを連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            '最初のイベントにだけ改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc\ndef', 'ghi' ]);
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に一番目のデータの改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に一番目のデータの改行より後と二番目のデータを連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'defghi');
                }
            },
            '二番目のイベントにだけ改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc', 'def\nghi' ]);
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に一番目のデータと二番目のデータの改行より前を連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                },
                '次に二番目のデータの改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'ghi');
                }
            },
            '一番目と二番目の両方のイベントに改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc\ndef', 'ghi\njkl' ]);
                },
                '3回コールバックされること' : function(topic) {
                    assert.length(topic, 3);
                },
                '最初に一番目のデータの改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に一番目のデータの改行より後と,二番目のデータの改行より前を連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'defghi');
                },
                '最後に二番目のデータの改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[2], 'jkl');
                }
            }
        }
    },
    '実際に読み込んだファイルが' : {
        topic : function() {
            return function(fileName) {
                var lines = [];
                var promise = new (events.EventEmitter);
                var stream = fs.createReadStream(fileName);
                stream.setEncoding('utf-8');
                read_line.onDataByLine(stream, function(line) {
                    lines.push(line);
                });
                stream.on('end', function() {
                    promise.emit('success', lines);
                });
                return promise;
            };
        },
        '空のファイルの場合' : {
            topic : function(topic) {
                return topic('test0.txt');
            },
            '1回もコールバックされないこと' : function(topic) {
                assert.length(topic, 0);
            }
        },
        '1行のファイルの場合' : {
            topic : function(topic) {
                return topic('test1.txt');
            },
            '1回だけコールバックされること' : function(topic) {
                assert.length(topic, 1);
            },
            '行全体がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            }
        },
        '2行で最後の行が改行で終わらないファイルの場合' : {
            topic : function(topic) {
                return topic('test2.txt');
            },
            '2回コールバックされること' : function(topic) {
                assert.length(topic, 2);
            },
            '最初に1行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            },
            '次に2行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[1], 'def');
            }
        },
        '空行を含めて3行で最後の行が改行で終わるファイルの場合' : {
            topic : function(topic) {
                return topic('test3.txt');
            },
            '3回コールバックされること' : function(topic) {
                assert.length(topic, 3);
            },
            '最初に1行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            },
            '次に2行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[1], '');
            },
            '最後に3行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[2], 'def');
            }
        },
        '空行を含めて4行で最後の行が改行のみの空行で終わるファイルの場合' : {
            topic : function(topic) {
                return topic('test4.txt');
            },
            '4回コールバックされること' : function(topic) {
                assert.length(topic, 4);
            },
            '最初に1行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            },
            '次に2行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[1], '');
            },
            '続けて3行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[2], 'def');
            },
            '最後に4行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[3], '');
            }
        }
    }
}).export(module);

ついでに read-line.js も掲載.「その 2」の途中から修正してないけど.

exports.onDataByLine = function(stream, callback) {
    var line = '';
    stream.on('data', function(data) {
        if (data) {
            line += data;
            while (line.match(/\r?\n/)) {
                callback(RegExp.leftContext);
                line = RegExp.rightContext;
            }
        }
    });
    stream.on('end', function() {
        if (line) {
            callback(line);
        }
    });
};

これっぽっちのテスト対象にあのテストの記述量はどうなのよ? って思わなくもないけど気にしない!! 気にしない!!
実行します.

$ vows read-line-test.js
············································OK » 44 honored (0.014s)

--spec します.

$ vows read-line-test.js --spec

 read-line

  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データがundefinedの場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データがnullの場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが空文字列の場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが改行を含まない文字列の場合
    ✓ 1回だけコールバックされることデータ全体を文字列としてコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが改行で始まる文字列の場合
    ✓ 2回コールバックされること最初に空行がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが改行で終了する文字列の場合
    ✓ 1回だけコールバックされること改行より前の文字列がコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが途中に改行を含む文字列の場合
    ✓ 2回コールバックされること最初に改行より前の文字列がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが途中に改行を二つ含む文字列の場合
    ✓ 3回コールバックされること最初に一番目の改行より前の文字列がコールバックされること次に一番目の改行と二番目の改行の間の文字列がコールバックされること最後に二番目の改行より後の文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し どのデータにも改行が含まれない場合
    ✓ 1回だけコールバックされること一番目のデータと二番目のデータを連結した文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し 最初のイベントにだけ改行が含まれる場合
    ✓ 2回コールバックされること最初に一番目のデータの改行より前の文字列がコールバックされること次に一番目のデータの改行より後と二番目のデータを連結した文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し 二番目のイベントにだけ改行が含まれる場合
    ✓ 2回コールバックされること最初に一番目のデータと二番目のデータの改行より前を連結した文字列がコールバックされること次に二番目のデータの改行より後の文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し 一番目と二番目の両方のイベントに改行が含まれる場合
    ✓ 3回コールバックされること最初に一番目のデータの改行より前の文字列がコールバックされること次に一番目のデータの改行より後と,二番目のデータの改行より前を連結した文字列がコールバックされること最後に二番目のデータの改行より後の文字列がコールバックされること
  実際に読み込んだファイルが 空のファイルの場合
    ✓ 1回もコールバックされないこと
  実際に読み込んだファイルが 1行のファイルの場合
    ✓ 1回だけコールバックされること行全体がコールバックされること
  実際に読み込んだファイルが 2行で最後の行が改行で終わらないファイルの場合
    ✓ 2回コールバックされること最初に1行目の文字列がコールバックされること次に2行目の文字列がコールバックされること
  実際に読み込んだファイルが 空行を含めて4行で最後の行が改行のみの空行で終わるファイルの場合
    ✓ 4回コールバックされること最初に1行目の文字列がコールバックされること次に2行目の文字列がコールバックされること続けて3行目の文字列がコールバックされること最後に4行目の文字列がコールバックされること
  実際に読み込んだファイルが 空行を含めて3行で最後の行が改行で終わるファイルの場合
    ✓ 3回コールバックされること最初に1行目の文字列がコールバックされること次に2行目の文字列がコールバックされること最後に3行目の文字列がコールバックされることOK » 44 honored (0.025s)

幸せな気分になりました♪
最後の二つのコンテキスト (ファイルが 3 行と4 行のやつ),表示の順番が記述の順番と異なっていることに注意.しかも,実行を繰り返すごとに異なってたりします.
これは,Vows がテスト (コンテキスト) を非同期に実行している証でしょう.表示順はテストの実行が完了する順番で,それはファイルの読み込みが完了する順番に依存しているわけです.Vows△!



説明を文字列で書く辺りとか記述量が多くなりそうなところで好き嫌い別れそうではありますが,Vows はなかなか素敵なテスティングフレームワークだと思います.
非同期イベントのテストも思ってた以上にあっさりと書けましたが,これはテスト対象であるトピックと,検証コードである vow を分けて記述しているおかげでしょう.そしてトピックが検証可能になった時,Vows が vow を呼び出してくれるわけです.まさにイベント駆動.
Node.js のテスティングフレームワークとしては他にも "Promise-based asynchronous test runner" なんていうカッコよさげな「patr」も興味深いのですが,

しばらくは浮気せずに Vows を足場に Node.js を楽しみたいと思ってます.