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

昨日の続きです.今回はもうちょっとだけ本格的に Vows を使ってみます.

とはいえ,どうにも TDD が身につかない人なので,そっち方面は大目に見てください.


サンプルとして read-line モジュールを作成します.
Node.js の場合,ファイルからであれ,ネットワークからであれ,基本的にデータ入力は ReadableStream からコールバックされます.

コールバック関数の形式は

  • function(data)

で,コールバック関数に渡されるデータは大抵はバッファリングされた単位でまとめてやってきます.それは多数の行を含んでいることも,行の途中でぶった切れていることもあります.でもでも,行単位で処理したいケースってよくありますよね.HTTP なんかだと Node.js の標準ライブラリがヘッダを解釈してくれますが,例えばメッセージボディが CSV だったりとかしたらやっぱり行単位で処理したくなります.
そんなわけで (どんなわけで?),ReadableStream からのデータイベントを行単位にしてくれるモジュールを作ります.
ちなみに,Node.js 本体にも readline というモジュールがありますが,これは REPL (Read-Eval-Print-Loop) のために tty デバイスから 1 行読み込むためのもので,任意のストリームから行単位にコールバックしてくれるものではないようです.



まずは中身のないテスト read-line-test.js を用意します.

+var vows = require('vows'), assert = require('assert');
+
+vows.describe('read-line').addBatch( {
+}).export(module);

軽く id:t-wada さんのマネマネ.
実行します.

$ vows read-line-test.js
 

undefined »  (0.000s)

何もなくても怒られません.


ではバッチにコンテキストを追加します.

 var vows = require('vows'), assert = require('assert');
 
 vows.describe('read-line').addBatch( {
+    'テストするよ' : {
+    }
 }).export(module);

意味ねーっ.でも気にしないで実行!!

$ vows read-line-test.js
 

undefined »  (0.000s)

コンテキストがあっても vow がないと何も出ないのね.
さて,ここからちょっとまじめに考えましょうか.



TDD が身についてないものとしては,まず read-line の API から考えてしまうわけですが,ここはぐっと我慢してテストから考えてみます.っていうかこのエントリの主役は Vows だからね.
んっと,read-line は何行もまとめてコールバックされてくるデータを行単位にコールバックしてくれるものなので,

  • function('abc\ndef')

なんて呼び出しが,

  • function('abc')
  • function('def')

って具合になってほしいわけですが,まずは最初の呼び出しに注目すると,'abc\ndef' が 'abc' になってほしいわけなので,それをそのままテストに書いてみます.

 vows.describe('read-line').addBatch( {
     'テストするよ' : {
+        topic : 'abc\ndef',
+        '改行より前の文字列がコールバックされること' : function(topic) {
+            assert.equal(topic, 'abc');
+        }
     }
 }).export(module);

まじめに考えてこれかよ... なんて気にしないで実行します.

$ vows read-line-test.js
 

  テストするよ
    ✗ 改行より前の文字列がコールバックされること
      » expected 'abc',
        got      'abc\ndef' (==) // read-line-test.js:7Broken » 1 broken (0.004s)

まぁ当然ですね.


