1// Copyright (c) 2012 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5'use strict'; 6 7/** 8 * @fileoverview A test harness loosely based on Python unittest, but that 9 * installs global assert methods during the test for backward compatibility 10 * with Closure tests. 11 */ 12base.requireStylesheet('unittest'); 13base.exportTo('unittest', function() { 14 15 function createTestCaseDiv(testName, opt_href, opt_alwaysShowErrorLink) { 16 var el = document.createElement('test-case'); 17 18 var titleBlockEl = document.createElement('title'); 19 titleBlockEl.style.display = 'inline'; 20 el.appendChild(titleBlockEl); 21 22 var titleEl = document.createElement('span'); 23 titleEl.style.marginRight = '20px'; 24 titleBlockEl.appendChild(titleEl); 25 26 var errorLink = document.createElement('a'); 27 errorLink.textContent = 'Run individually...'; 28 if (opt_href) 29 errorLink.href = opt_href; 30 else 31 errorLink.href = '#' + testName; 32 errorLink.style.display = 'none'; 33 titleBlockEl.appendChild(errorLink); 34 35 el.__defineSetter__('status', function(status) { 36 titleEl.textContent = testName + ': ' + status; 37 updateClassListGivenStatus(titleEl, status); 38 if (status == 'FAILED' || opt_alwaysShowErrorLink) 39 errorLink.style.display = ''; 40 else 41 errorLink.style.display = 'none'; 42 }); 43 44 el.addError = function(test, e) { 45 var errorEl = createErrorDiv(test, e); 46 el.appendChild(errorEl); 47 return errorEl; 48 }; 49 50 el.addHTMLOutput = function(opt_title, opt_element) { 51 var outputEl = createOutputDiv(opt_title, opt_element); 52 el.appendChild(outputEl); 53 return outputEl.contents; 54 }; 55 56 el.status = 'READY'; 57 return el; 58 } 59 60 function createErrorDiv(test, e) { 61 var el = document.createElement('test-case-error'); 62 el.className = 'unittest-error'; 63 64 var stackEl = document.createElement('test-case-stack'); 65 if (typeof e == 'string') { 66 stackEl.textContent = e; 67 } else if (e.stack) { 68 var i = document.location.pathname.lastIndexOf('/'); 69 var path = document.location.origin + 70 document.location.pathname.substring(0, i); 71 var pathEscaped = path.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); 72 var cleanStack = e.stack.replace(new RegExp(pathEscaped, 'g'), '.'); 73 stackEl.textContent = cleanStack; 74 } else { 75 stackEl.textContent = e; 76 } 77 el.appendChild(stackEl); 78 return el; 79 } 80 81 function createOutputDiv(opt_title, opt_element) { 82 var el = document.createElement('test-case-output'); 83 if (opt_title) { 84 var titleEl = document.createElement('div'); 85 titleEl.textContent = opt_title; 86 el.appendChild(titleEl); 87 } 88 var contentEl = opt_element || document.createElement('div'); 89 contentEl.style.border = '1px solid black'; 90 el.appendChild(contentEl); 91 92 el.__defineGetter__('contents', function() { 93 return contentEl; 94 }); 95 return el; 96 } 97 98 function statusToClassName(status) { 99 if (status == 'PASSED') 100 return 'unittest-green'; 101 else if (status == 'RUNNING' || status == 'READY') 102 return 'unittest-yellow'; 103 else 104 return 'unittest-red'; 105 } 106 107 function updateClassListGivenStatus(el, status) { 108 var newClass = statusToClassName(status); 109 if (newClass != 'unittest-green') 110 el.classList.remove('unittest-green'); 111 if (newClass != 'unittest-yellow') 112 el.classList.remove('unittest-yellow'); 113 if (newClass != 'unittest-red') 114 el.classList.remove('unittest-red'); 115 116 el.classList.add(newClass); 117 } 118 119 function HTMLTestRunner(opt_title, opt_curHash) { 120 // This constructs a HTMLDivElement and then adds our own runner methods to 121 // it. This is usually done via ui.js' define system, but we dont want our 122 // test runner to be dependent on the UI lib. :) 123 var outputEl = document.createElement('unittest-test-runner'); 124 outputEl.__proto__ = HTMLTestRunner.prototype; 125 this.decorate.call(outputEl, opt_title, opt_curHash); 126 return outputEl; 127 } 128 129 HTMLTestRunner.prototype = { 130 __proto__: HTMLDivElement.prototype, 131 132 decorate: function(opt_title, opt_curHash) { 133 this.running = false; 134 135 this.currentTest_ = undefined; 136 this.results = undefined; 137 if (opt_curHash) { 138 var trimmedHash = opt_curHash.substring(1); 139 this.filterFunc_ = function(testName) { 140 return testName.indexOf(trimmedHash) == 0; 141 }; 142 } else 143 this.filterFunc_ = function(testName) { return true; }; 144 145 this.statusEl_ = document.createElement('title'); 146 this.appendChild(this.statusEl_); 147 148 this.resultsEl_ = document.createElement('div'); 149 this.appendChild(this.resultsEl_); 150 151 this.title_ = opt_title || document.title; 152 153 this.updateStatus(); 154 }, 155 156 computeResultStats: function() { 157 var numTestsRun = 0; 158 var numTestsPassed = 0; 159 var numTestsWithErrors = 0; 160 if (this.results) { 161 for (var i = 0; i < this.results.length; i++) { 162 numTestsRun++; 163 if (this.results[i].errors.length) 164 numTestsWithErrors++; 165 else 166 numTestsPassed++; 167 } 168 } 169 return { 170 numTestsRun: numTestsRun, 171 numTestsPassed: numTestsPassed, 172 numTestsWithErrors: numTestsWithErrors 173 }; 174 }, 175 176 updateStatus: function() { 177 var stats = this.computeResultStats(); 178 var status; 179 if (!this.results) { 180 status = 'READY'; 181 } else if (this.running) { 182 status = 'RUNNING'; 183 } else { 184 if (stats.numTestsRun && stats.numTestsWithErrors == 0) 185 status = 'PASSED'; 186 else 187 status = 'FAILED'; 188 } 189 190 updateClassListGivenStatus(this.statusEl_, status); 191 this.statusEl_.textContent = this.title_ + ' [' + status + ']'; 192 }, 193 194 get done() { 195 return this.results && this.running == false; 196 }, 197 198 run: function(tests) { 199 this.results = []; 200 this.running = true; 201 this.updateStatus(); 202 for (var i = 0; i < tests.length; i++) { 203 if (!this.filterFunc_(tests[i].testName)) 204 continue; 205 tests[i].run(this); 206 this.updateStatus(); 207 } 208 this.running = false; 209 this.updateStatus(); 210 }, 211 212 willRunTest: function(test) { 213 this.currentTest_ = test; 214 this.currentResults_ = {testName: test.testName, 215 errors: []}; 216 this.results.push(this.currentResults_); 217 218 this.currentTestCaseEl_ = createTestCaseDiv(test.testName); 219 this.currentTestCaseEl_.status = 'RUNNING'; 220 this.resultsEl_.appendChild(this.currentTestCaseEl_); 221 }, 222 223 /** 224 * Adds some html content to the currently running test 225 * @param {String} opt_title The title for the output. 226 * @param {HTMLElement} opt_element The element to add. If not added, then. 227 * @return {HTMLElement} The element added, or if !opt_element, the element 228 * created. 229 */ 230 addHTMLOutput: function(opt_title, opt_element) { 231 return this.currentTestCaseEl_.addHTMLOutput(opt_title, opt_element); 232 }, 233 234 addError: function(e) { 235 this.currentResults_.errors.push(e); 236 return this.currentTestCaseEl_.addError(this.currentTest_, e); 237 }, 238 239 didRunTest: function(test) { 240 if (!this.currentResults_.errors.length) 241 this.currentTestCaseEl_.status = 'PASSED'; 242 else 243 this.currentTestCaseEl_.status = 'FAILED'; 244 245 this.currentResults_ = undefined; 246 this.currentTest_ = undefined; 247 } 248 }; 249 250 function TestError(opt_message) { 251 var that = new Error(opt_message); 252 Error.captureStackTrace(that, TestError); 253 that.__proto__ = TestError.prototype; 254 return that; 255 } 256 257 TestError.prototype = { 258 __proto__: Error.prototype 259 }; 260 261 /* 262 * @constructor TestCase 263 */ 264 function TestCase(testMethod, opt_testMethodName) { 265 if (!testMethod) 266 throw new Error('testMethod must be provided'); 267 if (testMethod.name == '' && !opt_testMethodName) 268 throw new Error('testMethod must have a name, ' + 269 'or opt_testMethodName must be provided.'); 270 271 this.testMethod_ = testMethod; 272 this.testMethodName_ = opt_testMethodName || testMethod.name; 273 this.results_ = undefined; 274 }; 275 276 function forAllAssertAndEnsureMethodsIn_(prototype, fn) { 277 for (var fieldName in prototype) { 278 if (fieldName.indexOf('assert') != 0 && 279 fieldName.indexOf('ensure') != 0) 280 continue; 281 var fieldValue = prototype[fieldName]; 282 if (typeof fieldValue != 'function') 283 continue; 284 fn(fieldName, fieldValue); 285 } 286 } 287 288 TestCase.prototype = { 289 __proto__: Object.prototype, 290 291 get testName() { 292 return this.testMethodName_; 293 }, 294 295 bindGlobals_: function() { 296 forAllAssertAndEnsureMethodsIn_(TestCase.prototype, 297 function(fieldName, fieldValue) { 298 global[fieldName] = fieldValue.bind(this); 299 }); 300 }, 301 302 unbindGlobals_: function() { 303 forAllAssertAndEnsureMethodsIn_(TestCase.prototype, 304 function(fieldName, fieldValue) { 305 delete global[fieldName]; 306 }); 307 }, 308 309 /** 310 * Adds some html content to the currently running test 311 * @param {String} opt_title The title for the output. 312 * @param {HTMLElement} opt_element The element to add. If not added, then. 313 * @return {HTMLElement} The element added, or if !opt_element, the element 314 * created. 315 */ 316 addHTMLOutput: function(opt_title, opt_element) { 317 return this.results_.addHTMLOutput(opt_title, opt_element); 318 }, 319 320 assertTrue: function(a, opt_message) { 321 if (a) 322 return; 323 var message = opt_message || 'Expected true, got ' + a; 324 throw new TestError(message); 325 }, 326 327 assertFalse: function(a, opt_message) { 328 if (!a) 329 return; 330 var message = opt_message || 'Expected false, got ' + a; 331 throw new TestError(message); 332 }, 333 334 assertUndefined: function(a, opt_message) { 335 if (a === undefined) 336 return; 337 var message = opt_message || 'Expected undefined, got ' + a; 338 throw new TestError(message); 339 }, 340 341 assertNotUndefined: function(a, opt_message) { 342 if (a !== undefined) 343 return; 344 var message = opt_message || 'Expected not undefined, got ' + a; 345 throw new TestError(message); 346 }, 347 348 assertNull: function(a, opt_message) { 349 if (a === null) 350 return; 351 var message = opt_message || 'Expected null, got ' + a; 352 throw new TestError(message); 353 }, 354 355 assertNotNull: function(a, opt_message) { 356 if (a !== null) 357 return; 358 var message = opt_message || 'Expected non-null, got ' + a; 359 throw new TestError(message); 360 }, 361 362 assertEquals: function(a, b, opt_message) { 363 if (a == b) 364 return; 365 var message = opt_message || 'Expected ' + a + ', got ' + b; 366 throw new TestError(message); 367 }, 368 369 assertNotEquals: function(a, b, opt_message) { 370 if (a != b) 371 return; 372 var message = opt_message || 'Expected something not equal to ' + b; 373 throw new TestError(message); 374 }, 375 376 assertArrayEquals: function(a, b, opt_message) { 377 if (a.length == b.length) { 378 var ok = true; 379 for (var i = 0; i < a.length; i++) { 380 ok &= a[i] === b[i]; 381 } 382 if (ok) 383 return; 384 } 385 386 var message = opt_message || 'Expected array ' + a + ', got array ' + b; 387 throw new TestError(message); 388 }, 389 390 assertArrayShallowEquals: function(a, b, opt_message) { 391 if (a.length == b.length) { 392 var ok = true; 393 for (var i = 0; i < a.length; i++) { 394 ok &= a[i] === b[i]; 395 } 396 if (ok) 397 return; 398 } 399 400 var message = opt_message || 'Expected array ' + b + ', got array ' + a; 401 throw new TestError(message); 402 }, 403 404 assertAlmostEquals: function(a, b, opt_message) { 405 if (Math.abs(a - b) < 0.00001) 406 return; 407 var message = opt_message || 'Expected almost ' + a + ', got ' + b; 408 throw new TestError(message); 409 }, 410 411 assertThrows: function(fn, opt_message) { 412 try { 413 fn(); 414 } catch (e) { 415 return; 416 } 417 var message = opt_message || 'Expected throw from ' + fn; 418 throw new TestError(message); 419 }, 420 421 setUp: function() { 422 }, 423 424 run: function(results) { 425 this.bindGlobals_(); 426 try { 427 this.results_ = results; 428 results.willRunTest(this); 429 430 // Set up. 431 try { 432 this.setUp(); 433 } catch (e) { 434 results.addError(e); 435 return; 436 } 437 438 // Run. 439 try { 440 this.testMethod_(); 441 } catch (e) { 442 results.addError(e); 443 } 444 445 // Tear down. 446 try { 447 this.tearDown(); 448 } catch (e) { 449 if (typeof e == 'string') 450 e = new TestError(e); 451 results.addError(e); 452 } 453 } finally { 454 this.unbindGlobals_(); 455 results.didRunTest(this); 456 this.results_ = undefined; 457 } 458 }, 459 460 tearDown: function() { 461 } 462 463 }; 464 465 /** 466 * Returns an array of TestCase objects correpsonding to the tests 467 * found in the given object. This considers any functions beginning with test 468 * as a potential test. 469 * 470 * @param {object} opt_objectToEnumerate The object to enumerate, or global if 471 * not specified. 472 * @param {RegExp} opt_filter Return only tests that match this regexp. 473 */ 474 function discoverTests(opt_objectToEnumerate, opt_filter) { 475 var objectToEnumerate = opt_objectToEnumerate || global; 476 477 var tests = []; 478 for (var testMethodName in objectToEnumerate) { 479 if (testMethodName.search(/^test.+/) != 0) 480 continue; 481 482 if (opt_filter && testMethodName.search(opt_filter) == -1) 483 continue; 484 485 var testMethod = objectToEnumerate[testMethodName]; 486 if (typeof testMethod != 'function') 487 continue; 488 var testCase = new TestCase(testMethod, testMethodName); 489 tests.push(testCase); 490 } 491 tests.sort(function(a, b) { 492 return a.testName.localeCompare(b.testName); 493 }); 494 return tests; 495 } 496 497 /** 498 * Runs all unit tests. 499 */ 500 function runAllTests(opt_objectToEnumerate) { 501 var runner; 502 function init() { 503 if (runner) 504 runner.parentElement.removeChild(runner); 505 runner = new HTMLTestRunner(document.title, document.location.hash); 506 // Stash the runner on global so that the global test runner 507 // can get to it. 508 global.G_testRunner = runner; 509 } 510 511 function append() { 512 document.body.appendChild(runner); 513 } 514 515 function run() { 516 var objectToEnumerate = opt_objectToEnumerate || global; 517 var tests = discoverTests(objectToEnumerate); 518 runner.run(tests); 519 } 520 521 global.addEventListener('hashchange', function() { 522 init(); 523 append(); 524 run(); 525 }); 526 527 init(); 528 if (document.body) 529 append(); 530 else 531 document.addEventListener('DOMContentLoaded', append); 532 global.addEventListener('load', run); 533 } 534 535 if (/_test.html$/.test(document.location.pathname)) 536 runAllTests(); 537 538 return { 539 HTMLTestRunner: HTMLTestRunner, 540 TestError: TestError, 541 TestCase: TestCase, 542 discoverTests: discoverTests, 543 runAllTests: runAllTests, 544 createErrorDiv_: createErrorDiv, 545 createTestCaseDiv_: createTestCaseDiv 546 }; 547}); 548