From 5918355a90382ab6d17e31ba7df8044d40f319f1 Mon Sep 17 00:00:00 2001 From: Schabse Laks Date: Mon, 26 Jun 2023 19:17:11 -0700 Subject: [PATCH] Split off an `EnvironmentBase` class that does not depend on mocks or `goog.testing.asserts`. This makes Environment-based features importable without creating globals for testing assertions. RELNOTES: n/a PiperOrigin-RevId: 543605712 Change-Id: I8d92f17f0691e3cee2b35ffede36f5c60d7f9210 --- closure/goog/labs/testing/BUILD | 16 +- closure/goog/labs/testing/environment.js | 276 +--------------- closure/goog/labs/testing/environment_test.js | 3 +- closure/goog/labs/testing/environmentbase.js | 312 ++++++++++++++++++ closure/goog/testing/BUILD | 2 +- closure/goog/testing/jsunit.js | 1 + closure/goog/testing/testcase.js | 3 +- 7 files changed, 336 insertions(+), 277 deletions(-) create mode 100644 closure/goog/labs/testing/environmentbase.js diff --git a/closure/goog/labs/testing/BUILD b/closure/goog/labs/testing/BUILD index c3f76ac034..28f92eed9a 100644 --- a/closure/goog/labs/testing/BUILD +++ b/closure/goog/labs/testing/BUILD @@ -34,14 +34,12 @@ closure_js_library( srcs = ["environment.js"], lenient = True, deps = [ - "//closure/goog/asserts", + ":environmentbase", "//closure/goog/debug:console", - "//closure/goog/promise:thenable", "//closure/goog/testing:jsunit", "//closure/goog/testing:mockclock", "//closure/goog/testing:mockcontrol", "//closure/goog/testing:propertyreplacer", - "//closure/goog/testing:testcase", ], ) @@ -90,6 +88,18 @@ closure_js_library( ], ) +closure_js_library( + name = "environmentbase", + testonly = True, + srcs = ["environmentbase.js"], + lenient = True, + deps = [ + "//closure/goog/asserts", + "//closure/goog/promise", + "//closure/goog/testing:testcase", + ], +) + alias( name = "numbermatcher", actual = ":matchers", diff --git a/closure/goog/labs/testing/environment.js b/closure/goog/labs/testing/environment.js index 65bd24acda..0d788720d4 100644 --- a/closure/goog/labs/testing/environment.js +++ b/closure/goog/labs/testing/environment.js @@ -11,9 +11,7 @@ const DebugConsole = goog.require('goog.debug.Console'); const MockClock = goog.require('goog.testing.MockClock'); const MockControl = goog.require('goog.testing.MockControl'); const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); -const TestCase = goog.require('goog.testing.TestCase'); -const Thenable = goog.require('goog.Thenable'); -const asserts = goog.require('goog.asserts'); +const {EnvironmentBase} = goog.require('goog.labs.testing.EnvironmentBase'); /** @suppress {extraRequire} Declares globals */ goog.require('goog.testing.jsunit'); @@ -29,16 +27,9 @@ goog.require('goog.testing.jsunit'); * * See http://go/jsunit-env for more information. */ -class Environment { +class Environment extends EnvironmentBase { constructor() { - // Use the same EnvironmentTestCase instance across all Environment objects. - if (!Environment.activeTestCase_) { - const testcase = new EnvironmentTestCase(); - - Environment.activeTestCase_ = testcase; - } - Environment.activeTestCase_.registerEnvironment_(this); - + super(); /** * Mocks are not type-checkable. To reduce burden on tests that are type * checked, this is typed as "?" to turn off JSCompiler checking. @@ -64,6 +55,7 @@ class Environment { * Runs immediately before the setUpPage phase of JsUnit tests. * @return {!IThenable<*>|undefined} An optional Promise which must be * resolved before the test is executed. + * @override */ setUpPage() { if (this.hasMockClock()) { @@ -71,7 +63,7 @@ class Environment { } } - /** Runs immediately after the tearDownPage phase of JsUnit tests. */ + /** @override Runs immediately after the tearDownPage phase of JsUnit tests */ tearDownPage() { // If we created the mockClock, we'll also dispose it. if (this.hasMockClock()) { @@ -79,17 +71,12 @@ class Environment { } } - /** - * Runs immediately before the setUp phase of JsUnit tests. - * @return {!IThenable<*>|undefined} An optional Promise which must be - * resolved before the test case is executed. - */ - setUp() {} /** * Runs immediately after the tearDown phase of JsUnit tests. * @return {!IThenable<*>|undefined} An optional Promise which must be * resolved before the next test case is executed. + * @override */ tearDown() { // Make sure promises and other stuff that may still be scheduled, @@ -219,261 +206,10 @@ class Environment { } } -/** - * @private {?EnvironmentTestCase} - */ -Environment.activeTestCase_ = null; - -// TODO(johnlenz): make this package private when it moves out of labs. -/** - * @return {?TestCase} - * @nocollapse - */ -Environment.getTestCaseIfActive = function() { - return Environment.activeTestCase_; -}; - /** @private @const {!DebugConsole} */ Environment.console_ = new DebugConsole(); // Activate logging to the browser's console by default. Environment.console_.setCapturing(true); -/** - * An internal TestCase used to hook environments into the JsUnit test runner. - * Environments cannot be used in conjunction with custom TestCases for JsUnit. - * @private @final @constructor - * @extends {TestCase} - */ -function EnvironmentTestCase() { - EnvironmentTestCase.base(this, 'constructor', document.title); - - /** @private {!Array}> */ - this.environments_ = []; - - /** @private {!Object} */ - this.testobj_ = goog.global; // default - - // Automatically install this TestCase when any environment is used in a test. - TestCase.initializeTestRunner(this); -} -goog.inherits(EnvironmentTestCase, TestCase); - -/** - * Override setLifecycleObj to allow incoming test object to provide only - * runTests and shouldRunTests. The other lifecycle methods are controlled by - * this environment. - * @param {!Object} obj - * @override - */ -EnvironmentTestCase.prototype.setLifecycleObj = function(obj) { - asserts.assert( - this.testobj_ == goog.global, - 'A test method object has already been provided ' + - 'and only one is supported.'); - - // Store the test object so we can call lifecyle methods when needed. - this.testobj_ = obj; - - if (this.testobj_['runTests']) { - this.runTests = this.testobj_['runTests'].bind(this.testobj_); - } - if (this.testobj_['shouldRunTests']) { - this.shouldRunTests = this.testobj_['shouldRunTests'].bind(this.testobj_); - } -}; - -/** - * @override - * @return {!TestCase.Test} - */ -EnvironmentTestCase.prototype.createTest = function( - name, ref, scope, objChain) { - return new EnvironmentTest(name, ref, scope, objChain); -}; - -/** - * Adds an environment to the JsUnit test. - * @param {!Environment} env - * @private - */ -EnvironmentTestCase.prototype.registerEnvironment_ = function(env) { - this.environments_.push(env); -}; - -/** - * @override - * @return {!IThenable<*>|undefined} - */ -EnvironmentTestCase.prototype.setUpPage = function() { - const setUpPageFns = this.environments_.map(env => { - return () => env.setUpPage(); - }); - - // User defined setUpPage method. - if (this.testobj_['setUpPage']) { - setUpPageFns.push(() => this.testobj_['setUpPage']()); - } - return this.callAndChainPromises_(setUpPageFns); -}; - -/** - * @override - * @return {!IThenable<*>|undefined} - */ -EnvironmentTestCase.prototype.setUp = function() { - const setUpFns = []; - // User defined configure method. - if (this.testobj_['configureEnvironment']) { - setUpFns.push(() => this.testobj_['configureEnvironment']()); - } - const test = this.getCurrentTest(); - if (test instanceof EnvironmentTest) { - setUpFns.push(...test.configureEnvironments); - } - - this.environments_.forEach(env => { - setUpFns.push(() => env.setUp()); - }, this); - - // User defined setUp method. - if (this.testobj_['setUp']) { - setUpFns.push(() => this.testobj_['setUp']()); - } - return this.callAndChainPromises_(setUpFns); -}; - -/** - * Calls a chain of methods and makes sure to properly chain them if any of the - * methods returns a thenable. - * @param {!Array} fns - * @param {boolean=} ensureAllFnsCalled If true, this method calls each function - * even if one of them throws an Error or returns a rejected Promise. If - * there were any Errors thrown (or Promises rejected), the first Error will - * be rethrown after all of the functions are called. - * @return {!IThenable<*>|undefined} - * @private - */ -EnvironmentTestCase.prototype.callAndChainPromises_ = function( - fns, ensureAllFnsCalled) { - // Using await here (and making callAndChainPromises_ an async method) - // causes many tests across google3 to start failing with errors like this: - // "Timed out while waiting for a promise returned from setUp to resolve". - - const isThenable = (v) => Thenable.isImplementedBy(v) || - (typeof goog.global['Promise'] === 'function' && - v instanceof goog.global['Promise']); - - // Record the first error that occurs so that it can be rethrown in the case - // where ensureAllFnsCalled is set. - let firstError; - const recordFirstError = (e) => { - if (!firstError) { - firstError = e instanceof Error ? e : new Error(e); - } - }; - - // Call the fns, chaining results that are Promises. - let lastFnResult; - for (const fn of fns) { - if (isThenable(lastFnResult)) { - // The previous fn was async, so chain the next fn. - const rejectedHandler = ensureAllFnsCalled ? (e) => { - recordFirstError(e); - return fn(); - } : undefined; - lastFnResult = lastFnResult.then(() => fn(), rejectedHandler); - } else { - // The previous fn was not async, so simply call the next fn. - try { - lastFnResult = fn(); - } catch (e) { - if (!ensureAllFnsCalled) { - throw e; - } - recordFirstError(e); - } - } - } - - // After all of the fns have been called, either throw the first error if - // there was one, or otherwise return the result of the last fn. - const resultFn = () => { - if (firstError) { - throw firstError; - } - return lastFnResult; - }; - return isThenable(lastFnResult) ? lastFnResult.then(resultFn, resultFn) : - resultFn(); -}; - -/** - * @override - * @return {!IThenable<*>|undefined} - */ -EnvironmentTestCase.prototype.tearDown = function() { - const tearDownFns = []; - // User defined tearDown method. - if (this.testobj_['tearDown']) { - tearDownFns.push(() => this.testobj_['tearDown']()); - } - - // Execute the tearDown methods for the environment in the reverse order - // in which they were registered to "unfold" the setUp. - const reverseEnvironments = [...this.environments_].reverse(); - reverseEnvironments.forEach(env => { - tearDownFns.push(() => env.tearDown()); - }); - // For tearDowns between tests make sure they run as much as possible to avoid - // interference between tests. - return this.callAndChainPromises_( - tearDownFns, /* ensureAllFnsCalled= */ true); -}; - -/** @override */ -EnvironmentTestCase.prototype.tearDownPage = function() { - // User defined tearDownPage method. - if (this.testobj_['tearDownPage']) { - this.testobj_['tearDownPage'](); - } - - const reverseEnvironments = [...this.environments_].reverse(); - reverseEnvironments.forEach(env => { - env.tearDownPage(); - }); -}; - -/** - * An internal Test used to hook environments into the JsUnit test runner. - * @param {string} name The test name. - * @param {function()} ref Reference to the test function or test object. - * @param {?Object=} scope Optional scope that the test function should be - * called in. - * @param {!Array=} objChain A chain of objects used to populate setUps - * and tearDowns. - * @private - * @final - * @constructor - * @extends {TestCase.Test} - */ -function EnvironmentTest(name, ref, scope, objChain) { - EnvironmentTest.base(this, 'constructor', name, ref, scope, objChain); - - /** - * @type {!Array} - */ - this.configureEnvironments = - (objChain || []) - .filter((obj) => typeof obj.configureEnvironment === 'function') - .map(/** - * @param {{configureEnvironment: function()}} obj - * @return {function()} - */ - function(obj) { - return obj.configureEnvironment.bind(obj); - }); -} -goog.inherits(EnvironmentTest, TestCase.Test); - exports = Environment; diff --git a/closure/goog/labs/testing/environment_test.js b/closure/goog/labs/testing/environment_test.js index b68bdaabd6..22c9fda0e0 100644 --- a/closure/goog/labs/testing/environment_test.js +++ b/closure/goog/labs/testing/environment_test.js @@ -13,6 +13,7 @@ const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); const TestCase = goog.require('goog.testing.TestCase'); const asserts = goog.require('goog.asserts'); const testingTestSuite = goog.require('goog.testing.testSuite'); +const {EnvironmentBase} = goog.require('goog.labs.testing.EnvironmentBase'); let testCase = null; let mockControl = null; @@ -30,7 +31,7 @@ const env = new Environment(); function setUpTestCase() { // Clear the activeTestCase_ field to make an instance of Environment create a // new EnvironmentTestCase instance. - Environment.activeTestCase_ = null; + EnvironmentBase.activeTestCase_ = null; new Environment(); // Assigns a new value to Environment.activeTestCase_. testCase = Environment.getTestCaseIfActive(); } diff --git a/closure/goog/labs/testing/environmentbase.js b/closure/goog/labs/testing/environmentbase.js new file mode 100644 index 0000000000..6c4efccd10 --- /dev/null +++ b/closure/goog/labs/testing/environmentbase.js @@ -0,0 +1,312 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('goog.labs.testing.EnvironmentBase'); + +const TestCase = goog.require('goog.testing.TestCase'); +const Thenable = goog.require('goog.Thenable'); +const asserts = goog.require('goog.asserts'); + + +/** + * JsUnit environments allow developers to customize the existing testing + * lifecycle by hitching additional setUp and tearDown behaviors to tests. + * + * Environments will run their setUp steps in the order in which they + * are instantiated and registered. During tearDown, the environments will + * unwind the setUp and execute in reverse order. + * + * This base class has no dependencies on mocking or goog.testing.asserts. + */ +class EnvironmentBase { + constructor() { + // Use the same EnvironmentTestCase instance across all EnvironmentBase + // objects. + if (!EnvironmentBase.activeTestCase_) { + const testcase = new EnvironmentTestCase(); + + EnvironmentBase.activeTestCase_ = testcase; + } + EnvironmentBase.activeTestCase_.registerEnvironment_(this); + } + + /** + * Runs immediately before the setUpPage phase of JsUnit tests. + * @return {!IThenable<*>|undefined} An optional Promise which must be + * resolved before the test is executed. + */ + setUpPage() {} + + /** Runs immediately after the tearDownPage phase of JsUnit tests. */ + tearDownPage() {} + + /** + * Runs immediately before the setUp phase of JsUnit tests. + * @return {!IThenable<*>|undefined} An optional Promise which must be + * resolved before the test case is executed. + */ + setUp() {} + + /** + * Runs immediately after the tearDown phase of JsUnit tests. + * @return {!IThenable<*>|undefined} An optional Promise which must be + * resolved before the next test case is executed. + */ + tearDown() {} +} + +/** + * @private {?EnvironmentTestCase} + */ +EnvironmentBase.activeTestCase_ = null; + +// TODO(johnlenz): make this package private when it moves out of labs. +/** + * @return {?TestCase} + * @nocollapse + */ +EnvironmentBase.getTestCaseIfActive = function() { + return EnvironmentBase.activeTestCase_; +}; + +/** + * An internal TestCase used to hook environments into the JsUnit test runner. + * Environments cannot be used in conjunction with custom TestCases for JsUnit. + * @private @final @constructor + * @extends {TestCase} + */ +function EnvironmentTestCase() { + EnvironmentTestCase.base(this, 'constructor', document.title); + + /** @private @const {!Array}> */ + this.environments_ = []; + + /** @private {!Object} */ + this.testobj_ = goog.global; // default + + // Automatically install this TestCase when any environment is used in a test. + TestCase.initializeTestRunner(this); +} +goog.inherits(EnvironmentTestCase, TestCase); + +/** + * Override setLifecycleObj to allow incoming test object to provide only + * runTests and shouldRunTests. The other lifecycle methods are controlled by + * this environment. + * @param {!Object} obj + * @override + */ +EnvironmentTestCase.prototype.setLifecycleObj = function(obj) { + asserts.assert( + this.testobj_ == goog.global, + 'A test method object has already been provided ' + + 'and only one is supported.'); + + // Store the test object so we can call lifecyle methods when needed. + this.testobj_ = obj; + + if (this.testobj_['runTests']) { + this.runTests = this.testobj_['runTests'].bind(this.testobj_); + } + if (this.testobj_['shouldRunTests']) { + this.shouldRunTests = this.testobj_['shouldRunTests'].bind(this.testobj_); + } +}; + +/** + * @override + * @return {!TestCase.Test} + */ +EnvironmentTestCase.prototype.createTest = function( + name, ref, scope, objChain) { + return new EnvironmentTest(name, ref, scope, objChain); +}; + +/** + * Adds an environment to the JsUnit test. + * @param {!EnvironmentBase} env + * @private + */ +EnvironmentTestCase.prototype.registerEnvironment_ = function(env) { + this.environments_.push(env); +}; + +/** + * @override + * @return {!IThenable<*>|undefined} + */ +EnvironmentTestCase.prototype.setUpPage = function() { + const setUpPageFns = this.environments_.map(env => { + return () => env.setUpPage(); + }); + + // User defined setUpPage method. + if (this.testobj_['setUpPage']) { + setUpPageFns.push(() => this.testobj_['setUpPage']()); + } + return this.callAndChainPromises_(setUpPageFns); +}; + +/** + * @override + * @return {!IThenable<*>|undefined} + */ +EnvironmentTestCase.prototype.setUp = function() { + const setUpFns = []; + // User defined configure method. + if (this.testobj_['configureEnvironment']) { + setUpFns.push(() => this.testobj_['configureEnvironment']()); + } + const test = this.getCurrentTest(); + if (test instanceof EnvironmentTest) { + setUpFns.push(...test.configureEnvironments); + } + + this.environments_.forEach(env => { + setUpFns.push(() => env.setUp()); + }, this); + + // User defined setUp method. + if (this.testobj_['setUp']) { + setUpFns.push(() => this.testobj_['setUp']()); + } + return this.callAndChainPromises_(setUpFns); +}; + +/** + * Calls a chain of methods and makes sure to properly chain them if any of the + * methods returns a thenable. + * @param {!Array} fns + * @param {boolean=} ensureAllFnsCalled If true, this method calls each function + * even if one of them throws an Error or returns a rejected Promise. If + * there were any Errors thrown (or Promises rejected), the first Error will + * be rethrown after all of the functions are called. + * @return {!IThenable<*>|undefined} + * @private + */ +EnvironmentTestCase.prototype.callAndChainPromises_ = function( + fns, ensureAllFnsCalled) { + // Using await here (and making callAndChainPromises_ an async method) + // causes many tests across google3 to start failing with errors like this: + // "Timed out while waiting for a promise returned from setUp to resolve". + + const isThenable = (v) => Thenable.isImplementedBy(v) || + (typeof goog.global['Promise'] === 'function' && + v instanceof goog.global['Promise']); + + // Record the first error that occurs so that it can be rethrown in the case + // where ensureAllFnsCalled is set. + let firstError; + const recordFirstError = (e) => { + if (!firstError) { + firstError = e instanceof Error ? e : new Error(e); + } + }; + + // Call the fns, chaining results that are Promises. + let lastFnResult; + for (const fn of fns) { + if (isThenable(lastFnResult)) { + // The previous fn was async, so chain the next fn. + const rejectedHandler = ensureAllFnsCalled ? (e) => { + recordFirstError(e); + return fn(); + } : undefined; + lastFnResult = lastFnResult.then(() => fn(), rejectedHandler); + } else { + // The previous fn was not async, so simply call the next fn. + try { + lastFnResult = fn(); + } catch (e) { + if (!ensureAllFnsCalled) { + throw e; + } + recordFirstError(e); + } + } + } + + // After all of the fns have been called, either throw the first error if + // there was one, or otherwise return the result of the last fn. + const resultFn = () => { + if (firstError) { + throw firstError; + } + return lastFnResult; + }; + return isThenable(lastFnResult) ? lastFnResult.then(resultFn, resultFn) : + resultFn(); +}; + +/** + * @override + * @return {!IThenable<*>|undefined} + */ +EnvironmentTestCase.prototype.tearDown = function() { + const tearDownFns = []; + // User defined tearDown method. + if (this.testobj_['tearDown']) { + tearDownFns.push(() => this.testobj_['tearDown']()); + } + + // Execute the tearDown methods for the environment in the reverse order + // in which they were registered to "unfold" the setUp. + const reverseEnvironments = [...this.environments_].reverse(); + reverseEnvironments.forEach(env => { + tearDownFns.push(() => env.tearDown()); + }); + // For tearDowns between tests make sure they run as much as possible to avoid + // interference between tests. + return this.callAndChainPromises_( + tearDownFns, /* ensureAllFnsCalled= */ true); +}; + +/** @override */ +EnvironmentTestCase.prototype.tearDownPage = function() { + // User defined tearDownPage method. + if (this.testobj_['tearDownPage']) { + this.testobj_['tearDownPage'](); + } + + const reverseEnvironments = [...this.environments_].reverse(); + reverseEnvironments.forEach(env => { + env.tearDownPage(); + }); +}; + +/** + * An internal Test used to hook environments into the JsUnit test runner. + * @param {string} name The test name. + * @param {function()} ref Reference to the test function or test object. + * @param {?Object=} scope Optional scope that the test function should be + * called in. + * @param {!Array=} objChain A chain of objects used to populate setUps + * and tearDowns. + * @private + * @final + * @constructor + * @extends {TestCase.Test} + */ +function EnvironmentTest(name, ref, scope, objChain) { + EnvironmentTest.base(this, 'constructor', name, ref, scope, objChain); + + /** + * @type {!Array} + */ + this.configureEnvironments = + (objChain || []) + .filter((obj) => typeof obj.configureEnvironment === 'function') + .map(/** + * @param {{configureEnvironment: function()}} obj + * @return {function()} + */ + function(obj) { + return obj.configureEnvironment.bind(obj); + }); +} +goog.inherits(EnvironmentTest, TestCase.Test); + +exports = {EnvironmentBase}; diff --git a/closure/goog/testing/BUILD b/closure/goog/testing/BUILD index 0d6380a026..67d3b3ffab 100644 --- a/closure/goog/testing/BUILD +++ b/closure/goog/testing/BUILD @@ -183,6 +183,7 @@ closure_js_library( srcs = ["jsunit.js"], lenient = True, deps = [ + ":asserts", ":testcase", ":testrunner", "//closure/goog/dom:tagname", @@ -490,7 +491,6 @@ closure_js_library( srcs = ["testcase.js"], lenient = True, deps = [ - ":asserts", ":cspviolationobserver", ":jsunitexception", "//closure/goog/array", diff --git a/closure/goog/testing/jsunit.js b/closure/goog/testing/jsunit.js index 8911647ad3..6e8abf2aa0 100644 --- a/closure/goog/testing/jsunit.js +++ b/closure/goog/testing/jsunit.js @@ -18,6 +18,7 @@ goog.provide('goog.testing.jsunit'); goog.require('goog.dom.TagName'); goog.require('goog.testing.TestCase'); goog.require('goog.testing.TestRunner'); +goog.require('goog.testing.asserts'); /** diff --git a/closure/goog/testing/testcase.js b/closure/goog/testing/testcase.js index ec6aaecd7c..519b92f1c1 100644 --- a/closure/goog/testing/testcase.js +++ b/closure/goog/testing/testcase.js @@ -32,7 +32,6 @@ goog.require('goog.dom.TagName'); goog.require('goog.object'); goog.require('goog.testing.CspViolationObserver'); goog.require('goog.testing.JsUnitException'); -goog.require('goog.testing.asserts'); goog.require('goog.url'); @@ -1547,7 +1546,7 @@ goog.testing.TestCase.prototype.setTestObj = function(obj) { // Check any previously added (likely auto-discovered) tests, only one source // of discovered test and life-cycle methods is allowed. if (this.tests_.length > 0) { - fail( + throw new Error( 'Test methods have already been configured.\n' + 'Tests previously found:\n' + this.tests_