// Copyright 2010 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // All Rights Reserved. /** * @fileoverview A class representing a set of test functions that use * asynchronous functions that cannot be meaningfully mocked. * * To create a Google-compatible JsUnit test using this test case, put the * following snippet in your test: * * var asyncTestCase = goog.testing.AsyncTestCase.createAndInstall(); * * To make the test runner wait for your asynchronous behaviour, use: * * asyncTestCase.waitForAsync('Waiting for xhr to respond'); * * The next test will not start until the following call is made, or a * timeout occurs: * * asyncTestCase.continueTesting(); * * There does NOT need to be a 1:1 mapping of waitForAsync calls and * continueTesting calls. The next test will be run after a single call to * continueTesting is made, as long as there is no subsequent call to * waitForAsync in the same thread. * * Example: * // Returning here would cause the next test to be run. * asyncTestCase.waitForAsync('description 1'); * // Returning here would *not* cause the next test to be run. * // Only effect of additional waitForAsync() calls is an updated * // description in the case of a timeout. * asyncTestCase.waitForAsync('updated description'); * asyncTestCase.continueTesting(); * // Returning here would cause the next test to be run. * asyncTestCase.waitForAsync('just kidding, still running.'); * // Returning here would *not* cause the next test to be run. * * The test runner can also be made to wait for more than one asynchronous * event with: * * asyncTestCase.waitForSignals(n); * * The next test will not start until asyncTestCase.signal() is called n times, * or the test step timeout is exceeded. * * This class supports asynchronous behaviour in all test functions except for * tearDownPage. If such support is needed, it can be added. * * Example Usage: * * var asyncTestCase = goog.testing.AsyncTestCase.createAndInstall(); * // Optionally, set a longer-than-normal step timeout. * asyncTestCase.stepTimeout = 30 * 1000; * * function testSetTimeout() { * var step = 0; * function stepCallback() { * step++; * switch (step) { * case 1: * var startTime = goog.now(); * asyncTestCase.waitForAsync('step 1'); * window.setTimeout(stepCallback, 100); * break; * case 2: * assertTrue('Timeout fired too soon', * goog.now() - startTime >= 100); * asyncTestCase.waitForAsync('step 2'); * window.setTimeout(stepCallback, 100); * break; * case 3: * assertTrue('Timeout fired too soon', * goog.now() - startTime >= 200); * asyncTestCase.continueTesting(); * break; * default: * fail('Unexpected call to stepCallback'); * } * } * stepCallback(); * } * * Known Issues: * IE7 Exceptions: * As the failingtest.html will show, it appears as though ie7 does not * propagate an exception past a function called using the func.call() * syntax. This causes case 3 of the failing tests (exceptions) to show up * as timeouts in IE. * window.onerror: * This seems to catch errors only in ff2/ff3. It does not work in Safari or * IE7. The consequence of this is that exceptions that would have been * caught by window.onerror show up as timeouts. * * @author agrieve@google.com (Andrew Grieve) */ goog.setTestOnly('goog.testing.AsyncTestCase'); goog.provide('goog.testing.AsyncTestCase'); goog.provide('goog.testing.AsyncTestCase.ControlBreakingException'); goog.require('goog.testing.TestCase'); goog.require('goog.testing.asserts'); /** * A test case that is capable of running tests that contain asynchronous logic. * @param {string=} opt_name A descriptive name for the test case. * @extends {goog.testing.TestCase} * @constructor * @deprecated Use goog.testing.TestCase instead. goog.testing.TestCase now * supports async testing using promises. */ goog.testing.AsyncTestCase = function(opt_name) { goog.testing.TestCase.call(this, opt_name); }; goog.inherits(goog.testing.AsyncTestCase, goog.testing.TestCase); /** * Represents result of top stack function call. * @typedef {{controlBreakingExceptionThrown: boolean, message: string}} * @private */ goog.testing.AsyncTestCase.TopStackFuncResult_; /** * An exception class used solely for control flow. * @param {string=} opt_message Error message. * @constructor * @extends {Error} * @final */ goog.testing.AsyncTestCase.ControlBreakingException = function(opt_message) { goog.testing.AsyncTestCase.ControlBreakingException.base( this, 'constructor', opt_message); /** * The exception message. * @type {string} */ this.message = opt_message || ''; }; goog.inherits(goog.testing.AsyncTestCase.ControlBreakingException, Error); /** * Return value for .toString(). * @type {string} */ goog.testing.AsyncTestCase.ControlBreakingException.TO_STRING = '[AsyncTestCase.ControlBreakingException]'; /** * Marks this object as a ControlBreakingException * @type {boolean} */ goog.testing.AsyncTestCase.ControlBreakingException.prototype .isControlBreakingException = true; /** @override */ goog.testing.AsyncTestCase.ControlBreakingException.prototype.toString = function() { // This shows up in the console when the exception is not caught. return goog.testing.AsyncTestCase.ControlBreakingException.TO_STRING; }; /** * How long to wait for a single step of a test to complete in milliseconds. * A step starts when a call to waitForAsync() is made. * @type {number} */ goog.testing.AsyncTestCase.prototype.stepTimeout = 1000; /** * How long to wait after a failed test before moving onto the next one. * The purpose of this is to allow any pending async callbacks from the failing * test to finish up and not cause the next test to fail. * @type {number} */ goog.testing.AsyncTestCase.prototype.timeToSleepAfterFailure = 500; /** * Turn on extra logging to help debug failing async. tests. * @type {boolean} * @private */ goog.testing.AsyncTestCase.prototype.enableDebugLogs_ = false; /** * A reference to the original asserts.js assert_() function. * @private */ goog.testing.AsyncTestCase.prototype.origAssert_; /** * A reference to the original asserts.js fail() function. * @private */ goog.testing.AsyncTestCase.prototype.origFail_; /** * A reference to the original window.onerror function. * @type {Function|undefined} * @private */ goog.testing.AsyncTestCase.prototype.origOnError_; /** * The stage of the test we are currently on. * @type {Function|undefined}} * @private */ goog.testing.AsyncTestCase.prototype.curStepFunc_; /** * The name of the stage of the test we are currently on. * @type {string} * @private */ goog.testing.AsyncTestCase.prototype.curStepName_ = ''; /** * The stage of the test we should run next. * @type {Function|undefined} * @private */ goog.testing.AsyncTestCase.prototype.nextStepFunc_; /** * The name of the stage of the test we should run next. * @type {string} * @private */ goog.testing.AsyncTestCase.prototype.nextStepName_ = ''; /** * The handle to the current setTimeout timer. * @type {number} * @private */ goog.testing.AsyncTestCase.prototype.timeoutHandle_ = 0; /** * Marks if the cleanUp() function has been called for the currently running * test. * @type {boolean} * @private */ goog.testing.AsyncTestCase.prototype.cleanedUp_ = false; /** * The currently active test. * @type {goog.testing.TestCase.Test|undefined} * @protected */ goog.testing.AsyncTestCase.prototype.activeTest; /** * A flag to prevent recursive exception handling. * @type {boolean} * @private */ goog.testing.AsyncTestCase.prototype.inException_ = false; /** * Flag used to determine if we can move to the next step in the testing loop. * @type {boolean} * @private */ goog.testing.AsyncTestCase.prototype.isReady_ = true; /** * Number of signals to wait for before continuing testing when waitForSignals * is used. * @type {number} * @private */ goog.testing.AsyncTestCase.prototype.expectedSignalCount_ = 0; /** * Number of signals received. * @type {number} * @private */ goog.testing.AsyncTestCase.prototype.receivedSignalCount_ = 0; /** * Flag that tells us if there is a function in the call stack that will make * a call to pump_(). * @type {boolean} * @private */ goog.testing.AsyncTestCase.prototype.returnWillPump_ = false; /** * The number of times we have thrown a ControlBreakingException so that we * know not to complain in our window.onerror handler. In Webkit, window.onerror * is not supported, and so this counter will keep going up but we won't care * about it. * @type {number} * @private */ goog.testing.AsyncTestCase.prototype.numControlExceptionsExpected_ = 0; /** * The current step name. * @return {string} Step name. * @protected */ goog.testing.AsyncTestCase.prototype.getCurrentStepName = function() { return this.curStepName_; }; /** * Preferred way of creating an AsyncTestCase. Creates one and initializes it * with the G_testRunner. * @param {string=} opt_name A descriptive name for the test case. * @return {!goog.testing.AsyncTestCase} The created AsyncTestCase. */ goog.testing.AsyncTestCase.createAndInstall = function(opt_name) { var asyncTestCase = new goog.testing.AsyncTestCase(opt_name); goog.testing.TestCase.initializeTestRunner(asyncTestCase); return asyncTestCase; }; /** * Informs the testcase not to continue to the next step in the test cycle * until continueTesting is called. * @param {string=} opt_name A description of what we are waiting for. */ goog.testing.AsyncTestCase.prototype.waitForAsync = function(opt_name) { this.isReady_ = false; this.curStepName_ = opt_name || this.curStepName_; // Reset the timer that tracks if the async test takes too long. this.stopTimeoutTimer_(); this.startTimeoutTimer_(); }; /** * Continue with the next step in the test cycle. */ goog.testing.AsyncTestCase.prototype.continueTesting = function() { if (this.receivedSignalCount_ < this.expectedSignalCount_) { var remaining = this.expectedSignalCount_ - this.receivedSignalCount_; throw Error('Still waiting for ' + remaining + ' signals.'); } this.endCurrentStep_(); }; /** * Ends the current test step and queues the next test step to run. * @private */ goog.testing.AsyncTestCase.prototype.endCurrentStep_ = function() { if (!this.isReady_) { // We are a potential entry point, so we pump. this.isReady_ = true; this.stopTimeoutTimer_(); // Run this in a setTimeout so that the caller has a chance to call // waitForAsync() again before we continue. this.timeout(goog.bind(this.pump_, this, null), 0); } }; /** * Informs the testcase not to continue to the next step in the test cycle * until signal is called the specified number of times. Within a test, this * function behaves additively if called multiple times; the number of signals * to wait for will be the sum of all expected number of signals this function * was called with. * @param {number} times The number of signals to receive before * continuing testing. * @param {string=} opt_name A description of what we are waiting for. */ goog.testing.AsyncTestCase.prototype.waitForSignals = function( times, opt_name) { this.expectedSignalCount_ += times; if (this.receivedSignalCount_ < this.expectedSignalCount_) { this.waitForAsync(opt_name); } }; /** * Signals once to continue with the test. If this is the last signal that the * test was waiting on, call continueTesting. */ goog.testing.AsyncTestCase.prototype.signal = function() { if (++this.receivedSignalCount_ === this.expectedSignalCount_ && this.expectedSignalCount_ > 0) { this.endCurrentStep_(); } }; /** * Handles an exception thrown by a test. * @param {*=} opt_e The exception object associated with the failure * or a string. * @throws Always throws a ControlBreakingException. */ goog.testing.AsyncTestCase.prototype.doAsyncError = function(opt_e) { // If we've caught an exception that we threw, then just pass it along. This // can happen if doAsyncError() was called from a call to assert and then // again by pump_(). if (opt_e && opt_e.isControlBreakingException) { throw opt_e; } // Prevent another timeout error from triggering for this test step. this.stopTimeoutTimer_(); // doError() uses test.name. Here, we create a dummy test and give it a more // helpful name based on the step we're currently on. var fakeTestObj = new goog.testing.TestCase.Test(this.curStepName_, goog.nullFunction); if (this.activeTest) { fakeTestObj.name = this.activeTest.name + ' [' + fakeTestObj.name + ']'; } if (this.activeTest) { // Note: if the test has an error, and then tearDown has an error, they will // both be reported. this.doError(fakeTestObj, opt_e); } else { this.exceptionBeforeTest = opt_e; } // This is a potential entry point, so we pump. We also add in a bit of a // delay to try and prevent any async behavior from the failed test from // causing the next test to fail. this.timeout( goog.bind(this.pump_, this, this.doAsyncErrorTearDown_), this.timeToSleepAfterFailure); // We just caught an exception, so we do not want the code above us on the // stack to continue executing. If pump_ is in our call-stack, then it will // batch together multiple errors, so we only increment the count if pump_ is // not in the stack and let pump_ increment the count when it batches them. if (!this.returnWillPump_) { this.numControlExceptionsExpected_ += 1; this.dbgLog_( 'doAsynError: numControlExceptionsExpected_ = ' + this.numControlExceptionsExpected_ + ' and throwing exception.'); } // Copy the error message to ControlBreakingException. var message = ''; if (typeof opt_e == 'string') { message = opt_e; } else if (opt_e && opt_e.message) { message = opt_e.message; } throw new goog.testing.AsyncTestCase.ControlBreakingException(message); }; /** * Sets up the test page and then waits until the test case has been marked * as ready before executing the tests. * @override */ goog.testing.AsyncTestCase.prototype.runTests = function() { this.hookAssert_(); this.hookOnError_(); goog.testing.TestCase.currentTestName = null; this.setNextStep_(this.doSetUpPage_, 'setUpPage'); // We are an entry point, so we pump. this.pump_(); }; /** * Starts the tests. * @override */ goog.testing.AsyncTestCase.prototype.cycleTests = function() { // We are an entry point, so we pump. this.saveMessage('Start'); this.setNextStep_(this.doIteration_, 'doIteration'); this.pump_(); }; /** * Finalizes the test case, called when the tests have finished executing. * @override */ goog.testing.AsyncTestCase.prototype.finalize = function() { this.unhookAll_(); this.setNextStep_(null, 'finalized'); goog.testing.AsyncTestCase.superClass_.finalize.call(this); }; /** * Enables verbose logging of what is happening inside of the AsyncTestCase. */ goog.testing.AsyncTestCase.prototype.enableDebugLogging = function() { this.enableDebugLogs_ = true; }; /** * Logs the given debug message to the console (when enabled). * @param {string} message The message to log. * @private */ goog.testing.AsyncTestCase.prototype.dbgLog_ = function(message) { if (this.enableDebugLogs_) { this.log('AsyncTestCase - ' + message); } }; /** * Wraps doAsyncError() for when we are sure that the test runner has no user * code above it in the stack. * @param {string|Error=} opt_e The exception object associated with the * failure or a string. * @private */ goog.testing.AsyncTestCase.prototype.doTopOfStackAsyncError_ = function(opt_e) { try { this.doAsyncError(opt_e); } catch (e) { // We know that we are on the top of the stack, so there is no need to // throw this exception in this case. if (e.isControlBreakingException) { this.numControlExceptionsExpected_ -= 1; this.dbgLog_( 'doTopOfStackAsyncError_: numControlExceptionsExpected_ = ' + this.numControlExceptionsExpected_ + ' and catching exception.'); } else { throw e; } } }; /** * Calls the tearDown function, catching any errors, and then moves on to * the next step in the testing cycle. * @private */ goog.testing.AsyncTestCase.prototype.doAsyncErrorTearDown_ = function() { if (this.inException_) { // We get here if tearDown is throwing the error. // Upon calling continueTesting, the inline function 'doAsyncError' (set // below) is run. this.endCurrentStep_(); } else { this.inException_ = true; this.isReady_ = true; // The continue point is different depending on if the error happened in // setUpPage() or in setUp()/test*()/tearDown(). var stepFuncAfterError = this.nextStepFunc_; var stepNameAfterError = 'TestCase.execute (after error)'; if (this.activeTest) { stepFuncAfterError = this.doIteration_; stepNameAfterError = 'doIteration (after error)'; } // We must set the next step before calling tearDown. this.setNextStep_(function() { this.inException_ = false; // This is null when an error happens in setUpPage. this.setNextStep_(stepFuncAfterError, stepNameAfterError); }, 'doAsyncError'); // Call the test's tearDown(). if (!this.cleanedUp_) { this.cleanedUp_ = true; this.tearDown(); } } }; /** * Replaces the asserts.js assert_() and fail() functions with a wrappers to * catch the exceptions. * @private */ goog.testing.AsyncTestCase.prototype.hookAssert_ = function() { if (!this.origAssert_) { this.origAssert_ = _assert; this.origFail_ = fail; var self = this; _assert = function() { try { self.origAssert_.apply(this, arguments); } catch (e) { self.dbgLog_('Wrapping failed assert()'); self.doAsyncError(e); } }; fail = function() { try { self.origFail_.apply(this, arguments); } catch (e) { self.dbgLog_('Wrapping fail()'); self.doAsyncError(e); } }; } }; /** * Sets a window.onerror handler for catching exceptions that happen in async * callbacks. Note that as of Safari 3.1, Safari does not support this. * @private */ goog.testing.AsyncTestCase.prototype.hookOnError_ = function() { if (!this.origOnError_) { this.origOnError_ = window.onerror; var self = this; window.onerror = function(error, url, line) { // Ignore exceptions that we threw on purpose. var cbe = goog.testing.AsyncTestCase.ControlBreakingException.TO_STRING; if (String(error).indexOf(cbe) != -1 && self.numControlExceptionsExpected_) { self.numControlExceptionsExpected_ -= 1; self.dbgLog_( 'window.onerror: numControlExceptionsExpected_ = ' + self.numControlExceptionsExpected_ + ' and ignoring exception. ' + error); // Tell the browser not to compain about the error. return true; } else { self.dbgLog_('window.onerror caught exception.'); var message = error + '\nURL: ' + url + '\nLine: ' + line; self.doTopOfStackAsyncError_(message); // Tell the browser to complain about the error. return false; } }; } }; /** * Unhooks window.onerror and _assert. * @private */ goog.testing.AsyncTestCase.prototype.unhookAll_ = function() { if (this.origOnError_) { window.onerror = this.origOnError_; this.origOnError_ = null; _assert = this.origAssert_; this.origAssert_ = null; fail = this.origFail_; this.origFail_ = null; } }; /** * Enables the timeout timer. This timer fires unless continueTesting is * called. * @private */ goog.testing.AsyncTestCase.prototype.startTimeoutTimer_ = function() { if (!this.timeoutHandle_ && this.stepTimeout > 0) { this.timeoutHandle_ = this.timeout(goog.bind(function() { this.dbgLog_('Timeout timer fired with id ' + this.timeoutHandle_); this.timeoutHandle_ = 0; this.doTopOfStackAsyncError_( 'Timed out while waiting for ' + 'continueTesting() to be called.'); }, this, null), this.stepTimeout); this.dbgLog_('Started timeout timer with id ' + this.timeoutHandle_); } }; /** * Disables the timeout timer. * @private */ goog.testing.AsyncTestCase.prototype.stopTimeoutTimer_ = function() { if (this.timeoutHandle_) { this.dbgLog_('Clearing timeout timer with id ' + this.timeoutHandle_); this.clearTimeout(this.timeoutHandle_); this.timeoutHandle_ = 0; } }; /** * Sets the next function to call in our sequence of async callbacks. * @param {Function} func The function that executes the next step. * @param {string} name A description of the next step. * @private */ goog.testing.AsyncTestCase.prototype.setNextStep_ = function(func, name) { this.nextStepFunc_ = func && goog.bind(func, this); this.nextStepName_ = name; }; /** * Calls the given function, redirecting any exceptions to doAsyncError. * @param {Function} func The function to call. * @return {!goog.testing.AsyncTestCase.TopStackFuncResult_} Returns a * TopStackFuncResult_. * @private */ goog.testing.AsyncTestCase.prototype.callTopOfStackFunc_ = function(func) { try { func.call(this); return {controlBreakingExceptionThrown: false, message: ''}; } catch (e) { this.dbgLog_('Caught exception in callTopOfStackFunc_'); try { this.doAsyncError(e); return {controlBreakingExceptionThrown: false, message: ''}; } catch (e2) { if (!e2.isControlBreakingException) { throw e2; } return {controlBreakingExceptionThrown: true, message: e2.message}; } } }; /** * Calls the next callback when the isReady_ flag is true. * @param {Function=} opt_doFirst A function to call before pumping. * @private * @throws Throws a ControlBreakingException if there were any failing steps. */ goog.testing.AsyncTestCase.prototype.pump_ = function(opt_doFirst) { // If this function is already above us in the call-stack, then we should // return rather than pumping in order to minimize call-stack depth. if (!this.returnWillPump_) { this.setBatchTime(this.now()); this.returnWillPump_ = true; var topFuncResult = {}; if (opt_doFirst) { topFuncResult = this.callTopOfStackFunc_(opt_doFirst); } // Note: we don't check for this.running here because it is not set to true // while executing setUpPage and tearDownPage. // Also, if isReady_ is false, then one of two things will happen: // 1. Our timeout callback will be called. // 2. The tests will call continueTesting(), which will call pump_() again. while (this.isReady_ && this.nextStepFunc_ && !topFuncResult.controlBreakingExceptionThrown) { this.curStepFunc_ = this.nextStepFunc_; this.curStepName_ = this.nextStepName_; this.nextStepFunc_ = null; this.nextStepName_ = ''; this.dbgLog_('Performing step: ' + this.curStepName_); topFuncResult = this.callTopOfStackFunc_(/** @type {Function} */ (this.curStepFunc_)); // If the max run time is exceeded call this function again async so as // not to block the browser. var delta = this.now() - this.getBatchTime(); if (delta > goog.testing.TestCase.maxRunTime && !topFuncResult.controlBreakingExceptionThrown) { this.saveMessage('Breaking async'); var self = this; this.timeout(function() { self.pump_(); }, 100); break; } } this.returnWillPump_ = false; } else if (opt_doFirst) { opt_doFirst.call(this); } }; /** * Sets up the test page and then waits until the test case has been marked * as ready before executing the tests. * @private */ goog.testing.AsyncTestCase.prototype.doSetUpPage_ = function() { this.setNextStep_(this.execute, 'TestCase.execute'); this.setUpPage(); }; /** * Step 1: Move to the next test. * @private */ goog.testing.AsyncTestCase.prototype.doIteration_ = function() { this.expectedSignalCount_ = 0; this.receivedSignalCount_ = 0; this.activeTest = this.next(); goog.testing.TestCase.currentTestName = this.activeTest ? this.activeTest.name : null; if (this.activeTest && this.running) { this.result_.runCount++; // If this test should be marked as having failed, doIteration will go // straight to the next test. if (this.maybeFailTestEarly(this.activeTest)) { this.setNextStep_(this.doIteration_, 'doIteration'); } else { this.setNextStep_(this.doSetUp_, 'setUp'); } } else { // All tests done. this.finalize(); } }; /** * Step 2: Call setUp(). * @private */ goog.testing.AsyncTestCase.prototype.doSetUp_ = function() { this.log('Running test: ' + this.activeTest.name); this.cleanedUp_ = false; this.setNextStep_(this.doExecute_, this.activeTest.name); this.setUp(); }; /** * Step 3: Call test.execute(). * @private */ goog.testing.AsyncTestCase.prototype.doExecute_ = function() { this.setNextStep_(this.doTearDown_, 'tearDown'); this.activeTest.execute(); }; /** * Step 4: Call tearDown(). * @private */ goog.testing.AsyncTestCase.prototype.doTearDown_ = function() { this.cleanedUp_ = true; this.setNextStep_(this.doNext_, 'doNext'); this.tearDown(); }; /** * Step 5: Call doSuccess() * @private */ goog.testing.AsyncTestCase.prototype.doNext_ = function() { this.setNextStep_(this.doIteration_, 'doIteration'); this.doSuccess(/** @type {goog.testing.TestCase.Test} */ (this.activeTest)); };