Ruby on RailsがリポジトリをGitHub に移したのにあわせて、Script.aculo.usも、リポジトリ とWiki を移転しました。私もこれからGitを覚えようと思っています。
今回解説するunittest.jsは、JavaScript用のテストライブラリです。たくさんの種類のアサートが用意されているのが特徴です。テストの結果はきれいな表組みになって、成功、失敗の数や、エラーの理由が、一覧表示されます。機能はそれだけにとどまらず、テストとテストの間に指定時間のウェイトを挟む機能、マウスの動きやキーボードイベントをシミュレートする機能、処理時間を計測する機能もあります。さらに、これは実験的にですが、RSpecのようなBDDスタイルの記述をサポートしています。
Script.aculo.us自体も、このライブラリを使って入念なテストが書かれています。このテストは、単純にバグを減らすだけにとどまらず、仕様書としての役割もあり、開発者同士の理解に役立っています。このようなテストの重要性については、[動画で解説]和田卓人の“ テスト駆動開発” 講座 もあわせてご覧ください。
それではコードを見ていきましょう。
0001:
0002:
0003:
0004:
0005:
0006:
0007:
0008:
0009:
1~9行めは著作権表示です。
Event.simulateMouse
0010:
0011: Event .simulateMouse = function (element, eventName) {
0012: var options = Object .extend({
0013: pointerX: 0,
0014: pointerY: 0,
0015: buttons: 0,
0016: ctrlKey: false ,
0017: altKey: false ,
0018: shiftKey: false ,
0019: metaKey: false
0020: }, arguments[ 2] || {});
0021: var oEvent = document .createEvent("MouseEvents" );
0022: oEvent.initMouseEvent(eventName, true , true , document .defaultView,
0023: options.buttons, options.pointerX, options.pointerY, options.pointerX, options.pointerY,
0024: options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, 0, $(element));
0025:
0026: if (this .mark) Element.remove(this .mark);
0027: this .mark = document .createElement('div' );
0028: this .mark.appendChild(document .createTextNode(" " ));
0029: document .body.appendChild(this .mark);
0030: this .mark.style.position = 'absolute' ;
0031: this .mark.style.top = options.pointerY + "px" ;
0032: this .mark.style.left = options.pointerX + "px" ;
0033: this .mark.style.width = "5px" ;
0034: this .mark.style.height = "5px;" ;
0035: this .mark.style.borderTop = "1px solid red;"
0036: this .mark.style.borderLeft = "1px solid red;"
0037:
0038: if (this .step)
0039: alert ('[' +new Date ().getTime().toString()+'] ' +eventName+'/' +Test.Unit.inspect(options));
0040:
0041: $(element).dispatchEvent(oEvent);
0042: };
0043:
10~43行目のEvent.simulateMouseは、マウスイベントを発生するための関数です(Firefox専用です) 。1番めの引数にマウスイベントの発生源となる要素をとり、2番めの引数に次に挙げるイベント名のいずれかをとります。click、mousedown、mouseup、mouseover、mousemove、mouseout。3番めの引数にオプションをとることができます。
initMouseEventに渡す値をオプションで設定します。設定の詳細についてはGecko DOM ReferenceのinitMouseEventの項 を参照してください。
pointerX、pointerY
マウスポインタの座標です。
buttons
どのボタンでクリックするかです。0なら通常の左ボタン、1なら中ボタン、2なら右ボタンです。
ctrlKey、altKey、shiftKey、metaKey
ctrlKeyは、Ctrlキーが押されているかです。その他も同様です。
21行目のdocument.createEventで、引数に"MouseEvents"を渡して、マウスイベントを作ります。
22~25行目でinitMouseEventでイベントの詳細な内容を設定します。
27~37行目でdiv要素を作って、マウスポインタの位置に5pxの赤い四角を表示します。
38行目でthis.stepがtrueなら、アラートで日時とイベントの情報を表示します。この機能は現在使われていません。
41行目で実際にイベントを発生させます。
Event.simulateKey
0044:
0045:
0046:
0047: Event .simulateKey = function (element, eventName) {
0048: var options = Object .extend({
0049: ctrlKey: false ,
0050: altKey: false ,
0051: shiftKey: false ,
0052: metaKey: false ,
0053: keyCode: 0,
0054: charCode: 0
0055: }, arguments[ 2] || {});
0056:
0057: var oEvent = document .createEvent("KeyEvents" );
0058: oEvent.initKeyEvent(eventName, true , true , window ,
0059: options.ctrlKey, options.altKey, options.shiftKey, options.metaKey,
0060: options.keyCode, options.charCode );
0061: $(element).dispatchEvent(oEvent);
0062: };
0063:
44~62行目のEvent.simulateKeyは、キーイベントを発生するための関数です(Firefox 1.0.4以下専用です) 。1番めの引数にキーイベントの発生源となる要素をとり、2番めの引数に次に挙げるイベント名のいずれかをとります。keydown、keyup、keypresskey。3番めの引数にオプションをとることができます。設定の詳細についてはGecko DOM ReferenceのinitKeyEventの項 を参照してください。
ctrlKey、altKey、shiftKey、metaKey
ctrlKeyは、Ctrlキーが押されているかです。その他も同様です。
keyCode、charCode
キーコードです。
57行目のdocument.createEventで、引数に"KeyEvents"を渡して、キーイベントを作ります。
58~60行目のinitKeyEventでイベントの詳細な内容を設定します。
61行目で実際にイベントを発生させます。
0064: Event .simulateKeys = function (element, command) {
0065: for (var i=0; i<command.length; i++) {
0066: Event .simulateKey(element,'keypress' ,{charCode:command.charCodeAt(i)});
0067: }
0068: };
0069:
64~69行目のEvent.simulateKeysは、上述のsimulateKeyを連続して呼ぶ関数です。引数にキーイベントの発生源となる要素と、文字列を取ります。この文字列の1文字1文字のcharCodeで、keypressイベントが連続して発生します。
Test.Unit.Logger
Test.Unit.Loggerクラスは、テストの結果を表組みにして表示するためのクラスです。
0070: var Test = {}
0071: Test.Unit = {};
0072:
0073:
0074: Test.Unit.inspect = Object .inspect;
0075:
70、71行目で、Testクラスを作り、その下にTest.Unitクラスを作ります。
74行目で、Prototype.jsのinspectメソッドに対応するようにします。
0076: Test.Unit.Logger = Class .create();
0077: Test.Unit.Logger.prototype = {
0078: initialize: function (log) {
0079: this .log = $(log);
0080: if (this .log) {
0081: this ._createLogTable();
0082: }
0083: },
78~83行目のinitializeは、インスタンスの初期化を行う関数です。引数にログを出力する要素のDOM idを取ります。
79行目で、ログを出力する要素を取得します。取得できない場合はログは出力されません。
80行目で、その要素の内部に表を作ります。
0084: start: function (testName) {
0085: if (!this .log) return ;
0086: this .testName = testName;
0087: this .lastLogLine = document .createElement('tr' );
0088: this .statusCell = document .createElement('td' );
0089: this .nameCell = document .createElement('td' );
0090: this .nameCell.className = "nameCell" ;
0091: this .nameCell.appendChild(document .createTextNode(testName));
0092: this .messageCell = document .createElement('td' );
0093: this .lastLogLine.appendChild(this .statusCell);
0094: this .lastLogLine.appendChild(this .nameCell);
0095: this .lastLogLine.appendChild(this .messageCell);
0096: this .loglines.appendChild(this .lastLogLine);
0097: },
84~97行目のstartは、表組みのテスト結果の1行分を作る関数です。引数にテスト名をとります。表はこの図のようになります。
テーブルの様子
0098: finish: function (status , summary) {
0099: if (!this .log) return ;
0100: this .lastLogLine.className = status ;
0101: this .statusCell.innerHTML = status ;
0102: this .messageCell.innerHTML = this ._toHTML(summary);
0103: this .addLinksToResults();
0104: },
98~104行目のfinishは、statusCellとmessageCellにテスト結果を書き込む関数です。
103行目では、後述のaddLinksToResultsで、クリックするとそのテストを再試行するようにします。
0105: message: function (message) {
0106: if (!this .log) return ;
0107: this .messageCell.innerHTML = this ._toHTML(message);
0108: },
105~108行目のmessageは、messageCellにメッセージを書き込む関数です。内部的に、'実行中...'などと表示するのに使われます。
0109: summary: function (summary) {
0110: if (!this .log) return ;
0111: this .logsummary.innerHTML = this ._toHTML(summary);
0112: },
109~112行目のsummaryは、logsummaryにテスト全体の結果の要約を書き込む関数です。
0113: _createLogTable: function () {
0114: this .log.innerHTML =
0115: '<div id="logsummary"></div>' +
0116: '<table id="logtable">' +
0117: '<thead><tr><th>Status</th><th>Test</th><th>Message</th></tr></thead>' +
0118: '<tbody id="loglines"></tbody>' +
0119: '</table>' ;
0120: this .logsummary = $('logsummary' )
0121: this .loglines = $('loglines' );
0122: },
113~122行目の_createLogTableは、表組みのテーブルを作る関数です。動的にテーブルを作るために、innerHTMLに書き込む方法をとっているのは、DOM操作で作ろうとするとブラウザ間の仕様の違いに悩まされることになるからです(過去に解説したbuilder.jsでもこの問題への対処がありました) 。
0123: _toHTML: function (txt) {
0124: return txt.escapeHTML().replace(/\n/g ,"<br/>" );
0125: },
123~125行目の_toHTMLは、引数の文字列をHTMLの文字列表現にする関数です。Prototype.jsのescapeHTML を使います。
0126: addLinksToResults: function (){
0127: $$("tr.failed .nameCell" ).each( function (td){ todo
0128: td.title = "Run only this test"
0129: Event .observe(td, 'click' , function (){ window .location .search = "?tests=" + td.innerHTML;});
0130: });
0131: $$("tr.passed .nameCell" ).each( function (td){ todo
0132: td.title = "Run all tests"
0133: Event .observe(td, 'click' , function (){ window .location .search = "" ;});
0134: });
0135: }
0136: }
0137:
126~136行目のaddLinksToResultsは、失敗したテストの表組みのセルをクリックするとそのテストを再試行するように、onClickイベントにハンドラを追加する関数です。このようにテストの一部分だけ実行するには、Test.Unit.Runnerで説明するとおり、クエリパラメータのtestsに実行したいテスト名を記述して、ページを読み込みます。
131~135行目で、同様に、成功したテストのセルをクリックすると、テスト全てを再試行するようにします。
Test.Unit.Runner
Test.Unit.Runnerは、テストを順番に実行するクラスです。ライブラリの利用者はこのクラスの引数にテスト全体を記述します。例えば以下のようになります。setupにはテスト前の処理、teardownにはテスト後の処理を書きます。
0001: new Test.Unit.Runner({
0002: setup: function () {},
0003: teardown: function () {},
0004: testAssertEqual: function () { with (this ) {
0005: assertEqual(0, 0);
0006:
0007: assertEqual(0,'0' );
0008: assertEqual(65.0, 65);
0009:
0010: assertEqual("a" , "a" );
0011:
0012: assertNotEqual(0, 1);
0013: assertNotEqual("a" ,"b" );
0014: }}
0015: })
それではコードに戻りましょう。
0138: Test.Unit.Runner = Class .create();
0139: Test.Unit.Runner.prototype = {
0140: initialize: function (testcases) {
0141: this .options = Object .extend({
0142: testLog: 'testlog'
0143: }, arguments[ 1] || {});
0144: this .options.resultsURL = this .parseResultsURLQueryParameter();
0145: this .options.tests = this .parseTestsQueryParameter();
0146: if (this .options.testLog) {
0147: this .options.testLog = $(this .options.testLog) || null ;
0148: }
0149: if (this .options.tests) {
0150: this .tests = [];
0151: for (var i = 0; i < this .options.tests.length; i++) {
0152: if (/^test/ .test(this .options.tests[i])) {
0153: this .tests.push(new Test.Unit.Testcase(this .options.tests[i], testcases[this .options.tests[i]] , testcases["setup" ], testcases["teardown" ]));
0154: }
0155: }
0156: } else {
0157: if (this .options.test) {
0158: this .tests = [new Test.Unit.Testcase(this .options.test, testcases[this .options.test], testcases["setup" ], testcases["teardown" ])];
0159: } else {
0160: this .tests = [];
0161: for (var testcase in testcases) {
0162: if (/^test/ .test(testcase)) {
0163: this .tests.push(
0164: new Test.Unit.Testcase(
0165: this .options.context ? ' -> ' + this .options.titles[testcase] : testcase,
0166: testcases[testcase], testcases["setup" ], testcases["teardown" ]
0167: ));
0168: }
0169: }
0170: }
0171: }
0172: this .currentTest = 0;
0173: this .logger = new Test.Unit.Logger(this .options.testLog);
0174: setTimeout(this .runTests.bind(this ), 1000);
0175: },
140~175行目のinitializeは、インスタンスの初期化を行う関数です。引数のtestcasesに、ハッシュテーブル(キーにテスト名、値にテスト内容)を取ります。2番めの引数にオプションを取ることができます。テスト名は、名前が'test'で始まっている必要があります。
以下のオプションがあります。
testLog
結果を出力する要素のDOM idです。デフォルトは'testlog'です。
resultsURL
クエリパラメータresultsURLから与えます。結果の送信先のURLです。
tests
クエリパラメータtestsから与えます。引数で与えたtestcasesの中から、実行したいテスト名をカンマ区切りで指定します。
test
引数で与えたtestcasesの中から、実行したいテスト名を1つ指定します。
context 有効にすると、次のtitlesのオプションが使われます。
titles テストのそれぞれにタイトルをつけることができます。キーにテスト名、値にタイトルのハッシュテーブルを与えます。
144行目で、クエリパラメータresultsURLから値を取りだします。parseResultsURLQueryParameterは後述します。
145行目で、クエリパラメータtestsから値を取りだします。parseTestsQueryParameterは後述します。
146行目で、ログの出力先になる要素を取得します。取得できなかったら、ログは出力されません。
149~156行目で、options.testsが指定されていたら、testcasesの中から、指定されたテストたちだけを加えます。テスト名は'test'で始まっている必要があります。Test.Unit.Testcaseは、後述するように、引数に、テスト名、テスト内容、テスト前の処理、テスト後の処理を取ります。
157行目で、options.testが設定されていたら、testcasesの中から、そのテストだけを加えます。
159~170行目で、何も設定がなければ、testcasesの全てのテストを加えます。テスト名は'test'で始まっている必要があります。
172行目で、this.currentTestは、現在実行中のテストが全体の何番めにあたるかを示します。まずは0にします。後述のrunTestsの内部で管理されます。
173行目で、Test.Unit.Loggerのインスタンスを作って、ログの出力先にします。
174行目で、タイマーで1000ミリ秒後にrunTestsを呼び、テストを開始します。
0176: parseResultsURLQueryParameter: function () {
0177: return window .location .search.parseQuery()["resultsURL" ];
0178: },
176~178行目のparseResultsURLQueryParameterは、ブラウザのアドレス欄のクエリパラメータから'resultsURL'の値を取り出す関数です。このアドレスに結果がAjaxで送信されます。
0179: parseTestsQueryParameter: function (){
0180: if (window .location .search.parseQuery()["tests" ]){
0181: return window .location .search.parseQuery()["tests" ].split(',' );
0182: };
0183: },
179~183行目のparseTestsQueryParameterは、ブラウザのアドレス欄のクエリパラメータから'tests'の値を取り出す関数です。この値に、実行したいテスト名をカンマ区切りで記述することで、スクリプトを書き換えずに、実行するテストを選べます。
0184:
0185:
0186:
0187:
0188: getResult: function () {
0189: var hasFailure = false ;
0190: for (var i=0;i<this .tests.length;i++) {
0191: if (this .tests[i].errors > 0) {
0192: return "ERROR" ;
0193: }
0194: if (this .tests[i].failures > 0) {
0195: hasFailure = true ;
0196: }
0197: }
0198: if (hasFailure) {
0199: return "FAILURE" ;
0200: } else {
0201: return "SUCCESS" ;
0202: }
0203: },
188~203行目のgetResultは、テスト全体の結果を返す関数です。実行時エラーが1つでもあれば"ERROR"が直ちに返ります。次に、失敗が1つでもあれば、"FAILURE"が返ります。何も無ければ"SUCCESS"が返ります。
0204: postResults: function () {
0205: if (this .options.resultsURL) {
0206: new Ajax.Request(this .options.resultsURL,
0207: { method: 'get' , parameters: 'result=' + this .getResult(), asynchronous: false });
0208: }
0209: },
204~209行目のpostResultsは、テスト全体の結果('ERROR'、'FAILURE'、'SUCCESS')をoptions.resultsURLのアドレスにAjaxで送信する関数です。
0210: runTests: function () {
0211: var test = this .tests[this .currentTest];
0212: if (!test) {
0213:
0214: this .postResults();
0215: this .logger.summary(this .summary());
0216: return ;
0217: }
0218: if (!test.isWaiting) {
0219: this .logger.start(test.name);
0220: }
0221: test.run();
0222: if (test.isWaiting) {
0223: this .logger.message("Waiting for " + test.timeToWait + "ms" );
0224: setTimeout(this .runTests.bind(this ), test.timeToWait || 1000);
0225: } else {
0226: this .logger.finish(test.status (), test.summary());
0227: this .currentTest++;
0228:
0229: this .runTests();
0230: }
0231: },
210~231行目のrunTestsは、テストを順番に実行する関数です。Test.Unit.Runnerの心臓部です。
211行目で、this.currentTestは、全体で何番めのテストを実行中かを表します。this.tests配列からテスト(Test.Unit.Testcaseのインスタンス)を取り出します。
212行目で、これ以上取り出せなければ、すべてのテストが終了です。以下の処理をします。
214行目で、終了時に、postResultsでテスト結果を送信します。
215行目で、終了時に、テスト全体の結果の要約を出力します。this.summaryは、後述するとおり、テスト全体の失敗の総数などの要約を返す関数です。
218行目で、テストにとって初めてのrunTestsの呼び出しかどうかは、このisWaitingフラグでわかります。初めてのときはログの出力先を作るために、表組みに1行追加します。
221行目で、テストを実行します。もしもこのrunの呼び出しの内部で、wait関数を呼ぶと、isWaitingフラグがtrueになります。
223、224行目が、wait関数の呼び出しがあった場合で、ログのメッセージ欄に'Waiting...'を表示します。
224行目で、タイマーで指定時間待ってから、再度runTestsを呼びます。そこで、wait関数の2番めの引数で渡した、続きの処理に移るというわけです。
226行目が、wait関数の呼び出しがなかった場合で、これ以降がテストの終了の処理です。ログにテストの実行結果を表示します。
227行目で、this.currentTestをインクリメントして、次のテストに進みます。
229行目で、runTestsを末尾再帰で呼び出します。コメントで、ブラウザのインタプリタがこれをループに最適化してくれることを願っています。
0232: summary: function () {
0233: var assertions = 0;
0234: var failures = 0;
0235: var errors = 0;
0236: var messages = [];
0237: for (var i=0;i<this .tests.length;i++) {
0238: assertions += this .tests[i].assertions;
0239: failures += this .tests[i].failures;
0240: errors += this .tests[i].errors;
0241: }
0242: return (
0243: (this .options.context ? this .options.context + ': ' : '' ) +
0244: this .tests.length + " tests, " +
0245: assertions + " assertions, " +
0246: failures + " failures, " +
0247: errors + " errors" );
0248: }
0249: }
0250:
232~250行目のsummaryは、テスト全体の結果の要約を出力する関数です。テスト全体の成功の総数、失敗の総数、エラーの総数を記述した文字列を返します。