Javascriptテストフレームワーク Jasmineを試す

日頃からJavascriptで開発をしているのにも関わらずあまりテストを書かないので、ここは本格的にテストを書こうと調べてみました。JavascriptのテストフレームワークといったらJsUnitなのかなーと思っていたが、調べてみると結構いろんな種類のテストフレームワークがあったりして、その中で得に人気なのかどうやらJasmineらしい。

Jasmine ~ JavaScript Test フレームワーク より引用:
今回は, JavaScript のテストを行うためのフレームワークJasmine の紹介です。
JavaScript のテストといえば, JSUnit が有名です。
JSUnit は, JUnit とに似たような, Matcher が利用できたりしてわかりやすいのですが,
開発やメンテナンスがストップしており, またWebプロジェクトに組み込まないと利用できないことが
ちょっと残念です。

JUnit のページでも紹介があるように, 今後は Jasmine というフレームワークを開発していくようです。

なるほど、JsUnitはかつて人気だったテストフレームワークだったけども開発ストップしてて古くなっており、今はJasmineということなのか。

とりあえずやってみた

しかし、初めて触るライブラリというのはどうにもこうにも敷居が高い。少しずつ調べてみる。なんにせよまずはインストールだ。

公式ページ: http://pivotal.github.com/jasmine/
公式ページ(の翻訳): http://mitsuruog.github.com/jasmine/
ダウンロード: https://github.com/pivotal/jasmine/downloads

ダウンロードしたファイルは、jasmine-standalone-1.3.1.zip です。

二種類の使い方

Jasmineは使い方が二種類あるそうです。

  • standalone
  • rubygems + rake

standaloneはRubyを使わずにJavascriptだけで使える環境、 rubygems + rake はテストを自動化させたい場合はこちらを選ぶ。ただ、standaloneでもPhantomJSを使えば自動化できるようです。

ここではstandaloneについて実験

rubygemsを使った方法だと、テストを実行させるためのHTMLを自動生成してくれて大変便利らしいのですが、ここでは導入としてstandaloneを使ったやりかたについて試してみたいとおもいます。

ディレクトリの構造はこのようになっています。

.
├── SpecRunner.html
├── lib
│   └── jasmine-1.3.1
│   ├── MIT.LICENSE
│   ├── jasmine-html.js
│   ├── jasmine.css
│   └── jasmine.js
├── spec
│   ├── PlayerSpec.js
│   └── SpecHelper.js
└── src ├── Player.js └── Song.js

これをWebサーバーにあげて、SpecRunner.html を開いてみます。

スクリーンショット 2013-01-03 3.24.32

なるほど、わからん。

見た感じ、上の5つの丸がテストの成功か失敗かを表し、下段の黒字がテストスイート(カテゴリ分け)、緑の文字がテストケースという具合のようです。

Specの意味

「仕様」 = テストケース

Suiteの意味

「一式(分類)」 = テストスイート

SpecRunner.htmlの中身を見る

SpecRunner.htmlの中身を見てみます。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head> <title>Jasmine Spec Runner</title> <link rel="shortcut icon" type="image/png" href="lib/jasmine-1.3.1/jasmine_favicon.png"> <link rel="stylesheet" type="text/css" href="lib/jasmine-1.3.1/jasmine.css"> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine.js"></script> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine-html.js"></script> <!-- include source files here... --> <script type="text/javascript" src="src/Player.js"></script> <script type="text/javascript" src="src/Song.js"></script> <!-- include spec files here... --> <script type="text/javascript" src="spec/SpecHelper.js"></script> <script type="text/javascript" src="spec/PlayerSpec.js"></script> <script type="text/javascript"> (function() { var jasmineEnv = jasmine.getEnv(); jasmineEnv.updateInterval = 1000; var htmlReporter = new jasmine.HtmlReporter(); jasmineEnv.addReporter(htmlReporter); jasmineEnv.specFilter = function(spec) { return htmlReporter.specFilter(spec); }; var currentWindowOnload = window.onload; window.onload = function() { if (currentWindowOnload) { currentWindowOnload(); } execJasmine(); }; function execJasmine() { jasmineEnv.execute(); } })(); </script>
</head>
<body>
</body>
</html>

よくわからなかったのでコメントを振ってみる