とりあえず,read-line モジュールにコールバック関数があると仮定して,そいつを直接呼び出すところから始めてみましょう.

     'テストするよ' : {
-        topic : 'abc\ndef',
+        topic : function() {
+            return read_line.callback('abc\ndef');
+        },
         '改行より前の文字列がコールバックされること' : function(topic) {
             assert.equal(topic, 'abc');
         }

実行します.

$ vows read-line-test.js

node.js:63
    throw e;
    ^
ReferenceError: read_line is not defined

そりゃそうだ.


そんなわけで (どんなわけで?),read-line.js を作成します.

+exports.callback = function(data) {
+    return 'abc';
+};

わざとらしい... 気がするけど気にしない!
モジュールができたので,テスト側で読み込みます.

 var vows = require('vows'), assert = require('assert');
+var read_line = require('./read-line');
 
 vows.describe('read-line').addBatch( {
     'テストするよ' : {

実行します.

$ vows read-line-test.js
·OK » 1 honored (0.002s)

うむ.



ではテストケースを増やしてみましょう.
とりあえず,トピックを増やしてみます.トピックはコンテキストに一つなので,コンテキストも増やします.

 var read_line = require('./read-line');
 
 vows.describe('read-line').addBatch( {
-    'テストするよ' : {
+    '途中に改行を含む文字列の場合' : {
         topic : function() {
             return read_line.callback('abc\ndef');
         },
         '改行より前の文字列がコールバックされること' : function(topic) {
             assert.equal(topic, 'abc');
         }
+    },
+    '改行で始まる文字列の場合' : {
+        topic : function() {
+            return read_line.callback('\nabc');
+        },
+        '空文字列がコールバックされること' : function(topic) {
+            assert.equal(topic, '');
+        }
     }
 }).export(module);

結果は分かってるけど実行します.

$ vows read-line-test.js
· 

  改行で始まる文字列の場合
    ✗ 空文字列がコールバックされること
      » expected '',
        got      'abc' (==) // read-line-test.js:18Broken » 1 honored ∙ 1 broken (0.006s)

当然です.


ちょっとだけまじめに実装します.

 exports.callback = function(data) {
-    return 'abc';
+    if (data.match(/\r?\n/)) {
+        return RegExp.leftContext;
+    }
 };

どこがまじめなんだか... でも気にしない,気にしてはいけないのだ.
気にしないで実行します.

$ vows read-line-test.js
··OK » 2 honored (0.002s)

うむ.



次へ進む前にテストコードを見直してみます.
二つのコンテキストのトピックはそれぞれ 1 行しかないとはいえ,異なるデータに対して同じ事をやっているだけで内容としては重複してます.まずはこれを取り除いてみましょう.
Vows のテストコードは JavaScript のソースファイルにすぎないので,普通に関数を定義して呼び出すこともできます.っていうか,Vows のテストを見てもそうする方が普通かも.
ですが,ここは Vows のコンテキスト/トピックを共通なコードの置き場所として利用してみます.


前日の日記で書いたように,トピックを関数として記述した場合はそれを評価した結果がトピックとなります.ということは,親コンテキストのトピックを「関数を返す関数」とすることで,返した関数が子コンテキストのトピックに渡されます.
先の二つのコンテキストに共通な親コンテキストを用意して,そのトピックとして共通な関数を返すようにしましょう.この関数は引数として read-line モジュールのコールバックを呼び出すデータを受け取り,その戻り値を返します.
こうなります (インデントがずれるので diff ではなくまるごと掲載).

vows.describe('read-line').addBatch( {
    'コールバックを直接呼び出す場合' : {
        topic : function() {
            return function(data) {
                return read_line.callback(data);
            };
        },
        '途中に改行を含む文字列の場合' : {
            topic : function(topic) {
                return topic('abc\ndef');
            },
            '改行より前の文字列がコールバックされること' : function(topic) {
                assert.equal(topic, 'abc');
            }
        },
        '改行で始まる文字列の場合' : {
            topic : function(topic) {
                return topic('\nabc');
            },
            '空文字列がコールバックされること' : function(topic) {
                assert.equal(topic, '');
            }
        }
    }
}).export(module);

親コンテキストのトピックが返す関数を抜粋すると,こうなってます.

            return function(data) {
                return read_line.callback(data);
            };

この関数が子コンテキストにトピックとして渡されます.
二つの子コンテキストのトピックは,渡された親コンテキストのトピック (上記の関数です) をそれぞれの引数を渡して呼び出し,その結果が同じコンテキストの vow にトピックとして渡されます.
実行します.

$ vows read-line-test.js
··OK » 2 honored (0.001s)


さて,昨日は書きませんでしたが,vows コマンドは引数 --spec を指定するとコンテキストやトピック,vow の説明を綺麗に出力してくれます.

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

 read-line

  コールバックを直接呼び出す場合 途中に改行を含む文字列の場合
    ✓ 改行より前の文字列がコールバックされること
  コールバックを直接呼び出す場合 改行で始まる文字列の場合
    ✓ 空文字列がコールバックされることOK » 2 honored (0.002s)

いいですね.
RSpec なんかと違って文字列で書いてるだけなので,DRY でもなければ信憑性を確かめるすべもないわけですが,英語苦手な自分にはこれでいいかな.
でもでも,「〜場合 〜場合」って変なのでちょっと修正します.

 var read_line = require('./read-line');
 
 vows.describe('read-line').addBatch( {
-    'コールバックを直接呼び出す場合' : {
+    'コールバックを直接呼び出して' : {
         topic : function() {
             return function(data) {
                 return read_line.callback(data);
             };
         },
-        '途中に改行を含む文字列の場合' : {
+        'データが途中に改行を含む文字列の場合' : {
             topic : function(topic) {
                 return topic('abc\ndef');
             },
@@ -16,7 +16,7 @@ vows.describe('read-line').addBatch( {
                 assert.equal(topic, 'abc');
             }
         },
-        '改行で始まる文字列の場合' : {
+        'データが改行で始まる文字列の場合' : {
             topic : function(topic) {
                 return topic('\nabc');
             },

実行します.

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

  コールバックを直接呼び出して データが途中に改行を含む文字列の場合
    ✓ 改行より前の文字列がコールバックされること
  コールバックを直接呼び出して データが改行で始まる文字列の場合
    ✓ 空文字列がコールバックされることOK » 2 honored (0.003s)

うむうむ.



それでは read-line の実装をもうちょっとだけマシなものにしましょう.
そもそも,read-line は自分がコールバックされたら本来のコールバック関数を呼び出すべきであって,戻り値を返して欲しいわけではありません.非同期な Node.js らしい API になっていないのです.
そこで,read-line を非同期なコールバックを受け取って呼び出すようにしてみましょう.


そのためのテストをどうするか?
昨日も書いたように,Vows は非同期なテストのための機能を用意してくれています.まずは簡単な this.callback を使ってみましょう.

 vows.describe('read-line').addBatch( {
     'コールバックを直接呼び出して' : {
         topic : function() {
-            return function(data) {
-                return read_line.callback(data);
+            return function(data, callback) {
+                read_line.callback(data, callback);
             };
         },
         'データが途中に改行を含む文字列の場合' : {
             topic : function(topic) {
-                return topic('abc\ndef');
+                return topic('abc\ndef', this.callback);
             },
             '改行より前の文字列がコールバックされること' : function(topic) {
                 assert.equal(topic, 'abc');
@@ -18,7 +18,7 @@ vows.describe('read-line').addBatch( {
         },
         'データが改行で始まる文字列の場合' : {
             topic : function(topic) {
-                return topic('\nabc');
+                return topic('\nabc', this.callback);
             },
             '空文字列がコールバックされること' : function(topic) {
                 assert.equal(topic, '');

まずは二つある子コンテキストのトピックで

            topic : function(topic) {
                return topic('abc\ndef', this.callback);
            },

と this.callback を親コンテキストのトピック (が返す関数) に渡しています.後で read-line がこの this.callback をコールバックすると,vow が呼び出されるわけですね.
そして親コンテキストのトピックは,

        topic : function() {
            return function(data, callback) {
                read_line.callback(data, callback);
            };
        },

もはや値を返さなくなりました.this.callback を使う場合のお約束です.そして this.callback を read-line に渡しています.
read-line はまだコールバックを受け取るようになってないけど,一応実行します.

$ vows read-line-test.jsErrored » read-line: コールバックを直接呼び出して データが途中に改行を含む文字列の場合  not fired!Errored » read-line: コールバックを直接呼び出して データが改行で始まる文字列の場合  not fired!Errored » 1 errored  2 dropped

だれも this.callback を呼び出していないので,イベントが発生しなかったと怒られました.


じゃあ read-line 側を修正して,コールバックを受け取って呼び出すようにしましょう.

-exports.callback = function(data) {
+exports.callback = function(data, callback) {
     if (data.match(/\r?\n/)) {
-        return RegExp.leftContext;
+        callback(RegExp.leftContext);
     }
 };

実行します.

$ vows read-line-test.js
 

  コールバックを直接呼び出して データが途中に改行を含む文字列の場合
    ✗ 改行より前の文字列がコールバックされること
      » An unexpected error was caught: abc

  コールバックを直接呼び出して データが改行で始まる文字列の場合
    ✗ 空文字列がコールバックされること
      » expected '',
	got	 undefined (==) // read-line-test.js:24Errored » 1 broken  1 errored (0.006s)

むむぅ...
そうでした,昨日も書いたように,this.callback はファイルアクセスなどと同じ

  • function(err, arg1, ...)

という形式を想定していて,第 1 引数が null または undefined でないとエラーが発生したと見なされちゃうのでした.っていうか,空文字列も成功扱いなのね.そのせいで 2 番目の vow は呼び出されてますが,渡されたのは undefined か...
しょうがないので this.callback を直接 read-line に渡すのではなく,一つ関数を挟みます.

     'コールバックを直接呼び出して' : {
         topic : function() {
             return function(data, callback) {
-                read_line.callback(data, callback);
+                read_line.callback(data, function(data) {
+                    callback(null, data);
+                });
             };
         },
         'データが途中に改行を含む文字列の場合' : {

実行します.

$ vows read-line-test.js
··OK » 2 honored (0.002s)

うむ,うむ.


ここまでのテストコード全体をここで掲載しておきます.

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

vows.describe('read-line').addBatch( {
    'コールバックを直接呼び出して' : {
        topic : function() {
            return function(data, callback) {
                read_line.callback(data, function(data) {
                    callback(null, data);
                });
            };
        },
        'データが途中に改行を含む文字列の場合' : {
            topic : function(topic) {
                return topic('abc\ndef', this.callback);
            },
            '改行より前の文字列がコールバックされること' : function(topic) {
                assert.equal(topic, 'abc');
            }
        },
        'データが改行で始まる文字列の場合' : {
            topic : function(topic) {
                return topic('\nabc', this.callback);
            },
            '空文字列がコールバックされること' : function(topic) {
                assert.equal(topic, '');
            }
        }
    }
}).export(module);



さて,一つ目のテストでは 'abc\ndef' という 2 行分のデータを渡しているのに,コールバックされるのは 1 回だけです.ここはちゃんと 2 回呼び出して欲しいですよね.
それをテストするには... コールバックされたらすぐに this.callback するのではなく,コールバックされたデータを貯めておいて,まとめて vow に渡すのがよさそうです.
やりましょう.

     'コールバックを直接呼び出して' : {
         topic : function() {
             return function(data, callback) {
+                var lines = [];
                 read_line.callback(data, function(data) {
-                    callback(null, data);
+                    lines.push(data);
                 });
+                callback(null, lines);
             };
         },
         'データが途中に改行を含む文字列の場合' : {

これで行単位に分割されたデータを配列にため込んでテストすることができるはず.
まだ vow を直してないけど実行!!

$ vows read-line-test.js
··OK » 2 honored (0.002s)

あれ? 予想外の成功.なぜ?
もしかして JavaScript では [ 'abc' ] と 'abc' は同値なの?

                assert.equal(['abc'], 'abc');

ってしてみたら本当に同値だった.知らなかったよ.俺の JavaScript 力,パネェ...


気を取り直して vow を修正します.配列のテストには assert.deepEqual() が使えるのですが,ここはちょっと頑張って「一つのトピックに複数の vow」でやってみます.

             topic : function(topic) {
                 return topic('abc\ndef', this.callback);
             },
-            '改行より前の文字列がコールバックされること' : function(topic) {
-                assert.equal(topic, 'abc');
+            '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('\nabc', this.callback);
             },
-            '空文字列がコールバックされること' : function(topic) {
-                assert.equal(topic, '');
+            '2回コールバックされること' : function(topic) {
+                assert.length(topic, 2);
+            },
+            '最初に空行がコールバックされること' : function(topic) {
+                assert.equal(topic[0], '');
+            },
+            '次に改行の後の文字列がコールバックされること' : function(topic) {
+                assert.equal(topic[1], 'abc');
             }
         }
     }

assert.length() は標準の assert モジュールを Vows が拡張して追加してくれているものです.

実行します.

$ vows read-line-test.js
·✗✗· 

  コールバックを直接呼び出して データが途中に改行を含む文字列の場合
    ✗ 2回コールバックされること
      » expected [ 'abc' ] to have 2 element(s) // read-line-test.js:20次に改行の後の文字列がコールバックされること
      » expected 'def',
	got	 undefined (==) // read-line-test.js:26

  コールバックを直接呼び出して データが改行で始まる文字列の場合
    ✗ 2回コールバックされること
      » expected [ '' ] to have 2 element(s) // read-line-test.js:34次に改行の後の文字列がコールバックされること
      » expected 'abc',
	got	 undefined (==) // read-line-test.js:40Broken » 2 honored  4 broken (0.008s)

今度はいい感じでテストが失敗しました♪
そんなわけで (どんなわけで?),read-line を修正して行の数だけコールバックするようにします.

 exports.callback = function(data, callback) {
-    if (data.match(/\r?\n/)) {
+    while (data.match(/\r?\n/)) {
         callback(RegExp.leftContext);
+        data = RegExp.rightContext;
     }
+    callback(data);
 };

テストを実行します.

$ vows read-line-test.js
······OK » 6 honored (0.003s)

ぃえい!
--spec してみます.

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

 read-line

  コールバックを直接呼び出して データが途中に改行を含む文字列の場合
    ✓ 2回コールバックされること最初に改行より前の文字列がコールバックされること次に改行の後の文字列がコールバックされること
  コールバックを直接呼び出して データが改行で始まる文字列の場合
    ✓ 2回コールバックされること最初に空行がコールバックされること次に改行の後の文字列がコールバックされること
 
✓ OK » 6 honored (0.003s)

ね,昨日 Vows を選んだ理由にちょっとだけカッコいいって書いた感じが出てきたでしょ?



始めの方で

  • function('abc\ndef')

なんて呼び出しが,

  • function('abc')
  • function('def')

って具合になって欲しいと書きましたが,本当はこれではダメです.
'def' の後ろには改行がないので,これは即座にコールバックに渡されるのではなく,次のイベントのデータとくっついてコールバックされるべきです.そのデータに残りがあれば,それもまたその次のイベントのデータとくっついて... 以下繰り返し.
そしてストリームが (クローズや EOF などで) 終了したら,残っていたデータがコールバックされるべきです.


どうやってテストを書きましょう?
'data' イベントを複数回生成して,最後に 'end' イベントを生成する,偽物の ReadableStream があるとよさそうです.
そこで,Node.js のイベントソースの元締め,EventEmitter を使いましょう.
実のところ,ReadableStream を含めた全てのストリームは EventEmitter なのです.

具体的には,EventEmitter とコールバック関数を read-line に渡すと,read-line の二つのコールバック関数が EventEmitter に登録されて,'data' イベントが発生するごとに行単位で区切られた文字列を引数として呼び出し側のコールバック関数が呼ばれる.最後に 'end' イベントが発生すると,残っていた文字列を引数として呼び出し側のコールバック関数が呼ばれる.
そんな感じ.


それでは親コンテキストのトピックがどうなるか考えてみましょう.
まずは EventEmitter を使うので,

var events = require('events');

が必要です.次に EventEmitter を生成します.

var emitter = new (events.EventEmitter);

そしてイベントソースである EventEmitter と,行単位のデータを受け取るコールバックを read-line に渡します.
いい加減,read-line 側の関数の名前 (API) もちゃんと考えないといけませんね.'data' イベントに対して行単位にコールバックしてもらう関数を登録するという気持ちなので,onDataByLine() とでもしておきましょう.

read_line.onDataByLine(emitter, function(line) {
    lines.push(line);
});

なんか,アンスコ区切りとキャメル記法が混じっちゃったけど気にしない!
このコールバックの引数はさっきまで data という名前でしたが,行単位であることを強調して line に名前を変更しました.最初からそうしておけばよかった.
そして 'data' イベントを生成します.

emitter.emit('data', data);

最後に 'end' イベントを生成します.

emitter.emit('end');

これで実際の ReadableStream を使った場合と同じように read-line を動かすことができそうです.
まとめるとこうなりました.

-var vows = require('vows'), assert = require('assert');
+var vows = require('vows'), assert = require('assert'), events = require('events');
 var read_line = require('./read-line');
 
 vows.describe('read-line').addBatch( {
@@ -6,9 +6,12 @@ vows.describe('read-line').addBatch( {
         topic : function() {
             return function(data, callback) {
                 var lines = [];
-                read_line.callback(data, function(data) {
-                    lines.push(data);
+                var emitter = new (events.EventEmitter);
+                read_line.onDataByLine(emitter, function(line) {
+                    lines.push(line);
                 });
+                emitter.emit('data', data);
+                emitter.emit('end');
                 callback(null, lines);
             };
         },

案外変わってないような気もしちゃいますね.でも,read_line.onDataByLine() は一応目的の API になったはず.
では実行します.

$ vows read-line-test.js
node.js:63
    throw e;
    ^
TypeError: Object # has no method 'onDataByLine'

そんなわけで,read-line 側の callback を onDataByLine() に変更して ReadableStream に対してイベントハンドラを登録するようにします.

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

line という変数が前のデータの残りを保持しているので,'data' のイベントハンドラではそれと新たに受信したデータをくっつけてから,改行までの部分文字列を引数としてコールバックを呼び出しています.'end' のイベントハンドラではその line を引数としてコールバックを呼び出しています.
では実行します.

$ vows read-line-test.js
······OK » 6 honored (0.003s)

ぃえぃえい!!



でもでも,なんだか変です.

    'コールバックを直接呼び出して' : {

もう read-line のコールバックを直接呼んでないし.

        topic : function() {
            return function(data, callback) {
                var lines = [];
                var emitter = new (events.EventEmitter);
                readine.onDataByLine(emitter, function(line) {
                    lines.push(line);
                });
                emitter.emit('data', data);
                emitter.emit('end');
                callback(null, lines);
            };
        },

それから,いつの間にか this.callback を呼び出す必要がなくなってます.だって read-line に渡すコールバック関数が呼ばれたら vow が呼ばれるようにしたくて導入したのに,今やコールバック関数の中で使ってませんからね.いつの間に...?
そうか,lines を導入してからか.気づかなかったよ.心より恥じる.これがうまく動いてたのは,EventEmitter の emit() が非同期なイベントを生成しているのではなく,その場でコールバックを同期的に呼び出しているからですね.
そんなわけで (どんなわけで?),コメントを修正して this.callback を呼び出す代わりに直接配列を返すようにします.

 var read_line = require('./read-line');
 
 vows.describe('read-line').addBatch( {
-    'コールバックを直接呼び出して' : {
+    '偽のEventEmitterを使って' : {
         topic : function() {
-            return function(data, callback) {
+            return function(data) {
                 var lines = [];
                 var emitter = new (events.EventEmitter);
                 read_line.onDataByLine(emitter, function(line) {
@@ -12,12 +12,12 @@ vows.describe('read-line').addBatch( {
                 });
                 emitter.emit('data', data);
                 emitter.emit('end');
-                callback(null, lines);
+                return lines;
             };
         },
         'データが途中に改行を含む文字列の場合' : {
             topic : function(topic) {
-                return topic('abc\ndef', this.callback);
+                return topic('abc\ndef');
             },
             '2回コールバックされること' : function(topic) {
                 assert.length(topic, 2);
@@ -31,7 +31,7 @@ vows.describe('read-line').addBatch( {
         },
         'データが改行で始まる文字列の場合' : {
             topic : function(topic) {
-                return topic('\nabc', this.callback);
+                return topic('\nabc');
             },
             '2回コールバックされること' : function(topic) {
                 assert.length(topic, 2);

実行します.

$ vows read-line-test.js
······OK » 6 honored (0.003s)

--spec もしてみます.

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

 read-line

  偽のEventEmitterを使って データが途中に改行を含む文字列の場合
    ✓ 2回コールバックされること最初に改行より前の文字列がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って データが改行で始まる文字列の場合
    ✓ 2回コールバックされること最初に空行がコールバックされること次に改行の後の文字列がコールバックされること
 
✓ OK » 6 honored (0.003s)

うむ,うむ.



次へ進む前に,テストを増やしてみます (量が多いので vow 丸ごと掲載).

        'データが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');
            }
        }

実行します.

$ vows read-line-test.js
✗✗✗············· 

  偽のEventEmitterを使って データがundefinedの場合
    ✗ 1回もコールバックされないこと
      » expected [ 'undefined' ] to have 0 element(s) // read-line-test.js:23

  偽のEventEmitterを使って データがnullの場合
    ✗ 1回もコールバックされないこと
      » expected [ 'null' ] to have 0 element(s) // read-line-test.js:31

  偽のEventEmitterを使って データが空文字列の場合
    ✗ 1回もコールバックされないこと
      » expected [ '' ] to have 0 element(s) // read-line-test.js:39

  偽のEventEmitterを使って データが改行で終了する文字列の場合
    ✗ 1回だけコールバックされること
      » expected [ 'abcdef', '' ] to have 1 element(s) // read-line-test.js:72Broken » 13 honored  4 broken (0.009s)

ぐはぁっ...
そうですね,read-line が何もチェックしてませんからね.ガードを入れます.

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

実行します.

$ vows read-line-test.js
·················OK » 17 honored (0.004s)

--spec します.

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

 read-line

  偽のEventEmitterを使って データがundefinedの場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って データがnullの場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って データが空文字列の場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って データが改行を含まない文字列の場合
    ✓ 1回だけコールバックされることデータ全体を文字列としてコールバックされること
  偽のEventEmitterを使って データが改行で始まる文字列の場合
    ✓ 2回コールバックされること最初に空行がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って データが改行で終了する文字列の場合
    ✓ 1回だけコールバックされること改行より前の文字列がコールバックされること
  偽のEventEmitterを使って データが途中に改行を含む文字列の場合
    ✓ 2回コールバックされること最初に改行より前の文字列がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って データが途中に改行を二つ含む文字列の場合
    ✓ 3回コールバックされること最初に一番目の改行より前の文字列がコールバックされること次に一番目の改行と二番目の改行の間の文字列がコールバックされること最後に二番目の改行より後の文字列がコールバックされることOK » 17 honored (0.004s)

うむうむ,なんかできてきた感じ♪



さて,ここまでは 'data' イベントが 1 回しか発生していません.次は複数回発生させましょう.それには,子コンテキストのトピックから親コンテキストのトピック (が返す関数) に渡す引数を配列にすれば良さそうです.

topic : function(topic) {
    return topic( [ 'abc', 'def' ]);
}

みたいな.
そして親のトピック (が返す関数) は,引数が配列だったら繰り返し 'data' イベントを生成します.
こうなりました (量が多いので diff ではなく抜粋を掲載).

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回だけデータ受信イベントが発生し' : {
            // さっきまでのテストケースなので省略
        },
        '2回データ受信イベントが発生し' : {
            'どのデータにも改行が含まれない場合' : {
                topic : function(topic) {
                    return topic( [ 'abc', 'def' ]);
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                '1番目のデータと2番目のデータを連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            '最初のイベントにだけ改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc\ndef', 'ghi' ]);
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に1番目のデータの改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に1番目のデータの改行より後と2番目のデータを連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'defghi');
                }
            },
            '2番目のイベントにだけ改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc', 'def\nghi' ]);
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に1番目のデータと2番目のデータの改行より前を連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                },
                '次に2番目のデータの改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'ghi');
                }
            },
            '1番目と2番目の両方のイベントに改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc\ndef', 'ghi\njkl' ]);
                },
                '3回コールバックされること' : function(topic) {
                    assert.length(topic, 3);
                },
                '最初に1番目のデータの改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に1番目のデータの改行より後と,2番目のデータの改行より前を連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'defghi');
                },
                '最後に2番目のデータの改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[2], 'jkl');
                }
            }
        }
    }
}).export(module);

実行します.

$ vows read-line-test.js
·····························OK » 29 honored (0.012s)

--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を使って 複数回データ受信イベントが発生し どのデータにも改行が含まれない場合
    ✓ 1回だけコールバックされること一番目のデータと二番目のデータを連結した文字列がコールバックされること
  偽のEventEmitterを使って 複数回データ受信イベントが発生し 最初のイベントにだけ改行が含まれる場合
    ✓ 2回コールバックされること最初に一番目のデータの改行より前の文字列がコールバックされること次に一番目のデータの改行より後と二番目のデータを連結した文字列がコールバックされること
  偽のEventEmitterを使って 複数回データ受信イベントが発生し 2番目のイベントにだけ改行が含まれる場合
    ✓ 2回コールバックされること最初に一番目のデータと二番目のデータの改行より前を連結した文字列がコールバックされること次に二番目のデータの改行より後の文字列がコールバックされること
  偽のEventEmitterを使って 複数回データ受信イベントが発生し 1番目と2番目の両方のイベントに改行が含まれる場合
    ✓ 3回コールバックされること最初に一番目のデータの改行より前の文字列がコールバックされること次に一番目のデータの改行より後と,二番目のデータの改行より前を連結した文字列がコールバックされること最後に二番目のデータの改行より後の文字列がコールバックされることOK » 29 honored (0.005s)

なんか満足感が得られた.


ここまでで本来のエントリの 6〜7 割だったのですが,長すぎるのか何度サブミットしても途中で切れてしまうので (苦笑),この続きは「その 3」とします.明日のエントリにした方が無難かな?
サブミットできないならプレビューの段階で教えてよ>はてな
続きの内容は,本物のファイルを使ったテストの追加です.