script.aculo.usを読み解く

第9回unittest.js(前編)

Ruby on RailsがリポジトリをGitHubに移したのにあわせて、Script.aculo.usも、リポジトリWikiを移転しました。私もこれからGitを覚えようと思っています。

今回解説するunittest.jsは、JavaScript用のテストライブラリです。たくさんの種類のアサートが用意されているのが特徴です。テストの結果はきれいな表組みになって、成功、失敗の数や、エラーの理由が、一覧表示されます。機能はそれだけにとどまらず、テストとテストの間に指定時間のウェイトを挟む機能、マウスの動きやキーボードイベントをシミュレートする機能、処理時間を計測する機能もあります。さらに、これは実験的にですが、RSpecのようなBDDスタイルの記述をサポートしています。

Script.aculo.us自体も、このライブラリを使って入念なテストが書かれています。このテストは、単純にバグを減らすだけにとどまらず、仕様書としての役割もあり、開発者同士の理解に役立っています。このようなテストの重要性については、[動画で解説]和田卓人の⁠テスト駆動開発⁠講座もあわせてご覧ください。

それではコードを見ていきましょう。

0001:// script.aculo.us unittest.js v1.8.1, Thu Jan 03 22:07:12 -0500 2008
0002:
0003:// Copyright (c) 2005-2007 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
0004://           (c) 2005-2007 Jon Tirsen (http://www.tirsen.com)
0005://           (c) 2005-2007 Michael Schuerig (http://www.schuerig.de/michael/)
0006://
0007:// script.aculo.us is freely distributable under the terms of an MIT-style license.
0008:// For details, see the script.aculo.us web site: http://script.aculo.us/
0009:

1~9行めは著作権表示です。

Event.simulateMouse

0010:// experimental, Firefox-only
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:// Note: Due to a fix in Firefox 1.0.5/6 that probably fixed "too much", this doesn't work in 1.0.6 or DP2.
0045:// You need to downgrade to 1.0.4 for now to get this working
0046:// See https://bugzilla.mozilla.org/show_bug.cgi?id=289940 for the fix that fixed too much
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:// security exception workaround
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: limit to children of this.log
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: limit to children of this.log
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:  // Returns:
0185:  //  "ERROR" if there was an error,
0186:  //  "FAILURE" if there was a failure, or
0187:  //  "SUCCESS" if there was neither
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:      // finished!
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:      // tail recursive, hopefully the browser will skip the stackframe
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は、テスト全体の結果の要約を出力する関数です。テスト全体の成功の総数、失敗の総数、エラーの総数を記述した文字列を返します。

おすすめ記事

記事・ニュース一覧