/** * Runs test suites and test cases, providing events to allowing for the * interpretation of test results. * @namespace Test * @module test * @class TestRunner * @static */ YUITest.TestRunner = function(){ /*(intentionally not documented) * Determines if any of the array of test groups appears * in the given TestRunner filter. * @param {Array} testGroups The array of test groups to * search for. * @param {String} filter The TestRunner groups filter. */ function inGroups(testGroups, filter){ if (!filter.length){ return true; } else { if (testGroups){ for (var i=0, len=testGroups.length; i < len; i++){ if (filter.indexOf("," + testGroups[i] + ",") > -1){ return true; } } } return false; } } /** * A node in the test tree structure. May represent a TestSuite, TestCase, or * test function. * @param {Any} testObject A TestSuite, TestCase, or the name of a test function. * @module test * @class TestNode * @constructor * @private */ function TestNode(testObject){ /** * The TestSuite, TestCase, or test function represented by this node. * @type {Any} * @property testObject */ this.testObject = testObject; /** * Pointer to this node's first child. * @type TestNode * @property firstChild */ this.firstChild = null; /** * Pointer to this node's last child. * @type TestNode * @property lastChild */ this.lastChild = null; /** * Pointer to this node's parent. * @type TestNode * @property parent */ this.parent = null; /** * Pointer to this node's next sibling. * @type TestNode * @property next */ this.next = null; /** * Test results for this test object. * @type object * @property results */ this.results = new YUITest.Results(); //initialize results if (testObject instanceof YUITest.TestSuite){ this.results.type = "testsuite"; this.results.name = testObject.name; } else if (testObject instanceof YUITest.TestCase){ this.results.type = "testcase"; this.results.name = testObject.name; } } TestNode.prototype = { /** * Appends a new test object (TestSuite, TestCase, or test function name) as a child * of this node. * @param {Any} testObject A TestSuite, TestCase, or the name of a test function. * @method appendChild */ appendChild : function (testObject){ var node = new TestNode(testObject); if (this.firstChild === null){ this.firstChild = this.lastChild = node; } else { this.lastChild.next = node; this.lastChild = node; } node.parent = this; return node; } }; /** * Runs test suites and test cases, providing events to allowing for the * interpretation of test results. * @namespace Test * @module test * @class Runner * @static */ function TestRunner(){ //inherit from EventTarget YUITest.EventTarget.call(this); /** * Suite on which to attach all TestSuites and TestCases to be run. * @type YUITest.TestSuite * @property masterSuite * @static * @private */ this.masterSuite = new YUITest.TestSuite(YUITest.guid('testSuite_')); /** * Pointer to the current node in the test tree. * @type TestNode * @private * @property _cur * @static */ this._cur = null; /** * Pointer to the root node in the test tree. * @type TestNode * @private * @property _root * @static */ this._root = null; /** * Indicates if the TestRunner will log events or not. * @type Boolean * @property _log * @private * @static */ this._log = true; /** * Indicates if the TestRunner is waiting as a result of * wait() being called. * @type Boolean * @property _waiting * @private * @static */ this._waiting = false; /** * Indicates if the TestRunner is currently running tests. * @type Boolean * @private * @property _running * @static */ this._running = false; /** * Holds copy of the results object generated when all tests are * complete. * @type Object * @private * @property _lastResults * @static */ this._lastResults = null; /** * Data object that is passed around from method to method. * @type Object * @private * @property _data * @static */ this._context = null; /** * The list of test groups to run. The list is represented * by a comma delimited string with commas at the start and * end. * @type String * @private * @property _groups * @static */ this._groups = ""; } TestRunner.prototype = YUITest.Util.mix(new YUITest.EventTarget(), { /** * If true, YUITest will not fire an error for tests with no Asserts. * @property _ignoreEmpty * @private * @type Boolean * @static */ _ignoreEmpty: false, //restore prototype constructor: YUITest.TestRunner, //------------------------------------------------------------------------- // Constants //------------------------------------------------------------------------- /** * Fires when a test case is opened but before the first * test is executed. * @event testcasebegin * @static */ TEST_CASE_BEGIN_EVENT : "testcasebegin", /** * Fires when all tests in a test case have been executed. * @event testcasecomplete * @static */ TEST_CASE_COMPLETE_EVENT : "testcasecomplete", /** * Fires when a test suite is opened but before the first * test is executed. * @event testsuitebegin * @static */ TEST_SUITE_BEGIN_EVENT : "testsuitebegin", /** * Fires when all test cases in a test suite have been * completed. * @event testsuitecomplete * @static */ TEST_SUITE_COMPLETE_EVENT : "testsuitecomplete", /** * Fires when a test has passed. * @event pass * @static */ TEST_PASS_EVENT : "pass", /** * Fires when a test has failed. * @event fail * @static */ TEST_FAIL_EVENT : "fail", /** * Fires when a non-test method has an error. * @event error * @static */ ERROR_EVENT : "error", /** * Fires when a test has been ignored. * @event ignore * @static */ TEST_IGNORE_EVENT : "ignore", /** * Fires when all test suites and test cases have been completed. * @event complete * @static */ COMPLETE_EVENT : "complete", /** * Fires when the run() method is called. * @event begin * @static */ BEGIN_EVENT : "begin", //------------------------------------------------------------------------- // Test Tree-Related Methods //------------------------------------------------------------------------- /** * Adds a test case to the test tree as a child of the specified node. * @param {TestNode} parentNode The node to add the test case to as a child. * @param {Test.TestCase} testCase The test case to add. * @static * @private * @method _addTestCaseToTestTree */ _addTestCaseToTestTree : function (parentNode, testCase){ //add the test suite var node = parentNode.appendChild(testCase), prop, testName; //iterate over the items in the test case for (prop in testCase){ if ((prop.indexOf("test") === 0 || prop.indexOf(" ") > -1) && typeof testCase[prop] == "function"){ node.appendChild(prop); } } }, /** * Adds a test suite to the test tree as a child of the specified node. * @param {TestNode} parentNode The node to add the test suite to as a child. * @param {Test.TestSuite} testSuite The test suite to add. * @static * @private * @method _addTestSuiteToTestTree */ _addTestSuiteToTestTree : function (parentNode, testSuite) { //add the test suite var node = parentNode.appendChild(testSuite); //iterate over the items in the master suite for (var i=0; i < testSuite.items.length; i++){ if (testSuite.items[i] instanceof YUITest.TestSuite) { this._addTestSuiteToTestTree(node, testSuite.items[i]); } else if (testSuite.items[i] instanceof YUITest.TestCase) { this._addTestCaseToTestTree(node, testSuite.items[i]); } } }, /** * Builds the test tree based on items in the master suite. The tree is a hierarchical * representation of the test suites, test cases, and test functions. The resulting tree * is stored in _root and the pointer _cur is set to the root initially. * @static * @private * @method _buildTestTree */ _buildTestTree : function () { this._root = new TestNode(this.masterSuite); //this._cur = this._root; //iterate over the items in the master suite for (var i=0; i < this.masterSuite.items.length; i++){ if (this.masterSuite.items[i] instanceof YUITest.TestSuite) { this._addTestSuiteToTestTree(this._root, this.masterSuite.items[i]); } else if (this.masterSuite.items[i] instanceof YUITest.TestCase) { this._addTestCaseToTestTree(this._root, this.masterSuite.items[i]); } } }, //------------------------------------------------------------------------- // Private Methods //------------------------------------------------------------------------- /** * Handles the completion of a test object's tests. Tallies test results * from one level up to the next. * @param {TestNode} node The TestNode representing the test object. * @method _handleTestObjectComplete * @private */ _handleTestObjectComplete : function (node) { var parentNode; if (node && (typeof node.testObject == "object")) { parentNode = node.parent; if (parentNode){ parentNode.results.include(node.results); parentNode.results[node.testObject.name] = node.results; } if (node.testObject instanceof YUITest.TestSuite){ this._execNonTestMethod(node, "tearDown", false); node.results.duration = (new Date()) - node._start; this.fire({ type: this.TEST_SUITE_COMPLETE_EVENT, testSuite: node.testObject, results: node.results}); } else if (node.testObject instanceof YUITest.TestCase){ this._execNonTestMethod(node, "destroy", false); node.results.duration = (new Date()) - node._start; this.fire({ type: this.TEST_CASE_COMPLETE_EVENT, testCase: node.testObject, results: node.results}); } } }, //------------------------------------------------------------------------- // Navigation Methods //------------------------------------------------------------------------- /** * Retrieves the next node in the test tree. * @return {TestNode} The next node in the test tree or null if the end is reached. * @private * @static * @method _next */ _next : function () { if (this._cur === null){ this._cur = this._root; } else if (this._cur.firstChild) { this._cur = this._cur.firstChild; } else if (this._cur.next) { this._cur = this._cur.next; } else { while (this._cur && !this._cur.next && this._cur !== this._root){ this._handleTestObjectComplete(this._cur); this._cur = this._cur.parent; } this._handleTestObjectComplete(this._cur); if (this._cur == this._root){ this._cur.results.type = "report"; this._cur.results.timestamp = (new Date()).toLocaleString(); this._cur.results.duration = (new Date()) - this._cur._start; this._lastResults = this._cur.results; this._running = false; this.fire({ type: this.COMPLETE_EVENT, results: this._lastResults}); this._cur = null; } else if (this._cur) { this._cur = this._cur.next; } } return this._cur; }, /** * Executes a non-test method (init, setUp, tearDown, destroy) * and traps an errors. If an error occurs, an error event is * fired. * @param {Object} node The test node in the testing tree. * @param {String} methodName The name of the method to execute. * @param {Boolean} allowAsync Determines if the method can be called asynchronously. * @return {Boolean} True if an async method was called, false if not. * @method _execNonTestMethod * @private */ _execNonTestMethod: function(node, methodName, allowAsync){ var testObject = node.testObject, event = { type: this.ERROR_EVENT }; try { if (allowAsync && testObject["async:" + methodName]){ testObject["async:" + methodName](this._context); return true; } else { testObject[methodName](this._context); } } catch (ex){ node.results.errors++; event.error = ex; event.methodName = methodName; if (testObject instanceof YUITest.TestCase){ event.testCase = testObject; } else { event.testSuite = testSuite; } this.fire(event); } return false; }, /** * Runs a test case or test suite, returning the results. * @param {Test.TestCase|YUITest.TestSuite} testObject The test case or test suite to run. * @return {Object} Results of the execution with properties passed, failed, and total. * @private * @method _run * @static */ _run : function () { //flag to indicate if the TestRunner should wait before continuing var shouldWait = false; //get the next test node var node = this._next(); if (node !== null) { //set flag to say the testrunner is running this._running = true; //eliminate last results this._lastResult = null; var testObject = node.testObject; //figure out what to do if (typeof testObject == "object" && testObject !== null){ if (testObject instanceof YUITest.TestSuite){ this.fire({ type: this.TEST_SUITE_BEGIN_EVENT, testSuite: testObject }); node._start = new Date(); this._execNonTestMethod(node, "setUp" ,false); } else if (testObject instanceof YUITest.TestCase){ this.fire({ type: this.TEST_CASE_BEGIN_EVENT, testCase: testObject }); node._start = new Date(); //regular or async init /*try { if (testObject["async:init"]){ testObject["async:init"](this._context); return; } else { testObject.init(this._context); } } catch (ex){ node.results.errors++; this.fire({ type: this.ERROR_EVENT, error: ex, testCase: testObject, methodName: "init" }); }*/ if(this._execNonTestMethod(node, "init", true)){ return; } } //some environments don't support setTimeout if (typeof setTimeout != "undefined"){ setTimeout(function(){ YUITest.TestRunner._run(); }, 0); } else { this._run(); } } else { this._runTest(node); } } }, _resumeTest : function (segment) { //get relevant information var node = this._cur; //we know there's no more waiting now this._waiting = false; //if there's no node, it probably means a wait() was called after resume() if (!node){ //TODO: Handle in some way? //console.log("wait() called after resume()"); //this.fire("error", { testCase: "(unknown)", test: "(unknown)", error: new Error("wait() called after resume()")} ); return; } var testName = node.testObject; var testCase = node.parent.testObject; //cancel other waits if available if (testCase.__yui_wait){ clearTimeout(testCase.__yui_wait); delete testCase.__yui_wait; } //get the "should" test cases var shouldFail = testName.indexOf("fail:") === 0 || (testCase._should.fail || {})[testName]; var shouldError = (testCase._should.error || {})[testName]; //variable to hold whether or not the test failed var failed = false; var error = null; //try the test try { //run the test segment.call(testCase, this._context); //if the test hasn't already failed and doesn't have any asserts... if(YUITest.Assert._getCount() == 0 && !this._ignoreEmpty){ throw new YUITest.AssertionError("Test has no asserts."); } //if it should fail, and it got here, then it's a fail because it didn't else if (shouldFail){ error = new YUITest.ShouldFail(); failed = true; } else if (shouldError){ error = new YUITest.ShouldError(); failed = true; } } catch (thrown){ //cancel any pending waits, the test already failed if (testCase.__yui_wait){ clearTimeout(testCase.__yui_wait); delete testCase.__yui_wait; } //figure out what type of error it was if (thrown instanceof YUITest.AssertionError) { if (!shouldFail){ error = thrown; failed = true; } } else if (thrown instanceof YUITest.Wait){ if (typeof thrown.segment == "function"){ if (typeof thrown.delay == "number"){ //some environments don't support setTimeout if (typeof setTimeout != "undefined"){ testCase.__yui_wait = setTimeout(function(){ YUITest.TestRunner._resumeTest(thrown.segment); }, thrown.delay); this._waiting = true; } else { throw new Error("Asynchronous tests not supported in this environment."); } } } return; } else { //first check to see if it should error if (!shouldError) { error = new YUITest.UnexpectedError(thrown); failed = true; } else { //check to see what type of data we have if (typeof shouldError == "string"){ //if it's a string, check the error message if (thrown.message != shouldError){ error = new YUITest.UnexpectedError(thrown); failed = true; } } else if (typeof shouldError == "function"){ //if it's a function, see if the error is an instance of it if (!(thrown instanceof shouldError)){ error = new YUITest.UnexpectedError(thrown); failed = true; } } else if (typeof shouldError == "object" && shouldError !== null){ //if it's an object, check the instance and message if (!(thrown instanceof shouldError.constructor) || thrown.message != shouldError.message){ error = new YUITest.UnexpectedError(thrown); failed = true; } } } } } //fire appropriate event if (failed) { this.fire({ type: this.TEST_FAIL_EVENT, testCase: testCase, testName: testName, error: error }); } else { this.fire({ type: this.TEST_PASS_EVENT, testCase: testCase, testName: testName }); } //run the tear down this._execNonTestMethod(node.parent, "tearDown", false); //reset the assert count YUITest.Assert._reset(); //calculate duration var duration = (new Date()) - node._start; //update results node.parent.results[testName] = { result: failed ? "fail" : "pass", message: error ? error.getMessage() : "Test passed", type: "test", name: testName, duration: duration }; if (failed){ node.parent.results.failed++; } else { node.parent.results.passed++; } node.parent.results.total++; //set timeout not supported in all environments if (typeof setTimeout != "undefined"){ setTimeout(function(){ YUITest.TestRunner._run(); }, 0); } else { this._run(); } }, /** * Handles an error as if it occurred within the currently executing * test. This is for mock methods that may be called asynchronously * and therefore out of the scope of the TestRunner. Previously, this * error would bubble up to the browser. Now, this method is used * to tell TestRunner about the error. This should never be called * by anyplace other than the Mock object. * @param {Error} error The error object. * @method _handleError * @private * @static */ _handleError: function(error){ if (this._waiting){ this._resumeTest(function(){ throw error; }); } else { throw error; } }, /** * Runs a single test based on the data provided in the node. * @method _runTest * @param {TestNode} node The TestNode representing the test to run. * @static * @private */ _runTest : function (node) { //get relevant information var testName = node.testObject, testCase = node.parent.testObject, test = testCase[testName], //get the "should" test cases shouldIgnore = testName.indexOf("ignore:") === 0 || !inGroups(testCase.groups, this._groups) || (testCase._should.ignore || {})[testName]; //deprecated //figure out if the test should be ignored or not if (shouldIgnore){ //update results node.parent.results[testName] = { result: "ignore", message: "Test ignored", type: "test", name: testName.indexOf("ignore:") === 0 ? testName.substring(7) : testName }; node.parent.results.ignored++; node.parent.results.total++; this.fire({ type: this.TEST_IGNORE_EVENT, testCase: testCase, testName: testName }); //some environments don't support setTimeout if (typeof setTimeout != "undefined"){ setTimeout(function(){ YUITest.TestRunner._run(); }, 0); } else { this._run(); } } else { //mark the start time node._start = new Date(); //run the setup this._execNonTestMethod(node.parent, "setUp", false); //now call the body of the test this._resumeTest(test); } }, //------------------------------------------------------------------------- // Misc Methods //------------------------------------------------------------------------- /** * Retrieves the name of the current result set. * @return {String} The name of the result set. * @method getName */ getName: function(){ return this.masterSuite.name; }, /** * The name assigned to the master suite of the TestRunner. This is the name * that is output as the root's name when results are retrieved. * @param {String} name The name of the result set. * @method setName */ setName: function(name){ this.masterSuite.name = name; }, //------------------------------------------------------------------------- // Public Methods //------------------------------------------------------------------------- /** * Adds a test suite or test case to the list of test objects to run. * @param testObject Either a TestCase or a TestSuite that should be run. * @method add * @static */ add : function (testObject) { this.masterSuite.add(testObject); return this; }, /** * Removes all test objects from the runner. * @method clear * @static */ clear : function () { this.masterSuite = new YUITest.TestSuite(YUITest.guid('testSuite_')); }, /** * Indicates if the TestRunner is waiting for a test to resume * @return {Boolean} True if the TestRunner is waiting, false if not. * @method isWaiting * @static */ isWaiting: function() { return this._waiting; }, /** * Indicates that the TestRunner is busy running tests and therefore can't * be stopped and results cannot be gathered. * @return {Boolean} True if the TestRunner is running, false if not. * @method isRunning */ isRunning: function(){ return this._running; }, /** * Returns the last complete results set from the TestRunner. Null is returned * if the TestRunner is running or no tests have been run. * @param {Function} format (Optional) A test format to return the results in. * @return {Object|String} Either the results object or, if a test format is * passed as the argument, a string representing the results in a specific * format. * @method getResults */ getResults: function(format){ if (!this._running && this._lastResults){ if (typeof format == "function"){ return format(this._lastResults); } else { return this._lastResults; } } else { return null; } }, /** * Returns the coverage report for the files that have been executed. * This returns only coverage information for files that have been * instrumented using YUI Test Coverage and only those that were run * in the same pass. * @param {Function} format (Optional) A coverage format to return results in. * @return {Object|String} Either the coverage object or, if a coverage * format is specified, a string representing the results in that format. * @method getCoverage */ getCoverage: function(format) { var covObject = null; if (typeof _yuitest_coverage === "object") { covObject = _yuitest_coverage; } if (typeof __coverage__ === "object") { covObject = __coverage__; } if (!this._running && typeof covObject == "object"){ if (typeof format == "function") { return format(covObject); } else { return covObject; } } else { return null; } }, /** * Used to continue processing when a method marked with * "async:" is executed. This should not be used in test * methods, only in init(). Each argument is a string, and * when the returned function is executed, the arguments * are assigned to the context data object using the string * as the key name (value is the argument itself). * @private * @return {Function} A callback function. * @method callback */ callback: function(){ var names = arguments, data = this._context, that = this; return function(){ for (var i=0; i < arguments.length; i++){ data[names[i]] = arguments[i]; } that._run(); }; }, /** * Resumes the TestRunner after wait() was called. * @param {Function} segment The function to run as the rest * of the haulted test. * @method resume * @static */ resume : function (segment) { if (this._waiting){ this._resumeTest(segment || function(){}); } else { throw new Error("resume() called without wait()."); } }, /** * Runs the test suite. * @param {Object|Boolean} options (Optional) Options for the runner: * <code>oldMode</code> indicates the TestRunner should work in the YUI <= 2.8 way * of internally managing test suites. <code>groups</code> is an array * of test groups indicating which tests to run. * @method run * @static */ run : function (options) { options = options || {}; //pointer to runner to avoid scope issues var runner = YUITest.TestRunner, oldMode = options.oldMode; //if there's only one suite on the masterSuite, move it up if (!oldMode && this.masterSuite.items.length == 1 && this.masterSuite.items[0] instanceof YUITest.TestSuite){ this.masterSuite = this.masterSuite.items[0]; } //determine if there are any groups to filter on runner._groups = (options.groups instanceof Array) ? "," + options.groups.join(",") + "," : ""; //initialize the runner runner._buildTestTree(); runner._context = {}; runner._root._start = new Date(); //fire the begin event runner.fire(runner.BEGIN_EVENT); //begin the testing runner._run(); } }); return new TestRunner(); }();