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