この記事は保守性の高いJavaScriptを作成するための集中講座です。ユニット・テストを書いて、そのテストを通るようにするという単純な法則を繰り返しながら既存の例題に機能を追加していきます。それぞれのテストは、安全策と実行可能なドキュメントの両方の意味で製品のコードを修正しようとする全ての人にとって、品質をフィードバックするのに使うことが出来ます。各機能の実装にあたって、まずは簡単な失敗するテストから始めることで確かにテストをしたことを確認します。後になってテストのためにコードを書き直す手間を避けるのです。JavaScriptの開発者にとっていかに多くの引っ掛かり易い罠があるかという事実を考えるとこれは特に効果的な方法です。DOM APIと(JavaScript)言語の間にどれだけのグローバルで変更可能な状態があるのかを考えてみて下さい。
私達が使う例題はカジノにあるの3リールスロットです。それぞれのリールは5つの状態をとることが出来、それぞれは画像で表現されます。スロット・マシーンのプレイ・ボタンを押すとそれぞれのリールはランダムに状態を選びます。3つのリールの状態が等しいかどうかによってスロット・マシーン上の持ち点は増減します。
私達が使うツールはスタブ、モック・オブジェクト、そしてDI(依存性の注入)を少しだけ使います。ユニット・テストを実行するのにJsUnitを使い、JsMockと呼ばれるJavaScriptモック・オブジェクトのライブラリも使います。インテグレーション・テストは、ユニット・テストをより丁寧にしたものですが、この記事の範疇ではありません。これはインテグレーション・テストの方が重要性が低いということを意味している訳ではなく、私達が望んでいるのはSeleniumやWatirのようなツールから得られる広範囲でゆっくりとしたフィードバックではなく、素早いフィードバックであるというだけのことです。
JsUnit、JavaScript用のユニット・テスト・フレームワーク
JsUnitはオープンソースのJavaScript用のユニット・テスト・フレームワークです。JUnitから発想を得て全てJavaScriptで書いたものです。最も普及したJavaScript用のユニット・テスト・フレームワークとなっただけではなく、いくつかのantタスクが同梱されています。これらのantタスクによって開発者はサーバでのビルド時に継続的インテグレーションの一環としてテスト・スイーツを実行させることが出来ます。継続的インテグレーションは、この記事の範疇を超えるまた別の優れた実践であり、TDDと併用することで品質を強化するものです。
ではJsUnitのテスト実行部の話から始めます。テスト実行部はHTMLとJavaScriptで作られたプレーンなWebページなので、ユニット・テストはあなたがサポートしたいと思うブラウザで直接実行することが出来るということです。JsUnitのダウンロード・ファイル(Zip)を解凍するとルート・ディレクトリにtestRunner.htmlがあります。テスト実行部をWebサーバに配置する必要はありません。単にファイル・システム上にあるファイルをブラウザで開けばいいのです。
テスト実行部上の最も重要な部分はページの上部にあるファイル参照用のフォーム部品になります。この部品はテスト・ページまたはテスト・ページのスイート・ファイルへのパスを保持します。ではJsUnit用のテスト・ページの簡単な例を見てみましょう。
<html> <title>A unit test for drw.SystemUnderTest class</title> <head> <script type='text/javascript' src='../jsunit/app/jsUnitCore.js'></script> <script type='text/javascript' src='../app/system_under_test.js'></script> <script type='text/javascript'> function setUp(){ // perform fixture set up } function tearDown() { // clean up } function testOneThing(){ // instantiating a SystemUnderTest, a class in the drw namespace var sut = new drw.SystemUnderTest(); var thing = sut.oneThing(); assertEquals(1, thing); } function testAnotherThing(){ var sut = new drw.SystemUnderTest(); var thing = sut.anotherThing(); assertNotEquals(1, thing); } </script> </head> <body/> </html>
JsUnitには他のxUnitフレームワークとの共通点が多くあります。期待通りテスト実行部はテスト・ページを読み込み、テスト関数をそれぞれ呼び出します。それぞれのテスト関数はsetUpとtearDownという関数の呼び出しに挟まれています。setUp関数はテスト作成者がテストの設定を構成する機会を与えます。テストの設定はそのページの全てのテストで固定化される状態のことを指します。一方、tearDown関数はテスト作成者がテストの設定の後始末をする機会を与えます。
ただし、JsUnitと他のxUnitフレームワークの間にはテストのライフ・サイクルの面で僅かな違いがあります。テスト・ページはそれぞれ別のウィンドウに読み込まれるので、アプリケーション・コードが公開されているクラスを通してフレームワークのコードをオーバーライドしてしまうのを避けられます。(テスト・ページが)読み込まれたそれぞれのウィンドウ内で全てのユニット・テスト関数が実行されます。テスト関数を実行する度にページが再読み込みされることはありません。一方JUnitでテスト・ページに相当するのはテスト・ケースですが、テスト実行部は各テスト・メソッド毎に別のテスト・ケースのインスタンスを生成します。つまり、以下のようになります。
JsUnitはN個のテスト関数に対して1度だけテスト・ページを読み込む
JUnitはN個のテスト・メソッドに対してN回テスト・ケースを生成する
従って、テスト・ページの状態を変更してしまうと後続のテストの結果に影響を与えてしまうという意味でJavaScript開発者は一歩間違えると深みにはまる状況にあると言えます。それに対してJava開発者はテスト・ケース・オブジェクトの状態を変更してもそのようなリスクにさらされることはありません。ではなぜJsUnitはテストの度にテスト・ページを再読み込みするだけの手間を惜しんだのでしょうか。それはテスト・スイートに含まれる全てのテスト関数を実行する度にDOMを再構築することは性能上大きなコストを要することになるからです。幸い、JavaScript開発者はグローバルな状態の変更による副作用について考慮する必要はあまりありません。JVMやCLRのようなプラットフォーム上でのプログラミングでは、静的な変数を変更するユニット・テストは、同じテスト・ケース内のテストのみならず、同じテスト・スイートに含まれる後続のテスト全てに影響を与えてしまいます。
jsUnitCore.jsというスクリプトは全てのテスト・ページに組み込まれていなければなりません。この重要なファイルはJsUnitのダウンロード・ファイルを解凍して出来たappディレクトリに配置されています。このスクリプトには他のxUnitフレームワークとほぼ同一の振る舞いをする少量のアサーション用関数が含まれています。但し、JavaScriptには等価性に関する2つの考え方があるという事実に由来する僅かな違いが存在します。JavaScriptには等価演算子と厳密等価演算子があります。例えば、最初の演算子は以下の式をtrueと評価しますが、二つ目の演算子はfalseと評価します。
0 == false
0 === false
どういうことでしょうか。等価演算子は厳密等価演算子ほどには厳密ではなく、実行時に最初のブーリアン式への型変換を許可します*。従って初心者は下記のアサーションを通ると思うかも知れません。
assertEquals(false, 0);
実際のところ、JsUnitフレームワークで提供されているアサーション用関数は全ての比較に対して等価演算子ではなくより厳密な厳密等価演算子を使っているので、このアサーションは失敗します。等価演算子の利用を避けることでJsUnitは多くの誤検知を回避することが出来るのです。
スタブ vs モック
続いて私達の例題であるスロット・マシーンにおけるスタブとモック・オブジェクトについて見てみましょう。今回のユニット・テストは1つのオブジェクトに注目しているので、スロット・マシーンを作成してテスト対象システム呼ぶことにします。ではスロット・マシーンをレンダリングする簡単なテストを書いて見ましょう。
function testRender() { var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; var randomNumbers = [2, 1, 3]; var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); slotMachine.render(); assertEquals('Pay to play', buttonStub.value); assertTrue(buttonStub.disabled); assertEquals(0, balanceStub.innerHTML); assertEquals('images/2.jpg', reelsStub[0].src); assertEquals('images/1.jpg', reelsStub[1].src); assertEquals('images/3.jpg', reelsStub[2].src); }
testRender関数では2つのDOM要素をスタブ化していて、どちらもテスト対象システムのコンストラクタに渡され、その後renderメソッドを呼んでいます。テストはrenderメソッドの期待される副作用に関するアサーションで終わっています。DOM要素をスタブ化することでこのテスト・ページにあるその他のテストを無効にしてしまうことなくrenderメソッドの副作用についてテストすることが出来ると言うことに注目して下さい。この方法と実際のDOM要素を使う方法にはトレード・オフがあります。実際のDOM要素を使う方がブラウザ互換性に関するバグをより多く見つけることが出来るでしょうが、それぞれのテストの最後もしくはtearDownの中でDOMの状態を復元しない限りテスト自体のバグが増えるでしょう。
テスト対象のシステムは各リールの初期画像を決定するためにグローバル関数Math.randomを直接呼び出してはいません。その代わりスロット・マシーンはこれらの数字を得るためにスロット・マシーンの生成時に渡されたものを使っています。これによって私達はソフトウェアの予測不能な面についてあたかも完全に予測可能であるかのようにテストすることが出来るのです。ブラウザのMath.random関数の実装を上書きしないことでこのテストがどのように状態の変更や副作用を回避しているのかに注目して下さい。
ちょっと待って下さい。テスト関数には複数のアサーションがあります。これでいいのでしょうか。アジャイルのコミュニティには一つのテストで複数のアサーションをすることは悪いことであると考えている小さな一派があります。しかし、実際に利益を生み出している実際のアプリケーションのテスト・スイーツではそのような書き方(一つのテストに一つのアサーション)をすることはほとんどありません。このような人々の多くはJUnitフレームワーク自身のテスト・スイート(リンク)で一つのテスト当りにどれだけ多くのアサーションがあるのかを知ったときにとても驚いたことでしょう。
(テスト対象の)オブジェクトのコンストラクタとrenderメソッドは以下のようになります。
/** * スロットマシーンの開発者へ */ drw.SlotMachine = function(buttonElement, balanceElement, reels, random, networkClient) { this.buttonElement = buttonElement; this.balanceElement = balanceElement; this.reels = reels; this.random = random; this.networkClient = networkClient; this.balance = 0; }; drw.SlotMachine.prototype.render = function() { this.buttonElement.disabled = true; this.buttonElement.value = 'Pay to play'; this.balanceElement.innerHTML = 0; for(var i = 0; i < this.reels.length;){ this.reels[i++].src = 'images/' + this.random() + '.jpg'; } };
ではスロット・マシーンにいくらかのお金を投入してみましょう。このシナリオでは、ユーザの持ち点を取得するためにスロット・マシーンはサーバに対して非同期呼び出しをします。ユニット・テストではネットワーク環境もなくAJAX呼び出しは失敗するのでこれはとても挑戦的な取り組みになります。ユニット・テストを書く際にはそのコードが副作用をもたらさない様に努める必要があります。そして、IOは間違いなくこの部類に含まれる問題でしょう。
function testGetBalanceGoesToNetwork(){ var url, callback; var networkStub = { send : function() { url = arguments[0]; callback = arguments[1]; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); assertEquals('/getBalance.jsp', url); assertEquals('function', typeof callback); }
このテストではネットワークをスタブ化しています。スタブとは何でしょう。そしてスタブはモックと何が違うのでしょうか。多くの開発者がこれら2つの言葉を同義語だと勘違いしています。テストの世界ではスタブという言葉は状態に依存するテストのための予約語です。JavaScriptの世界でいうとこれはほとんどの場合単にハードコードされた値を返すだけのオブジェクトのことを意味しています。一方、モックという言葉は相互作用のテストのための予約語です。モックは振舞いを持たせることが出来るのです。こらの振舞いはテスト対象のシステムと相互作用し、その相互作用を検証することが出来るのです。
ネットワーク・クライアントをスタブ化することでgetBalanceメソッドをテスト出来るようになりました。コンストラクタに渡されたスタブ化されたリテラル・オブジェクトはテスト対象システムとの相互作用についてurlとcallbackというローカル変数に記録をします。これらのローカル変数を使ってテストの最後にアサーションを行います。残念ながら私達は間違ったツールを選択してしまいました。これはスタブの限界となぜモック・オブジェクトなら目的を達成出来るかを示す古典的な例です。このテストの目的はある状態を与えられた際のテスト対象システムの振舞いを検証することではありません。このテストはdrw.SlotMachineのインスタンスとその協調相手の一つであるネットワーク・クライアントとの間の相互作用について検証することに関心があるのです。
JsMock、JavaScriptのためのモック・オブジェクト・ライブラリ
testGetBalanceGoesToNetworkをよく見るとそれ自身がちょっとしたモック・フレームワークであることが分かるでしょう。では、このテストをリファクタリングして一般的なモック・フレームワークを使うようにしてみましょう。そのためにscriptタグを一つ追加し、テストを以下のように書き換えます。
<script type='text/javascript' src='../jsmock/jsmock.js'></script> function testGetBalanceWithMocks(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); mockControl.verify(); }
これでより少ない行数で同じ効果を得ることが出来るようになったばかりでなく将来のテストのためのよりよい基盤を作る土台を用意することも出来ました。ではこれはどのように機能するのでしょうか。コードの最初の1行はJsMockで提供されるMockControlのコンストラクタを使ってオブジェクトを生成しています。その後2つ目のメソッドを使ってモック・オブジェクトを生成しています。実際にNetworkClientクラスを使ったアプリケーションではcreateMockメソッドにリテラル・オブジェクトを渡す必要すらないのです。JsMockではprototypeを使うことで同じ効果を得ることが出来ます。
var mock = mockControl.createMock(NetworkClient.prototype);
ネットワーク・クライアントのモック・オブジェクトを生成した後、あるパラメータを使ってsendメソッドが呼ばれることを確認するようにプログラミングされています。サーバ・リソースの名前が正しいことと2つ目の引数がコールバック関数となっていることを確認します。モック・オブジェクトはテスト対象システムのコンストラクタに渡されMockControlオブジェクトのverifyメソッドを通してこの相互作用を確認してテストは終わります。もし何らかの理由によりスロット・マシーンの実装がネットワーク・クライアントのsendメソッドを呼ばなかったか、パラメータが期待値と一致しなかった場合には、verifyメソッドが例外を投げてテストは失敗します。
続けてdrw.SlotMachineのインスタンスがどの程度の頻度でネットワーク通信を行っているのかを確認するテストを作成してみましょう。もしサーバの応答が完了する前にgetBalanceメソッドが呼ばれた場合は、持ち点を2度も取得したくはありません。もしそうしてしまうとスロット・マシーンはユーザの持ち点を二重に反映してしまう上に余計な通信をすることになります。
function testGetBalanceWithMocksToTheNetworkOnce(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response mockControl.verify(); }
最初の試みを覚えていますか。独自のちょっとしたモック・フレームワークを作成した時のことです。その時点では実用的な解決策と思えたかも知れませんが、このような相互作用をテストするのにどれだけのコードが必要となるか想像してみて下さい。議論のために以下のスタブを使った欠点のある方法を見て下さい。
function testGetBalanceFlawed(){ var networkStub = { send : function() { if(this.called) throw new Error('This should not be called > 1 time'); this.called = true; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response }
このテストでは一度ネットワークのスタブが呼び出されると以降の呼び出し時にはエラーを返すようにすることでネットワーク・クライアントが一度しか呼び出されないことを確認しています。テスト対象のオブジェクトを制御することでアサーションを行おうとしているという点でこのテストにはちょっとした問題があります。例えば、テスト対象のシステムがネットワークのスタブを複数回呼び出すとして、(テスト対象のシステムが)投げられた例外を握り潰してしまっているとしたら、テスト実行部は例外が発生したことを知ることができないのでテストは失敗することがありません。より複雑な独自のモック・フレームワークを作ることでこの問題を回避することは出来ますが、このような場合はJsMockのような一般的な目的向けに作成されたものを使う方が簡単なのです。
JsMockはメソッドの呼び出しとパラメータのテストをする機能だけを提供しているわけではありません。次のコードはネットワーク障害の場合にスロット・マシーンがどのように振舞うのかを再現したものです。
function testGetBalanceWithFailure(){ var buttonStub = {}; var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andThrow('network failure'); var slotMachine = new drw.SlotMachine(buttonStub, null, null, null, networkMock); slotMachine.getBalance(); assertEquals('Sorry, can't talk to the server right now', buttonStub.value); mockControl.verify(); }
ここではネットワーク障害の場合にスロット・マシーンが"お行儀良く"失敗することを検証しています。上記の例はユニット・テストがシステム統合テストより優れている場合の好例です。品質保証/リリースの度に全てのシステム統合箇所について手動でネットワーク障害を再現するのにどれだけの時間と予算が必要となるか想像出来るでしょうか。
ここまででgetBalanceメソッドの実装は以下のようになっています。
drw.SlotMachine.prototype.getBalance = function() { if(this.balanceRequested) return; try{ // this line of code requires the very excellent functional.js // library, found at http://osteele.com/sources/javascript/functional this.networkClient.send('/getBalance.jsp', this.deposit.bind(this)); this.balanceRequested = true; }catch(e){ this.buttonElement.value = 'Sorry, can't talk to the server right now'; } };
モックの欠点の一つは、少なくてもそのままだと、スタブと比較してテスト対象のシステムと密結合してしまうという点です。テスト対象のシステムが想定通りの振舞いをしなくなったらテストが失敗して欲しいでしょうが、実装の詳細を隠ぺいするためには修正の度にテストが失敗するのは避けたいことでしょう。この状況を改善するためにJsMockではユルイ表現を用意しています。実は既にこの例を紹介しています。ネットワークのモック・オブジェクトを作成した際に以下のようなコードを書きました。
networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function));
2つ目の引数でどの関数がコールバックされるかまでは特定しませんでした。ただコールバック関数がセットされることだけを確認しました。これらの期待値をさらにユルイ設定にしたければ以下のようにすることも出来ます。
networkMock.expects().send(TypeOf.isA(String), TypeOf.isA(Function));
もしネットワーク・クライアントのモック・オブジェクトのsendメソッドに渡された実際のコールバック関数を参照する必要があれば、JsMockフレームワークの提供するandStubメソッドを使って以下のように書くことも出来ます。
var depositCallback; networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andStub( function(){depositCallback = arguments[1];} ); depositCallback({responseText:"10"});
話を続ける前にモック・オブジェクトについて二点注目する点があります。まずそれぞれのテストがMockControlのverifyメソッドをどのように呼んで終了しているのか注意して下さい。これは重要なことです。verifyメソッドを呼んでいないユニット・テストは処理に失敗してはいけないユニット・テストです。多くの開発者が、いくつかの標準的なユニット・テスト関数を実装した後で、verifyメソッドの呼び出しを各テスト関数からtearDown関数へ移動した方がいいという結論に至ります。これによって数行のコードが削減され各テスト関数の最後にこの重要な呼び出しを行うことを覚えなければならないことから解放されますが、一方で新たな問題をもたらします。tearDownで発生した例外はテスト内部で発生した最初の例外によって隠蔽されてしまうのです。2つ目の落とし穴は、モック・オブジェクトを使い慣れない開発者はしばしば過剰にモック・オブジェクトを利用してしまうということです。もっと言うと、そのような開発者はモック・オブジェクトをスタブの代わりとして使ってしまうということです。これは止めましょう。スタブは状態に依存するテストに、モックは振舞いに依存するテストに使いましょう。
勝つ場合のシナリオ・テスト
これまでに学習したことを全て使って以下のシナリオに対するテストを作りましょう。このテストではユーザが一旦スロットに負けた後で勝つ状況をシミュレーションしています。
function testLoseThenWin(){ var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; // a losing combination, followed by a winning combination var randomNumbers = [2, 1, 3].concat([4, 4, 4]); var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); var balance = 10; slotMachine.deposit({responseText: String(balance)}); slotMachine.play(); assertEquals(balance - 1, balanceStub.innerHTML); assertEquals('Sorry, try again', buttonStub.value); slotMachine.play(); assertEquals('balance - 2 + 40', 48, balanceStub.innerHTML); assertEquals('You Won!', buttonStub.value); assertEquals('images/4.jpg', reelsStub[0].src); assertEquals('images/4.jpg', reelsStub[1].src); assertEquals('images/4.jpg', reelsStub[2].src); }
drw.SlotMachineクラスのplayメソッドの実装は以下のようになります。
drw.SlotMachine.prototype.play = function(){ var outcomes = []; var msg = 'Sorry, try again'; for(var i = 0; i < this.reels.length; i++){ this.reels[i].src = 'images/' + (outcomes[i] = this.random()) + '.jpg'; } if(outcomes[0] == outcomes[1] && outcomes[0] == outcomes[2]){ msg = 'You Won!'; this.balance += (outcomes[0] * 10); } this.buttonElement.value = msg; this.balanceElement.innerHTML = --this.balance; }; |
そして最後に実際に稼働するスロット・マシーンの実証コードになります。
<html> <title>A Slot Machine Demonstration</title> <head> <script type='text/javascript' src='functional.js'></script> <script type='text/javascript' src='slot_machine.js'></script> <script type='text/javascript' src='network_client.js'></script> <script type='text/javascript'> window.onload = function(){ var leftReel = document.getElementById('leftReel'); var middleReel = document.getElementById('middleReel'); var rightReel = document.getElementById('rightReel'); var random = function(){ return Math.floor(Math.random()*5) + 1; // generate 1 through 5 }; slotMachine = new drw.SlotMachine(document.getElementById('buttonElement'), document.getElementById('balanceElement'), [leftReel, middleReel, rightReel], random, new NetworkClient()); slotMachine.render(); slotMachine.getBalance(); }; </script> </head> <body id='body'> <div style="text-align:center; background-color:#BFE4FF; padding: 5px; width: 160px;"> <div>Slot Machine Widget</div> <div style="padding: 5 0 5 0;"> <img id='leftReel'/> <img id='middleReel'/> <img id='rightReel'/> </div> <div>Balance: <span id="balanceElement"></span></div> <input id="buttonElement" style="width:150px" type="button" onclick="slotMachine.play()"></input> </div> </body> </html>
参考文献
- JSMock はJustin DeWind氏が作成した完全な機能を有するJavaScript向けのモック・オブジェクト・ライブラリです。
- JsUnitはクライアント・サイド(ブラウザ内)のJavaScriptのためのユニット・テスト・フレームワークです。
- Mocks Aren’t Stubs(モックはスタブではない)は、Martin Fowler氏による記事です。
- FunctionalはOliver Steele氏がJavaScriptで作成した関数型プログラミング用のライブラリです。
- Dependency Injection(依存性の注入)は、Martin Fowler氏による記事です。
著者について
Dennis Byrne氏(リンク)はシカゴ在住でプロップファームそしてマーケットメーカであるDRW Trading(リンク),で勤務しています。Dennis氏はライター兼プレゼンターでありオープン・ソース・コミュニティの活動的なメンバでもあります。