よくわからなかったのでコメントを振ってみます。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head> <title>Jasmine Spec Runner</title> <!-- ショートカットアイコン --> <link rel="shortcut icon" type="image/png" href="lib/jasmine-1.3.1/jasmine_favicon.png"> <!-- スタイルシート --> <link rel="stylesheet" type="text/css" href="lib/jasmine-1.3.1/jasmine.css"> <!-- Jasmineの読み込み --> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine.js"></script> <!-- Jasmineの読み込み --> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine-html.js"></script> <!-- テストを行う対象ファイルを読み込み --> <script type="text/javascript" src="src/Player.js"></script> <script type="text/javascript" src="src/Song.js"></script> <!-- 独自定義のマッチャを読み込み --> <script type="text/javascript" src="spec/SpecHelper.js"></script> <!-- テストケースファイルを読み込み --> <script type="text/javascript" src="spec/PlayerSpec.js"></script> <script type="text/javascript"> (function() { // Jasmineの環境設定オブジェクトを読み込み var jasmineEnv = jasmine.getEnv(); // アップデートの間隔を1秒に設定? jasmineEnv.updateInterval = 1000; // テスト結果を取得するためのオブジェクトを取得 var htmlReporter = new jasmine.HtmlReporter(); // 不明・・・ jasmineEnv.addReporter(htmlReporter); // 個別のテストスイートやテストケースをクリックすることで、テストスイートの一部分のみを実行して結果を取得する jasmineEnv.specFilter = function(spec) { return htmlReporter.specFilter(spec); }; // ウィンドウをリロードするオブジェクト生成 var currentWindowOnload = window.onload; window.onload = function() { if (currentWindowOnload) { currentWindowOnload(); } // テスト実行 execJasmine(); }; // テスト実行関数の定義 function execJasmine() { jasmineEnv.execute(); } })(); </script>
</head>
<body>
</body>
</html>

このファイルは、テストを行いたいJavascriptを読み込み、それに対してテストを定義し実行するというもののようです。しかし、「jasmineEnv.updateInterval = 1000;」という行が気になった。これはもしかして1秒ごとにテストを実行するということなのだろうか。もしそうなら、このHTMLファイルを開きっぱなしで常にテスト結果を確認できるということなのでとても嬉しいのだけれど・・。

バグらせてみる

まだなんだか感覚がつかめないので、テスト対象のファイルをバグらせてみることにします。

Player.jsをバグらせる。

function Player() {
}
Player.prototype.play = function(song) { this.currentlyPlayingSong = song; this.isPlaying = true;
};
Player.prototype.pause = function() { this.isPlaying = false;
};
Player.prototype.resume = function() { if (this.isPlaying) { throw new Error("song is already playing"); } this.isPlaying = true;
};
Player.prototype.makeFavorite = function() { this.currentlyPlayingSong.persistFavoriteStatus(true);
};

中身はとてもシンプルで、本当にプレイヤー機能が入っているのかと思えばそんなことはなくて、模倣したオブジェクトのようです。

コメントを振ってみます。

function Player() {
}
// 再生する
Player.prototype.play = function(song) { this.currentlyPlayingSong = song; this.isPlaying = true;
};
// ポーズする
Player.prototype.pause = function() { this.isPlaying = false;
};
// 途中から再生する
Player.prototype.resume = function() { if (this.isPlaying) { throw new Error("song is already playing"); } this.isPlaying = true;
};
// お気に入りに登録
Player.prototype.makeFavorite = function() { this.currentlyPlayingSong.persistFavoriteStatus(true);
};

お気に入りに登録のコメントが合っているのか不安ですが、これの途中から再生するをバグらせてみます。

...
// 途中から再生する
Player.prototype.resume = function() { if (this.isPlaying) { throw new Error("song is already "); } this.isPlaying = true;
};
...

「song is already playing」からplaying を消してみました。これでいったいなにが起きるか・・・。
先ほどの SpecRunner.html を開いて確認してみます。

バグがある場合のテスト結果

スクリーンショット 2013-01-03 4.10.31

おお! なんか出ました!エラーがでました!これはエラー詳細ページで、全体のテストケース一覧を見るには「5 specs」をクリックします。

スクリーンショット 2013-01-03 4.11.31

エラーがあるテストケースは赤字になっています。
また、自動的にリロードしてくれるのか期待していたのですが、自動リロード機能はないようなので、ブラウザの自動リロード機能を使って常に監視しておくといいです。

テストのほうを弄る

テストケースが定義されているファイルをいじってみます。弄るファイルはspecフォルダにある「PlayerSpec.js」です。

中身はこんなかんじ

describe("Player", function() { var player; var song; beforeEach(function() { player = new Player(); song = new Song(); }); it("should be able to play a Song", function() { player.play(song); expect(player.currentlyPlayingSong).toEqual(song); //demonstrates use of custom matcher expect(player).toBePlaying(song); }); describe("when song has been paused", function() { beforeEach(function() { player.play(song); player.pause(); }); it("should indicate that the song is currently paused", function() { expect(player.isPlaying).toBeFalsy(); // demonstrates use of 'not' with a custom matcher expect(player).not.toBePlaying(song); }); it("should be possible to resume", function() { player.resume(); expect(player.isPlaying).toBeTruthy(); expect(player.currentlyPlayingSong).toEqual(song); }); }); // demonstrates use of spies to intercept and test method calls it("tells the current song if the user has made it a favorite", function() { spyOn(song, 'persistFavoriteStatus'); player.play(song); player.makeFavorite(); expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true); }); //demonstrates use of expected exceptions describe("#resume", function() { it("should throw an exception if song is already playing", function() { player.play(song); expect(function() { player.resume(); }).toThrow("song is already playing"); }); });
});

うおお..よくわからない…

RSpecというruby界隈の方にはお馴染みの書き方のようです。そしてこの書き方がテストをやる上ですごいやりやすいんだとか。
また同じようにコメントを振ってみます。

// テストスイート定義
describe("プレイヤー", function() { // テストを行うJavascriptのオブジェクトを読みこませる変数の宣言 var player; var song; // テストを開始する準備を行う beforeEach(function() { // テスト対象となるオブジェクトの読み込み player = new Player(); song = new Song(); }); // テストケースを定義 it("テストケース名", 無名関数) it("曲を再生することができる", function() { // 音楽を再生 player.play(song); // player.currentlyPlayingSong が song と同値であることを期待する expect(player.currentlyPlayingSong).toEqual(song); // プレイヤーが再生中であることを期待する -> SpecHelper.js に定義されています expect(player).toBePlaying(song); }); // テストスイート定義(階層になっていて見やすい) describe("曲が一時停止されたときの挙動", function() { // テストを開始する準備を行う beforeEach(function() { // プレイヤーを再生 player.play(song); // プレイヤーをポーズ player.pause(); }); // テストケース定義 it("曲が一時停止しているか確認する", function() { // player.isPlaying が false になっている expect(player.isPlaying).toBeFalsy(); // プレイヤーが再生されているマッチャに対しnotメソッドによって評価が逆になり、プレイヤーは音楽を再生していない となる expect(player).not.toBePlaying(song); }); it("途中から再生可能かどうか", function() { // プレイヤーをレジュームする player.resume(); // player.isPlaying は true になっている expect(player.isPlaying).toBeTruthy(); // player.currentlyPlayingSong は song と 同じ値であることを期待する expect(player.currentlyPlayingSong).toEqual(song); }); }); // テストケース定義 it("ユーザーがお気に入りにしていた場合、その曲を教える", function() { // オブジェクトのメソッドの呼び出しをスパイを使って監視 spyOn(song, 'persistFavoriteStatus'); // プレイヤーを再生 player.play(song); // プレイヤーがお気に入りに設定(内部で this.currentlyPlayingSong.persistFavoriteStatus(true); が実行されます ) player.makeFavorite(); // song.persistFavoriteStatusメソッド は 引数に true を伴って実行されたか expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true); }); // テストスイート定義 describe("#レジューム", function() { it("曲が既に再生されている場合に例外をスルーする", function() { // プレイヤーを再生 player.play(song); // プレイヤーをレジュームしてその例外が同じものであることを期待する -> player.resume(); では throw new Error("song is already"); として例外が送出されている。 expect(function() { // レジューム player.resume(); }).toThrow("song is already playing"); }); });
});

頭を使いすぎた・・・。だいたいこんなかんじで合っているとおもいます。
流れはコメントの通りで、expect の期待が外れると、そのテストケースは「失敗」となるようです。describeによるテストスイートのカテゴリ分けや階層化もなかなかおもしろいと思いました。

そして、とくに気になった点を2つあげると、独自定義マッチャと、スパイ機能です。これについては次に書きます。そしてその前に、マッチャってなんだ・・・・・。

マッチャ

マッチャとは、「AはBであることを期待する」というように正しいかどうかを評価するためのものです。
マッチャはいくつかの種類があります。

expect(x).toEqual(y);xとyが等しいことを期待する
expect(x).toBe(y);xとyが同じオブジェクトであることを期待する
expect(x).toMatch(pattern);文字列または正規表現パターンでxと比較し、一致することを期待する
expect(x).toBeDefined();xがundefinedではない場合ことを期待する
expect(x).toBeUndefined();xがundefinedであることを期待する
expect(x).toBeNull();xがnullであることを期待する
expect(x).toBeTruthy();xがtrueであることを期待する
expect(x).toBeFalsy();xがfalseであることを期待する
expect(x).toContain(y);配列化か文字列であるxに対して、yが含まれていることを期待する
expect(x).toBeLessThan(y);xがy未満であることを期待する
expect(x).toBeGreaterThan(y);xがyよりも大きいことを期待する
expect(function(){fn();}).toThrow(e);無名関数が実行された時に関数fnが例外eを投げることを期待する
.not.(matcher)(matcher)に他のマッチャを指定することでそのマッチャを逆に評価します(trueをfalseに falseをtrueに)

独自マッチャの定義

マッチャは予め定められたものだけではなく、「SpecHelper.js」によって独自に定義することもできます。

// テストを実行する準備をする
beforeEach(function() { // マッチャの追加 this.addMatchers({ //toBePlayingを追加 toBePlaying: function(expectedSong) { // this.actual は expect(player).toBePlaying(song); の player の部分 var player = this.actual; // 評価 return player.currentlyPlayingSong === expectedSong && player.isPlaying; } });
});

スパイ

次はスパイを見ていきます。

 // テストケース定義 it("ユーザーがお気に入りにしていた場合、その曲を教える", function() { // オブジェクトのメソッドの呼び出しをスパイを使って監視 spyOn(song, 'persistFavoriteStatus'); // プレイヤーを再生 player.play(song); // プレイヤーがお気に入りに設定(内部で this.currentlyPlayingSong.persistFavoriteStatus(true); が実行されます ) player.makeFavorite(); // song.persistFavoriteStatusメソッド は 引数に true を伴って実行されたか expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true); });

スパイのテストケースはこうなっています。「spyOn(song, ‘persistFavoriteStatus’);」で song オブジェクトの persistFavoriteStatusメソッドを監視します。

songのpersistFavoriteStatusメソッドはこのようになっています。

function Song() {
}
Song.prototype.persistFavoriteStatus = function(value) { // something complicated throw new Error("not yet implemented");
};

「player.play(song);」でプレイヤーを再生するときには特に内部でスパイは活動を行いませんが、「player.makeFavorite();」で活動を行います。

makeFavoriteメソッドは内部ではこのようになっており、「this.currentlyPlayingSong」には 「player.play(song);」内部で定義された songオブジェクトが入っているため、「persistFavoriteStatus」が実行できます。

// お気に入りに登録
Player.prototype.makeFavorite = function() { this.currentlyPlayingSong.persistFavoriteStatus(true);
};

このときに、スパイが活動を始め、このメソッドが実行されたことをしっかりと記憶します。

そして、

// song.persistFavoriteStatusメソッド は 引数に true を伴って実行されたか expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true);

スパイの活動を見るために、toHaveBeenCalledWithメソッドがスパイから songオブジェクトがpersistFavoriteStatusメソッドを実行したときに引数にtrueを使用したかを聞き出します。

true が引数に使われたことがわかったので、これで期待通りの結果になっているということがわかります。

スパイのためのマッチャ

このように toHaveBeenCalledWith のようなスパイのためのマッチャがいくつか用意されています。

expect(x).toHaveBeenCalled()xメソッドがスパイ中に呼び出されていたメソッドであることを期待
expect(x).toHaveBeenCalledWith(arguments)xメソッドがスパイ中に呼び出された時にそのメソッドに使用していた引数がargumentsであることを期待
expect(x).not.toHaveBeenCalled()xメソッドがスパイ中に呼び出されなかった
expect(x).not.toHaveBeenCalledWith(arguments)xメソッドがスパイ中に呼び出された時にそのメソッドに使用していた引数がargumentsでないことを期待

さまざまなスパイの呼び出し方法

スパイが監視しているメソッドの実行を検知したときに細かい挙動を指定することができます。

spyOn(x, ‘method’).andCallThrough();デフォルト機能。スパイ活動を開始します。
spyOn(x, ‘method’).andReturn(arguments);スパイが呼び出されたときに決められた引数であるargumentsを返します。
spyOn(x, ‘method’).andThrow(exception);スパイが呼び出された時に渡された例外をスルーします。
spyOn(x, ‘method’).andCallFake(function);スパイが呼び出された時に指定された関数へ実行を移譲します。

最後に

とても使いやすくて、ひとつのHTMLページに詰め込むこともできるし、デバッグモードをONにした場合にのみ表示なんてことも不可能ではないので、いろんな応用が効くテストフレームワークだと思いました。こんなに柔軟性のあるテストフレームワークだということに今更ながら驚いています。

スクリーンショット 2013-01-03 5.57.54