// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @fileoverview A test harness loosely based on Python unittest, but that * installs global assert methods during the test for backward compatibility * with Closure tests. */ base.requireStylesheet('unittest'); base.exportTo('unittest', function() { function createTestCaseDiv(testName, opt_href, opt_alwaysShowErrorLink) { var el = document.createElement('test-case'); var titleBlockEl = document.createElement('title'); titleBlockEl.style.display = 'inline'; el.appendChild(titleBlockEl); var titleEl = document.createElement('span'); titleEl.style.marginRight = '20px'; titleBlockEl.appendChild(titleEl); var errorLink = document.createElement('a'); errorLink.textContent = 'Run individually...'; if (opt_href) errorLink.href = opt_href; else errorLink.href = '#' + testName; errorLink.style.display = 'none'; titleBlockEl.appendChild(errorLink); el.__defineSetter__('status', function(status) { titleEl.textContent = testName + ': ' + status; updateClassListGivenStatus(titleEl, status); if (status == 'FAILED' || opt_alwaysShowErrorLink) errorLink.style.display = ''; else errorLink.style.display = 'none'; }); el.addError = function(test, e) { var errorEl = createErrorDiv(test, e); el.appendChild(errorEl); return errorEl; }; el.addHTMLOutput = function(opt_title, opt_element) { var outputEl = createOutputDiv(opt_title, opt_element); el.appendChild(outputEl); return outputEl.contents; }; el.status = 'READY'; return el; } function createErrorDiv(test, e) { var el = document.createElement('test-case-error'); el.className = 'unittest-error'; var stackEl = document.createElement('test-case-stack'); if (typeof e == 'string') { stackEl.textContent = e; } else if (e.stack) { var i = document.location.pathname.lastIndexOf('/'); var path = document.location.origin + document.location.pathname.substring(0, i); var pathEscaped = path.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); var cleanStack = e.stack.replace(new RegExp(pathEscaped, 'g'), '.'); stackEl.textContent = cleanStack; } else { stackEl.textContent = e; } el.appendChild(stackEl); return el; } function createOutputDiv(opt_title, opt_element) { var el = document.createElement('test-case-output'); if (opt_title) { var titleEl = document.createElement('div'); titleEl.textContent = opt_title; el.appendChild(titleEl); } var contentEl = opt_element || document.createElement('div'); contentEl.style.border = '1px solid black'; el.appendChild(contentEl); el.__defineGetter__('contents', function() { return contentEl; }); return el; } function statusToClassName(status) { if (status == 'PASSED') return 'unittest-green'; else if (status == 'RUNNING' || status == 'READY') return 'unittest-yellow'; else return 'unittest-red'; } function updateClassListGivenStatus(el, status) { var newClass = statusToClassName(status); if (newClass != 'unittest-green') el.classList.remove('unittest-green'); if (newClass != 'unittest-yellow') el.classList.remove('unittest-yellow'); if (newClass != 'unittest-red') el.classList.remove('unittest-red'); el.classList.add(newClass); } function HTMLTestRunner(opt_title, opt_curHash) { // This constructs a HTMLDivElement and then adds our own runner methods to // it. This is usually done via ui.js' define system, but we dont want our // test runner to be dependent on the UI lib. :) var outputEl = document.createElement('unittest-test-runner'); outputEl.__proto__ = HTMLTestRunner.prototype; this.decorate.call(outputEl, opt_title, opt_curHash); return outputEl; } HTMLTestRunner.prototype = { __proto__: HTMLDivElement.prototype, decorate: function(opt_title, opt_curHash) { this.running = false; this.currentTest_ = undefined; this.results = undefined; if (opt_curHash) { var trimmedHash = opt_curHash.substring(1); this.filterFunc_ = function(testName) { return testName.indexOf(trimmedHash) == 0; }; } else this.filterFunc_ = function(testName) { return true; }; this.statusEl_ = document.createElement('title'); this.appendChild(this.statusEl_); this.resultsEl_ = document.createElement('div'); this.appendChild(this.resultsEl_); this.title_ = opt_title || document.title; this.updateStatus(); }, computeResultStats: function() { var numTestsRun = 0; var numTestsPassed = 0; var numTestsWithErrors = 0; if (this.results) { for (var i = 0; i < this.results.length; i++) { numTestsRun++; if (this.results[i].errors.length) numTestsWithErrors++; else numTestsPassed++; } } return { numTestsRun: numTestsRun, numTestsPassed: numTestsPassed, numTestsWithErrors: numTestsWithErrors }; }, updateStatus: function() { var stats = this.computeResultStats(); var status; if (!this.results) { status = 'READY'; } else if (this.running) { status = 'RUNNING'; } else { if (stats.numTestsRun && stats.numTestsWithErrors == 0) status = 'PASSED'; else status = 'FAILED'; } updateClassListGivenStatus(this.statusEl_, status); this.statusEl_.textContent = this.title_ + ' [' + status + ']'; }, get done() { return this.results && this.running == false; }, run: function(tests) { this.results = []; this.running = true; this.updateStatus(); for (var i = 0; i < tests.length; i++) { if (!this.filterFunc_(tests[i].testName)) continue; tests[i].run(this); this.updateStatus(); } this.running = false; this.updateStatus(); }, willRunTest: function(test) { this.currentTest_ = test; this.currentResults_ = {testName: test.testName, errors: []}; this.results.push(this.currentResults_); this.currentTestCaseEl_ = createTestCaseDiv(test.testName); this.currentTestCaseEl_.status = 'RUNNING'; this.resultsEl_.appendChild(this.currentTestCaseEl_); }, /** * Adds some html content to the currently running test * @param {String} opt_title The title for the output. * @param {HTMLElement} opt_element The element to add. If not added, then. * @return {HTMLElement} The element added, or if !opt_element, the element * created. */ addHTMLOutput: function(opt_title, opt_element) { return this.currentTestCaseEl_.addHTMLOutput(opt_title, opt_element); }, addError: function(e) { this.currentResults_.errors.push(e); return this.currentTestCaseEl_.addError(this.currentTest_, e); }, didRunTest: function(test) { if (!this.currentResults_.errors.length) this.currentTestCaseEl_.status = 'PASSED'; else this.currentTestCaseEl_.status = 'FAILED'; this.currentResults_ = undefined; this.currentTest_ = undefined; } }; function TestError(opt_message) { var that = new Error(opt_message); Error.captureStackTrace(that, TestError); that.__proto__ = TestError.prototype; return that; } TestError.prototype = { __proto__: Error.prototype }; /* * @constructor TestCase */ function TestCase(testMethod, opt_testMethodName) { if (!testMethod) throw new Error('testMethod must be provided'); if (testMethod.name == '' && !opt_testMethodName) throw new Error('testMethod must have a name, ' + 'or opt_testMethodName must be provided.'); this.testMethod_ = testMethod; this.testMethodName_ = opt_testMethodName || testMethod.name; this.results_ = undefined; }; function forAllAssertAndEnsureMethodsIn_(prototype, fn) { for (var fieldName in prototype) { if (fieldName.indexOf('assert') != 0 && fieldName.indexOf('ensure') != 0) continue; var fieldValue = prototype[fieldName]; if (typeof fieldValue != 'function') continue; fn(fieldName, fieldValue); } } TestCase.prototype = { __proto__: Object.prototype, get testName() { return this.testMethodName_; }, bindGlobals_: function() { forAllAssertAndEnsureMethodsIn_(TestCase.prototype, function(fieldName, fieldValue) { global[fieldName] = fieldValue.bind(this); }); }, unbindGlobals_: function() { forAllAssertAndEnsureMethodsIn_(TestCase.prototype, function(fieldName, fieldValue) { delete global[fieldName]; }); }, /** * Adds some html content to the currently running test * @param {String} opt_title The title for the output. * @param {HTMLElement} opt_element The element to add. If not added, then. * @return {HTMLElement} The element added, or if !opt_element, the element * created. */ addHTMLOutput: function(opt_title, opt_element) { return this.results_.addHTMLOutput(opt_title, opt_element); }, assertTrue: function(a, opt_message) { if (a) return; var message = opt_message || 'Expected true, got ' + a; throw new TestError(message); }, assertFalse: function(a, opt_message) { if (!a) return; var message = opt_message || 'Expected false, got ' + a; throw new TestError(message); }, assertUndefined: function(a, opt_message) { if (a === undefined) return; var message = opt_message || 'Expected undefined, got ' + a; throw new TestError(message); }, assertNotUndefined: function(a, opt_message) { if (a !== undefined) return; var message = opt_message || 'Expected not undefined, got ' + a; throw new TestError(message); }, assertNull: function(a, opt_message) { if (a === null) return; var message = opt_message || 'Expected null, got ' + a; throw new TestError(message); }, assertNotNull: function(a, opt_message) { if (a !== null) return; var message = opt_message || 'Expected non-null, got ' + a; throw new TestError(message); }, assertEquals: function(a, b, opt_message) { if (a == b) return; var message = opt_message || 'Expected ' + a + ', got ' + b; throw new TestError(message); }, assertNotEquals: function(a, b, opt_message) { if (a != b) return; var message = opt_message || 'Expected something not equal to ' + b; throw new TestError(message); }, assertArrayEquals: function(a, b, opt_message) { if (a.length == b.length) { var ok = true; for (var i = 0; i < a.length; i++) { ok &= a[i] === b[i]; } if (ok) return; } var message = opt_message || 'Expected array ' + a + ', got array ' + b; throw new TestError(message); }, assertArrayShallowEquals: function(a, b, opt_message) { if (a.length == b.length) { var ok = true; for (var i = 0; i < a.length; i++) { ok &= a[i] === b[i]; } if (ok) return; } var message = opt_message || 'Expected array ' + b + ', got array ' + a; throw new TestError(message); }, assertAlmostEquals: function(a, b, opt_message) { if (Math.abs(a - b) < 0.00001) return; var message = opt_message || 'Expected almost ' + a + ', got ' + b; throw new TestError(message); }, assertThrows: function(fn, opt_message) { try { fn(); } catch (e) { return; } var message = opt_message || 'Expected throw from ' + fn; throw new TestError(message); }, setUp: function() { }, run: function(results) { this.bindGlobals_(); try { this.results_ = results; results.willRunTest(this); // Set up. try { this.setUp(); } catch (e) { results.addError(e); return; } // Run. try { this.testMethod_(); } catch (e) { results.addError(e); } // Tear down. try { this.tearDown(); } catch (e) { if (typeof e == 'string') e = new TestError(e); results.addError(e); } } finally { this.unbindGlobals_(); results.didRunTest(this); this.results_ = undefined; } }, tearDown: function() { } }; /** * Returns an array of TestCase objects correpsonding to the tests * found in the given object. This considers any functions beginning with test * as a potential test. * * @param {object} opt_objectToEnumerate The object to enumerate, or global if * not specified. * @param {RegExp} opt_filter Return only tests that match this regexp. */ function discoverTests(opt_objectToEnumerate, opt_filter) { var objectToEnumerate = opt_objectToEnumerate || global; var tests = []; for (var testMethodName in objectToEnumerate) { if (testMethodName.search(/^test.+/) != 0) continue; if (opt_filter && testMethodName.search(opt_filter) == -1) continue; var testMethod = objectToEnumerate[testMethodName]; if (typeof testMethod != 'function') continue; var testCase = new TestCase(testMethod, testMethodName); tests.push(testCase); } tests.sort(function(a, b) { return a.testName.localeCompare(b.testName); }); return tests; } /** * Runs all unit tests. */ function runAllTests(opt_objectToEnumerate) { var runner; function init() { if (runner) runner.parentElement.removeChild(runner); runner = new HTMLTestRunner(document.title, document.location.hash); // Stash the runner on global so that the global test runner // can get to it. global.G_testRunner = runner; } function append() { document.body.appendChild(runner); } function run() { var objectToEnumerate = opt_objectToEnumerate || global; var tests = discoverTests(objectToEnumerate); runner.run(tests); } global.addEventListener('hashchange', function() { init(); append(); run(); }); init(); if (document.body) append(); else document.addEventListener('DOMContentLoaded', append); global.addEventListener('load', run); } if (/_test.html$/.test(document.location.pathname)) runAllTests(); return { HTMLTestRunner: HTMLTestRunner, TestError: TestError, TestCase: TestCase, discoverTests: discoverTests, runAllTests: runAllTests, createErrorDiv_: createErrorDiv, createTestCaseDiv_: createTestCaseDiv }; });