タイトルは「テストとカバレッジが必要だ」ではなく、「Require.jsとテストとカバレッジ」のお話です 😛
ここしばらくの試行錯誤によって、Require.jsを使ってWebアプリを実装してテストとコードカバレッジを計測するやり方が手元で整ってきたので、それをご紹介したいと思います。
ここではサンプルとして、「JSONで書いた設定ファイルのメッセージをブラウザで表示する」というアプリを作ってみます。
アプリ本体では、以下のライブラリを使います。
まずはアプリのファイルツリーを決めましょう。今回は以下のようにします。
.
├── index.html
├── scripts
│ ├── main.js
│ └── message.js
└── vendor
├── jquery-2.1.3.min.js
└── require-2.1.17.min.js
実際のファイルをGithubに置いておきましたので、以下の要領で(ほぼ)同じ物をお手元に持ってこられます(コマンド実行時の細かい出力は省略しています)。
kuroda@charlie:~/tmp$ git clone https://github.com/hiraku/js-bdd-example.git
Cloning into 'js-bdd-example'...
kuroda@charlie:~/tmp$ cd js-bdd-example/
kuroda@charlie:~/tmp/js-bdd-example$ git checkout 0095c9f4
Note: checking out '0095c9f4'.
kuroda@charlie:~/tmp/js-bdd-example$ tree
.
├── README.md
├── index.html
├── scripts
│ ├── main.js
│ └── message.js
└── vendor
├── jquery-2.1.3.min.js
└── require-2.1.17.min.js
ライブラリをご自分で用意する場合は、それぞれ以下のサイトからダウンロードしてください。
アプリの画面になる index.html は、次のとおりです。
<!DOCTYPE html>
<html>
<head>
<title>Example</title>
<script data-main="scripts/main" src="vendor/require-2.1.17.min.js"></script>
</head>
<body>
<h1 class="message"></h1>
</body>
</html>
Require.jsを使うので、基本的に script 要素は以下の1つだけになります。
<script src="vendor/require-2.1.17.min.js" data-main="scripts/main"></script>
data-main 属性には、最初にロードする自前のスクリプトを、末尾の .js を除いて書くことで指定します。今回は scripts/main.js なので scripts/main と書きます。
その scripts/main.js はひとまず以下のように書いてみました。
require.config({
paths: {
jquery: "../vendor/jquery-2.1.3.min"
}
});
require(["jquery", "message"], function($, Message){
});
RequireJSの require 関数や define 関数で使用したいモジュールを指定すると、さきほどの data-main 属性で指定したjsファイルのあるディレクトリを基準ディレクトリにして探します。今回だと scripts/ の下から探します。
指定するモジュールの名前は、jsファイルの名前から末尾の .js を除いたものになります。
モジュール名には "foo/bar/module-name" とかのサブディレクトリも使えます。
require.config関数の paths で特定のモジュール(ライブラリ)だけ別の場所のファイルを使うように指定することもできます。
paths でも、実際のjsファイル名から.jsを除いたものを指定します。
require や define 関数でモジュール名ではなく.js付きのファイル名をそのまま書くと、呼び出し元のhtmlがあるディレクトリを基準に探します。
例えば今回の例だと、 "jquery" の代わりに "vendor/jquery-2.1.3.min.js" と指定することもできます。
require 関数に渡すコールバック関数の中にアプリの処理を書きます。コールバックの引数には、1つ目の引数で指定したモジュールが入ってきます。
上の例では reuqire 関数で "message" というモジュールも指定していますが、これが実際にメッセージを設定ファイルから読んで指定した要素に書き出すモジュールで、これから作っていくものになります。
とりあえず以下のように空のモジュールを書いて scripts/message.js として保存します。
define(["jquery"], function($){
console.log("in message.js", $("body").jquery);
});
とはいえ、本当に空っぽだとちゃんとロードされたか分からないので、 console.log でjQueryのバージョン番号を出力するようにしてみました。
この段階で index.html をブラウザで開くと、開発コンソールに
in message.js 2.1.3
と表示されるはずです。
それでは改めて、 Message モジュールの機能を検討します。
今回は
というアプリなので、以下の関数があれば良さそうです。
message.get() : 設定ファイルからロードしたメッセージを this.text に格納するmessage.put(element) : 引数に与えた element (jQueryの要素オブジェクト)の中にthis.text を element.text() で書き出す今度はテスト関係のファイルツリーを決めましょう。
テストには以下のライブラリ・フレームワークを使います。
テスト周りを含めたファイルツリーは以下のようにします。
.
├── README.md
├── index.html
├── scripts
│ ├── main.js
│ └── message.js
├── test
│ ├── index.html
│ ├── main.js
│ ├── message-spec.js
│ └── vendor
│ ├── chai
│ ├── mocha
│ └── sinon-1.14.1.js
└── vendor
├── jquery-2.1.3.min.js
└── require-2.1.17.min.js
test/index.html で最初の test/main.js などのロードと結果の表示を行い、 test/main.js からRequireJSの仕組みでテストスクリプトをロードします。
それぞれのテストスクリプトはテスト対象のモジュール名から test/XXXX-spec.js とします(今回は test/message-spec.js だけ)。
というわけで、まずはテスト用の test/index.html (テストランナーと呼んでいます)ですが、次のようにします。
<!DOCTYPE html>
<html>
<head>
<title>Test runner</title>
<link rel="stylesheet" href="vendor/mocha/mocha.css" />
<script src="vendor/mocha/mocha.js"></script>
<script data-main="main" src="../vendor/require-2.1.17.min.js"></script>
</head>
<body>
<div id="mocha"></div>
</body>
</html>
ここでは mocha.js を、RequireJSとは別に script 要素でロードしています。
テストだけの場合だと、 mocha.js もRequireJSの枠組みでロードして構わないのですが、後で説明するカバレッジ計測の際に、RequireJSでロードしてしまうと正しくテストが実行されないので今からこのようにしています。
test/main.js はこんな感じになります。
require.config({
baseUrl: "../scripts",
paths: {
chai: "../test/vendor/chai/chai",
sinon: "../test/vendor/sinon-1.14.1",
jquery: "../vendor/jquery-2.1.3.min"
}
});
define("mocha", function(){
window.mocha.setup("bdd");
return window.mocha;
});
define("expect", ["chai"], function(chai){
return chai.expect;
});
require(["mocha", "message-spec.js"], function(mocha){
mocha.run();
});
先ほどのアプリ用の scripts/main.js と比べて、いくつか違う部分があります。
まず、 require とかdefine 関数で指定するモジュール(jsファイル)を探す場所が、テスト用の test/main.js がある場所ではなくアプリ用の scripts/main.js の場所になってほしいので、 require.config の baseUrl オプションで指定しています。
baseUrl: "../scripts",
../scripts と相対パス指定で書いていますが、この時だけ基準がテスト用の test/main.js があるディレクトリになります。 paths の基準ディレクトリは baseUrl で指定した scripts/ になります。
つぎに、先ほどMochaをRequireJSの外でロードしましたが、RequireJSの中からも require や define で参照できるようにモジュールを別途定義しています。
define("mocha", function(){
window.mocha.setup("bdd");
return window.mocha;
});
グローバル変数の mocha (実際は window オブジェクトの mocha プロパティ)を define から返すことで、
require(["mocha"], function(mocha){...});
といった具合に参照できるようになります。これがなくてもグローバル変数で参照できてしまいますが、他のモジュールと統一した方法で使えた方が気分が良いので..。
また、この時ついでに mocha.setup() 関数で「BDD形式でテストを書く」と宣言しています。
その次の define 関数では、 chai.expect を直接参照できるようにダミー的なモジュールを宣言しています。
define("expect", ["chai"], function(chai){
return chai.expect;
});
これにより、
require(["expect"], function(expect){
という書き方で直接 expect を参照して使えるようになります。
require 関数では "mocha" とテストスクリプトの "message-spec.js"を参照することでRequireJSにロードさせて、テストの開始を行っています。
require(["mocha", "message-spec.js"], function(mocha){
mocha.run();
});
"message-spec.js" は末尾に .js を残した形で指定したので、RequireJSは test/main.js のある場所からファイルを探して、 test/message-spec.js をロードします。
テストスクリプトの雛形はこんな風になります。
// RequireJSのdefine関数でテストフレームワークのモジュール、共通のライブラリと、
// テスト対象のモジュールを参照する
define(["expect", "sinon", "jquery", "message"], function(expect, sinon, $, Message){
// describe関数でテスト全体を包む
describe("Message", function(){
// 必要に応じてbeforeEachとafterEach(またはbeforeとafter)で下準備・後片付けを入れる
beforeEach(function(){
});
afterEach(function(){
});
// it関数にテストを書く
it("XXX", function(done){
});
});
});
実際にテストを書くと、 test/message-spec.js はこうなります。
define(["expect", "sinon", "jquery", "message"], function(expect, sinon, $, Message){
describe("Message", function(){
// describeの中を通して効く変数を用意する
var message;
beforeEach(function(){
// jQuery.ajaxの代わりに、事前に作った文字列を与える処理をsino.stubで用意します。
// アプリ内のファイルを参照するのでこのケースではstubではなくても構わないのですが、
// 今回は試しにsinon.stubを使います
var d = $.Deferred();
sinon.stub($, "ajax").returns(d.promise());
d.resolve({message: "Dummy message"});
message = new Message("../conf.json");
});
afterEach(function(){
// stubの$.ajaxの後片付け
$.ajax.restore();
});
// 非同期な処理の場合はitのcallbackの引数にdoneを取る
it("get", function(done){
// message.get関数のdoneの中で文字列の判定を行う
message.get().done(function(text){
// doneのコールバック関数の引数に設定ファイルからのメッセージ文字列が入るか調べる
expect(text).to.equal("Dummy message");
// doneの時点で message.text にメッセージ文字列が入っているか調べる
expect(message.text).to.equal("Dummy message");
// 非同期処理の終わりでdone()を呼ぶ
done();
});
});
// message.putは非同期ではないのでdoneは使わない
it("put", function(){
var e = $("");
message.text = "foo";
message.put(e);
expect(e.text()).to.eq("foo"); // itの中に直接、判定処理を書く
});
});
});
この時点で一度テストを実行してみましょう。
といっても、単に test/index.html をブラウザで開くだけです。
scripts/message.js の中に雛形しかないので、「Message関数なんてありません(意訳)」といってきています。
というわけで、 Message 関数(オブジェクトのコンストラクタ)と、 message.get と message.put (インスタンスのプロパティ関数)を用意しましょう。最初はそれぞれ空にしておきます。
define(["jquery"], function($){
function Message(confUrl){
}
Message.prototype.get = function(){
var deferred = $.Deferred();
deferred.resolve();
return deferred.promise();
};
Message.prototype.put = function(element){
};
return Message;
});
空にすると言ったばかりですが、message.get は jQuery.Deferred の promise() を返すようにしておきます。本当に空にしていたらどうなるかはお手元で試してみてください。
この状態で再度テストを実行すると、今度は以下のようになります。
先ほどのテスト結果は beforeEach の中で Message がないと言ってきていましたが、今度は get と put のテストの中で、期待した文字列が得られなかったと言ってきました。
それではいよいよ、きちんとした実装を書いていきます。
まず、コンストラクタ Message() で設定ファイルのURLを保存します
function Message(confUrl){
this.confUrl = confUrl;
}
message.get 関数は jQuery.get で得られたJSONデータから message を取り出して保存しつつ、 done のコールバックにも渡るようにします。
Message.prototype.get = function(){
var deferred = $.Deferred();
var self = this;
$.get(this.confUrl).done(function(data){
self.text = data.message;
deferred.resolve(self.text);
});
return deferred.promise();
};
message.put 関数はjQueryの要素オブジェクトを引数に取って、その text にメッセージ文字列を設定します。
Message.prototype.put = function(element){
element.text(this.text);
};
これでテストを動かすと、晴れて passes:2 となります。
最後にこの Message オブジェクトを scripts/main.js の require の中で使ってアプリを仕上げます。
require(["jquery", "message"], function($, Message){
var message = new Message("conf.json");
message.get().done(function(text){
$(".message").text(text);
});
});
設定ファイル conf.json も用意しておきます。
{
"message": "Hello, world"
}
これでアプリのindex.htmlをブラウザで開けば、アプリが動いてくれます。
それではいよいよ、コードカバレッジを計ってみましょう。以下のライブラリを使います。
まず、 Blanket.js をgithubからcloneします。
test/vendor$ git clone https://github.com/alex-seville/blanket.git Cloning into 'blanket'... remote: Counting objects: 5880, done. remote: Total 5880 (delta 0), reused 0 (delta 0), pack-reused 5880 Receiving objects: 100% (5880/5880), 6.11 MiB | 3.34 MiB/s, done. Resolving deltas: 100% (2449/2449), done. Checking connectivity... done.
実際に使うのは https://github.com/alex-seville/blanket/tree/master/dist/mocha にある blanket_mocha.js だけなので、これを test/vendor/blanket/dist/mocha/blanket_mocha.js としてダウンロードしても構いません。
ただし、 http://blanketjs.org/ の”Download 1.1.5″からダウンロードできるファイルは古くて以下で紹介する方法では使えないので、その点はご注意ください(本稿執筆時点の情報です。1.1.6とかになったら、大丈夫かもしれません)。
カバレッジ計測には、以下の test/coverage.html のような専用のテストランナーを用意します。
<!DOCTYPE html>
<html>
<head>
<title>Code coverage</title>
<link rel="stylesheet" href="vendor/mocha/mocha.css" />
<script src="vendor/mocha/mocha.js"></script>
<script data-main="main" src="../vendor/require-2.1.17.min.js"></script>
<script data-cover-never="vendor" src="vendor/blanket/dist/mocha/blanket_mocha.js"></script>
</head>
<body>
<div id="mocha"></div>
</body>
</html>
通常のテストと違うのは、blanket_mocha.js をロードする script 要素を追加した点です。
このとき、 data-cover-never 属性でカバレッジ計測から除外するディレクトリを指定します。今回の例では外部ライブラリなどを入れた vendor ディレクトリを除外するように指定しています。
この指定で vendor/ と test/vendor/ の両方が除外されます。
カバレッジ計測の準備はこれだけです。 test/coverage.html をブラウザで開くと、画面下部にカバレッジ計測の結果が表示されます。
今回は規模が小さいし、きちんとテストを書きながら開発を進めたので、コードカバレッジは見事に満点になっています!
試しに scripts/message.js にテストされない関数を追加してみましょう。
Message.prototype.hoge = function(){
alert("HOGE!");
};
coverage.html をリロードすると、きちんとカバレッジが低下しています。
alert を10行くらい繰り返すとさらに低下して、60%を下回ると表示が赤くなります。
ちなみに計測結果のファイル名をクリックすると、どの部分がテスト時に実行されなかったかが分かるようになっています。
テストの方にも実行されないコードを追加してみましょう。
describe("hoge", function(){
var alert;
beforeEach(function(){
alert = sinon.stub(window, "alert");
});
afterEach(function(){
window.alert.restore();
});
it("hoge", function(){
function testHoge(){
message.hoge();
expect(alert.call_edOnce).to.be.ok;
};
});
});
かなり苦しいですが、状況を変えて同じテストを試すために関数化したとか、そんな場面だと思ってください。
この状態で coverage.html を開くと、これまたきちんと test/message-spec.js のカバレッジが低下しています。
テスト対象のカバレッジが満点じゃないのは単にテストが不足しているからですが、テストその物のカバレッジが満点でない場合は、実行されると思っているテストが実行されていないわけで、非常に良くないです。
というわけで、テストコードだけのカバレッジを見られるようにしておくと便利なので、次のような test/coverage-only-test.html も用意しておきます。
<!DOCTYPE html>
<html>
<head>
<title>Test coverage</title>
<link rel="stylesheet" href="vendor/mocha/mocha.css" />
<script src="vendor/mocha/mocha.js"></script>
<script data-main="main" src="../vendor/require-2.1.17.min.js"></script>
<script data-cover-never="[vendor,../scripts]" src="vendor/blanket/dist/mocha/blanket_mocha.js"></script>
</head>
<body>
<div id="mocha"></div>
</body>
</html>
最初の test/coverage.html と違うのは、 blanket_mocha.js をロードする script 要素の data-cover-never 属性で vendor と ../scripts の2つを除外するよう指定しているところです。
複数のパスを除外する場合は [...] で囲んでカンマ( , )で区切ります。
<script data-cover-never="[vendor,../scripts]" src="vendor/blanket/dist/mocha/blanket_mocha.js"></script>
これで、 test/ 以下のjsだけのカバレッジを計測して表示できます。
あとは、それぞれのテストランナーを相互にリンクしたりするのも便利です。
というわけで、私たちにはテストのカバレッジが必要です(!?)
どっとはらい