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);
+ }
+
+});