diff --git a/docs/content/guide/unit-testing.ngdoc b/docs/content/guide/unit-testing.ngdoc index fd8cbfffcf40..7afaaf107aa1 100644 --- a/docs/content/guide/unit-testing.ngdoc +++ b/docs/content/guide/unit-testing.ngdoc @@ -438,5 +438,42 @@ In tests, you can trigger a digest by calling a scope's {@link ng.$rootScope.Sco If you don't have a scope in your test, you can inject the {@link ng.$rootScope $rootScope} and call `$apply` on it. There is also an example of testing promises in the {@link ng.$q#testing `$q` service documentation}. +## Using `beforeAll()` + +Jasmine's `beforeAll()` and mocha's `before()` hooks are often useful for sharing test setup - either to reduce test run-time or simply to make for more focussed test cases. + +By default, ngMock will create an injector per test case to ensure your tests do not affect each other. However, if we want to use `beforeAll()`, ngMock will have to create the injector before any test cases are run, and share that injector through all the cases for that `describe`. That is where {@link angular.mock.module.sharedInjector module.sharedInjector()} comes in. When it's called within a `describe` block, a single injector is shared between all hooks and test cases run in that block. + +In the example below we are testing a service that takes a long time to generate its answer. To avoid having all of the assertions we want to write in a single test case, {@link angular.mock.module.sharedInjector module.sharedInjector()} and Jasmine's `beforeAll()` are used to run the service only one. The test cases then all make assertions about the properties added to the service instance. + +```javascript +describe("Deep Thought", function() { + + module.sharedInjector(); + + beforeAll(module("UltimateQuestion")); + + beforeAll(inject(function(DeepThought) { + expect(DeepThought.answer).toBe(undefined); + DeepThought.generateAnswer(); + })); + + it("has calculated the answer correctly", inject(function(DeepThought) { + // Because of sharedInjector, we have access to the instance of the DeepThought service + // that was provided to the beforeAll() hook. Therefore we can test the generated answer + expect(DeepThought.answer).toBe(42); + })); + + it("has calculated the answer within the expected time", inject(function(DeepThought) { + expect(DeepThought.runTimeMillennia).toBeLessThan(8000); + })); + + it("has double checked the answer", inject(function(DeepThought) { + expect(DeepThought.absolutelySureItIsTheRightAnswer).toBe(true); + })); + +}); +``` + ## Sample project See the [angular-seed](https://github.com/angular/angular-seed) project for an example. diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index f1e727e393bb..278a234103d7 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -2561,11 +2561,16 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { }]; -if (window.jasmine || window.mocha) { +!(function(jasmineOrMocha) { + + if (!jasmineOrMocha) { + return; + } var currentSpec = null, + injectorState = new InjectorState(), annotatedFunctions = [], - isSpecRunning = function() { + wasInjectorCreated = function() { return !!currentSpec; }; @@ -2577,14 +2582,165 @@ if (window.jasmine || window.mocha) { return angular.mock.$$annotate.apply(this, arguments); }; + /** + * @ngdoc function + * @name angular.mock.module + * @description + * + * *NOTE*: This function is also published on window for easy access.
+ * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha + * + * This function registers a module configuration code. It collects the configuration information + * which will be used when the injector is created by {@link angular.mock.inject inject}. + * + * See {@link angular.mock.inject inject} for usage example + * + * @param {...(string|Function|Object)} fns any number of modules which are represented as string + * aliases or as anonymous module initialization functions. The modules are used to + * configure the injector. The 'ng' and 'ngMock' modules are automatically loaded. If an + * object literal is passed each key-value pair will be registered on the module via + * {@link auto.$provide $provide}.value, the key being the string name (or token) to associate + * with the value on the injector. + */ + var module = window.module = angular.mock.module = function() { + var moduleFns = Array.prototype.slice.call(arguments, 0); + return wasInjectorCreated() ? workFn() : workFn; + ///////////////////// + function workFn() { + if (currentSpec.$injector) { + throw new Error('Injector already created, can not register a module!'); + } else { + var fn, modules = currentSpec.$modules || (currentSpec.$modules = []); + angular.forEach(moduleFns, function(module) { + if (angular.isObject(module) && !angular.isArray(module)) { + fn = ['$provide', function($provide) { + angular.forEach(module, function(value, key) { + $provide.value(key, value); + }); + }]; + } else { + fn = module; + } + if (currentSpec.$providerInjector) { + currentSpec.$providerInjector.invoke(fn); + } else { + modules.push(fn); + } + }); + } + } + }; - (window.beforeEach || window.setup)(function() { - originalRootElement = null; - annotatedFunctions = []; - currentSpec = this; - }); + module.$$beforeAllHook = (window.before || window.beforeAll); + module.$$afterAllHook = (window.after || window.afterAll); + + // purely for testing ngMock itself + module.$$currentSpec = function(to) { + if (arguments.length === 0) return to; + currentSpec = to; + }; + + /** + * @ngdoc function + * @name angular.mock.module.sharedInjector + * @description + * + * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha + * + * This function ensures a single injector will be used for all tests in a given describe context. + * This contrasts with the default behaviour where a new injector is created per test case. + * + * Use sharedInjector when you want to take advantage of Jasmine's `beforeAll()`, or mocha's + * `before()` methods. Call `module.sharedInjector()` before you setup any other hooks that + * will create (i.e call `module()`) or use (i.e call `inject()`) the injector. + * + * You cannot call `sharedInjector()` from within a context already using `sharedInjector()`. + * + * ## Example + * + * Typically beforeAll is used to make many assertions about a single operation. This can + * cut down test run-time as the test setup doesn't need to be re-run, and enabling focussed + * tests each with a single assertion. + * + * ```js + * describe("Deep Thought", function() { + * + * module.sharedInjector(); + * + * beforeAll(module("UltimateQuestion")); + * + * beforeAll(inject(function(DeepThought) { + * expect(DeepThought.answer).toBe(undefined); + * DeepThought.generateAnswer(); + * })); + * + * it("has calculated the answer correctly", inject(function(DeepThought) { + * // Because of sharedInjector, we have access to the instance of the DeepThought service + * // that was provided to the beforeAll() hook. Therefore we can test the generated answer + * expect(DeepThought.answer).toBe(42); + * })); + * + * it("has calculated the answer within the expected time", inject(function(DeepThought) { + * expect(DeepThought.runTimeMillennia).toBeLessThan(8000); + * })); + * + * it("has double checked the answer", inject(function(DeepThought) { + * expect(DeepThought.absolutelySureItIsTheRightAnswer).toBe(true); + * })); + * + * }); + * + * ``` + */ + module.sharedInjector = function() { + if (!(module.$$beforeAllHook && module.$$afterAllHook)) { + throw Error("sharedInjector() cannot be used unless your test runner defines beforeAll/afterAll"); + } + + var initialized = false; + + module.$$beforeAllHook(function() { + if (injectorState.shared) { + injectorState.sharedError = Error("sharedInjector() cannot be called inside a context that has already called sharedInjector()"); + throw injectorState.sharedError; + } + initialized = true; + currentSpec = this; + injectorState.shared = true; + }); - (window.afterEach || window.teardown)(function() { + module.$$afterAllHook(function() { + if (initialized) { + injectorState = new InjectorState(); + module.$$cleanup(); + } else { + injectorState.sharedError = null; + } + }); + }; + + module.$$beforeEach = function() { + if (injectorState.shared && currentSpec && currentSpec != this) { + var state = currentSpec; + currentSpec = this; + angular.forEach(["$injector","$modules","$providerInjector", "$injectorStrict"], function(k) { + currentSpec[k] = state[k]; + state[k] = null; + }); + } else { + currentSpec = this; + originalRootElement = null; + annotatedFunctions = []; + } + }; + + module.$$afterEach = function() { + if (injectorState.cleanupAfterEach()) { + module.$$cleanup(); + } + }; + + module.$$cleanup = function() { var injector = currentSpec.$injector; annotatedFunctions.forEach(function(fn) { @@ -2629,57 +2785,11 @@ if (window.jasmine || window.mocha) { delete angular.callbacks[key]; }); angular.callbacks.counter = 0; - }); - - /** - * @ngdoc function - * @name angular.mock.module - * @description - * - * *NOTE*: This function is also published on window for easy access.
- * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha - * - * This function registers a module configuration code. It collects the configuration information - * which will be used when the injector is created by {@link angular.mock.inject inject}. - * - * See {@link angular.mock.inject inject} for usage example - * - * @param {...(string|Function|Object)} fns any number of modules which are represented as string - * aliases or as anonymous module initialization functions. The modules are used to - * configure the injector. The 'ng' and 'ngMock' modules are automatically loaded. If an - * object literal is passed each key-value pair will be registered on the module via - * {@link auto.$provide $provide}.value, the key being the string name (or token) to associate - * with the value on the injector. - */ - window.module = angular.mock.module = function() { - var moduleFns = Array.prototype.slice.call(arguments, 0); - return isSpecRunning() ? workFn() : workFn; - ///////////////////// - function workFn() { - if (currentSpec.$injector) { - throw new Error('Injector already created, can not register a module!'); - } else { - var fn, modules = currentSpec.$modules || (currentSpec.$modules = []); - angular.forEach(moduleFns, function(module) { - if (angular.isObject(module) && !angular.isArray(module)) { - fn = ['$provide', function($provide) { - angular.forEach(module, function(value, key) { - $provide.value(key, value); - }); - }]; - } else { - fn = module; - } - if (currentSpec.$providerInjector) { - currentSpec.$providerInjector.invoke(fn); - } else { - modules.push(fn); - } - }); - } - } }; + (window.beforeEach || window.setup)(module.$$beforeEach); + (window.afterEach || window.teardown)(module.$$afterEach); + /** * @ngdoc function * @name angular.mock.inject @@ -2782,7 +2892,7 @@ if (window.jasmine || window.mocha) { window.inject = angular.mock.inject = function() { var blockFns = Array.prototype.slice.call(arguments, 0); var errorForStack = new Error('Declaration Location'); - return isSpecRunning() ? workFn.call(currentSpec) : workFn; + return wasInjectorCreated() ? workFn.call(currentSpec) : workFn; ///////////////////// function workFn() { var modules = currentSpec.$modules || []; @@ -2830,7 +2940,7 @@ if (window.jasmine || window.mocha) { angular.mock.inject.strictDi = function(value) { value = arguments.length ? !!value : true; - return isSpecRunning() ? workFn() : workFn; + return wasInjectorCreated() ? workFn() : workFn; function workFn() { if (value !== currentSpec.$injectorStrict) { @@ -2842,4 +2952,13 @@ if (window.jasmine || window.mocha) { } } }; -} + + function InjectorState() { + this.shared = false; + this.sharedError = null; + + this.cleanupAfterEach = function() { + return !this.shared || this.sharedError; + }; + } +})(window.jasmine || window.mocha); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 166d10d0dc2c..3058476217d9 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -2397,7 +2397,6 @@ describe('make sure that we can create an injector outside of tests', function() angular.injector([function($injector) {}]); }); - describe('`afterEach` clean-up', function() { describe('`$rootElement`', function() { describe('undecorated', function() { @@ -2561,3 +2560,263 @@ describe('`afterEach` clean-up', function() { }); }); }); + +describe('sharedInjector', function() { + // this is of a bit tricky feature to test as we hit angular's own testing + // mechanisms (e.g around jQuery cache checking), as ngMock augments the very + // jasmine test runner we're using to test ngMock! + // + // with that in mind, we define a stubbed test framework + // to simulate test cases being run with the ngMock hooks + + + // we use the 'module' and 'inject' globals from ngMock + + it("allowes me to mutate a single instace of a module (proving it has been shared)", ngMockTest(function() { + sdescribe("test state is shared", function() { + angular.module("sharedInjectorTestModuleA", []) + .factory("testService", function() { + return { state: 0 }; + }); + + module.sharedInjector(); + + sbeforeAll(module("sharedInjectorTestModuleA")); + + sit("access and mutate", inject(function(testService) { + testService.state += 1; + })); + + sit("expect mutation to have persisted", inject(function(testService) { + expect(testService.state).toEqual(1); + })); + }); + })); + + + it("works with standard beforeEach", ngMockTest(function() { + sdescribe("test state is not shared", function() { + angular.module("sharedInjectorTestModuleC", []) + .factory("testService", function() { + return { state: 0 }; + }); + + sbeforeEach(module("sharedInjectorTestModuleC")); + + sit("access and mutate", inject(function(testService) { + testService.state += 1; + })); + + sit("expect mutation not to have persisted", inject(function(testService) { + expect(testService.state).toEqual(0); + })); + }); + })); + + + it('allows me to stub with shared injector', ngMockTest(function() { + sdescribe("test state is shared", function() { + angular.module("sharedInjectorTestModuleD", []) + .value("testService", 43); + + module.sharedInjector(); + + sbeforeAll(module("sharedInjectorTestModuleD", function($provide) { + $provide.value("testService", 42); + })); + + sit("expected access stubbed value", inject(function(testService) { + expect(testService).toEqual(42); + })); + }); + })); + + it("doesn't interfere with other test describes", ngMockTest(function() { + angular.module("sharedInjectorTestModuleE", []) + .factory("testService", function() { + return { state: 0 }; + }); + + sdescribe("with stubbed injector", function() { + + module.sharedInjector(); + + sbeforeAll(module("sharedInjectorTestModuleE")); + + sit("access and mutate", inject(function(testService) { + expect(testService.state).toEqual(0); + testService.state += 1; + })); + + sit("expect mutation to have persisted", inject(function(testService) { + expect(testService.state).toEqual(1); + })); + }); + + sdescribe("without stubbed injector", function() { + sbeforeEach(module("sharedInjectorTestModuleE")); + + sit("access and mutate", inject(function(testService) { + expect(testService.state).toEqual(0); + testService.state += 1; + })); + + sit("expect original, unmutated value", inject(function(testService) { + expect(testService.state).toEqual(0); + })); + }); + })); + + it("prevents nested use of sharedInjector()", function() { + var test = ngMockTest(function() { + sdescribe("outer", function() { + + module.sharedInjector(); + + sdescribe("inner", function() { + + module.sharedInjector(); + + sit("should not get here", function() { + throw Error("should have thrown before here!"); + }); + }); + + }); + + }); + + assertThrowsErrorMatching(test.bind(this), /already called sharedInjector()/); + }); + + it('warns that shared injector cannot be used unless test frameworks define before/after all hooks', function() { + assertThrowsErrorMatching(function() { + module.sharedInjector(); + }, /sharedInjector()/); + }); + + function assertThrowsErrorMatching(fn, re) { + try { + fn(); + } catch (e) { + if (re.test(e.message)) { + return; + } + throw Error("thrown error '" + e.message + "' did not match:" + re); + } + throw Error("should have thrown error"); + } + + // run a set of test cases in the sdescribe stub test framework + function ngMockTest(define) { + return function() { + var spec = this; + module.$$currentSpec(null); + + // configure our stubbed test framework and then hook ngMock into it + // in much the same way + module.$$beforeAllHook = sbeforeAll; + module.$$afterAllHook = safterAll; + + sdescribe.root = sdescribe("root", function() {}); + + sdescribe.root.beforeEach.push(module.$$beforeEach); + sdescribe.root.afterEach.push(module.$$afterEach); + + try { + define(); + sdescribe.root.run(); + } finally { + // avoid failing testability for the additional + // injectors etc created + angular.element.cache = {}; + + // clear up + module.$$beforeAllHook = null; + module.$$afterAllHook = null; + module.$$currentSpec(spec); + } + }; + } + + // stub test framework that follows the pattern of hooks that + // jasmine/mocha do + function sdescribe(name, define) { + var self = { name: name }; + self.parent = sdescribe.current || sdescribe.root; + if (self.parent) { + self.parent.describes.push(self); + } + + var previous = sdescribe.current; + sdescribe.current = self; + + self.beforeAll = []; + self.beforeEach = []; + self.afterAll = []; + self.afterEach = []; + self.define = define; + self.tests = []; + self.describes = []; + + self.run = function() { + var spec = {}; + self.hooks("beforeAll", spec); + + self.tests.forEach(function(test) { + if (self.parent) self.parent.hooks("beforeEach", spec); + self.hooks("beforeEach", spec); + test.run.call(spec); + self.hooks("afterEach", spec); + if (self.parent) self.parent.hooks("afterEach", spec); + }); + + self.describes.forEach(function(d) { + d.run(); + }); + + self.hooks("afterAll", spec); + }; + + self.hooks = function(hook, spec) { + self[hook].forEach(function(f) { + f.call(spec); + }); + }; + + define(); + + sdescribe.current = previous; + + return self; + } + + function sit(name, fn) { + if (typeof fn !== "function") throw Error("not fn", fn); + sdescribe.current.tests.push({ + name: name, + run: fn + }); + } + + function sbeforeAll(fn) { + if (typeof fn !== "function") throw Error("not fn", fn); + sdescribe.current.beforeAll.push(fn); + } + + function safterAll(fn) { + if (typeof fn !== "function") throw Error("not fn", fn); + sdescribe.current.afterAll.push(fn); + } + + function sbeforeEach(fn) { + if (typeof fn !== "function") throw Error("not fn", fn); + sdescribe.current.beforeEach.push(fn); + } + + function safterEach(fn) { + if (typeof fn !== "function") throw Error("not fn", fn); + sdescribe.current.afterEach.push(fn); + } + +});