ngImprovedTesting: mock testing for AngularJS made easy
NOTE: Just released version 0.3 of ngImprovedTesting with lots of bug fixes.
Check out this blog post or the README of the GitHub repo for more info.
Being able to easily test your application is one of the most powerful features that AngularJS offers. All the services, controllers, filters even directives you develop can be fully (unit) tested. However the learning curve for writing (proper) unit tests tends to be quite steep. This is mainly because AngularJS doesn't really offer any high level API's to ease the unit testing. Instead you are forced to use the same (low level) services that AngularJS uses internally. That means you have to gain in dept knowledge about the internals of $controller, when to $digest and how to use $provide in order to mock these services. Especially mocking out a dependency of controller, filter or another service is too cumbersome. This blog will show how you would normally create mocks in AngularJS, why its troublesome and finally introduces the new ngImprovedTesting library that makes mock testing much easier.
Sample application
Consider the following application consisting of the "userService" and the "permissionService":
var appModule = angular.module('myApp', []);
appModule.factory('userService', function($http) {
var detailsPerUsername = {};
$http({method: 'GET', url: '/users'})
.success(function(users) {
detailsPerUsername = _.indexBy(users, 'username');
});
return {
getUserDetails: function(userName) {
return detailsPerUsername[userName];
}
};
});
appModule.factory('permissionService', function(users) {
return {
hasAdminAccess: function(username) {
return users.getUserDetails(username).admin === true;
}
};
});
When it comes to unit testing "permissionService" there are two default strategies:
- using mock $httpBackend (from the ngMock module) to simulate $http trafic from the "userService"
- using a mock instead of the actual "userService" dependency
Replacing the "userService" with a mock using vanilla AngularJS
Using vanilla AngularJS you have to do all the hard work yourself when you like to create a mock. You will have to manually create an object with its relevant fields and methods. Finally you will have to register the mock (using $provide) to overwrite the existing service implementation. Using the following vanilla AngularJS we can replace "userService" with a mock in our unit tests:
describe('Vanilla mocked style permissions service specification',
function() {
var userServiceMock;
beforeEach(module('myApp', function ($provide) {
userServiceMock = {
getUserDetails: jasmine.createSpy()
};
$provide.value('userService', userServiceMock);
}));
// ...
The imperfections of the vanilla style of mocking
To ability to mock services in unit tests is a really great feature in AngularJS but it's far from perfect. As a developer I really don't want to be bothered with having to manually create a mock object. For instance I might just simply forget to mock the "userService" dependency when testing the "permissionService" meaning I would accidentally test it using the actual "userService". And what if you would refactor the "userService" and would rename its method to "getUserInfo". Then you would except the unit test of "permissionService" to fail, right? But it won't since the mocked "userService" still has the old "getUserDetails" (spy) method. Make things even worse... what if you would rename service to "userInfoService". This makes the "userService" dependency of the "permissionService" to be no longer resolvable. Due to this modification the application will no longer bootstrap when executed inside a browser. But when executed from the unit test it won't fail since its still uses its own mock. However other unit tests using the same module but not mocking the service will fail.
How mock testing could be improved
Coming from a Java background if found the manual creation of mocks felt quite weird to me. In static languages the existence of interfaces (and classes) make it way more easy to automatically create mocks. Using AngularJS we could do something similar ... ... what if we would use the original service as a template for creating a mocked version. Then we could automatically create mocks that contain the same properties as the original object. Each non-method property could be copied as-is and each method would instead be a Jasmine spy. Instead of manually registering a mock service using $provide we could instead automate this. This would also allow us to automatically check if a service you want to mock actually exists. Also we could check if the service being mock is indeed being used as dependency of a component.
Introducing the ngImprovedTesting library
With the intention of making (unit) testing more easy I created the "ngImprovedTesting" library.
The library supports (selectively) mocking out dependencies of a controller, filter, a directive, an animation or another service. Mock out the "userService" dependency when testing the "permissionService" is now extremely easy:
describe('ngImprovedTesting mocked style permissions service specification',
function() {
beforeEach(ModuleBuilder.forModules('myApp')
.serviceWithMocksFor('permissionService', 'userService')
.build());
// ... continous in next code snippets
...
});
Instead of using the traditional "beforeEach(module('myApp'))" we are using the ModuleBuilder of "ngImprovedTesting" to build a module specifically for our test. In this case we would like to test the actual "permissionService" in a test in combination with a mock for its "userService" dependency. But what if I would like to set some behaviour on the automatically created mock ... ... how do I actually get a hold on the actual mock instance? Well simple... besides the component being tested all its dependencies including the mocked one can be injected. To differentiate a mock from a regular one it's registered with "Mock" appended in its name. So to inject the mocked out version of "userService" just use "userServiceMock" instead:
describe('hasAdminAccess method', function() {
it('should return true when user details has property: admin == true',
inject(function(permissions, userServiceMock) {
userServiceMock.getUserDetails.and.returnValue({admin: true});
expect(permissions.hasAdminAccess('anAdminUser')).toBe(true);
}));
});
As you can see in the example the "userServiceMock.getUserDetails" method is a just a Jasmine spy. It therefor allows invocation of and.returnValue
on in order to set the return value of the method. However it does not allow an and.callThrough()
as the spy is not on the original service. NOTE:
- ngImprovedTesting will only mock out services that are either a function or an object with at least one (inherited) method (another than from Object.prototype); so for instance a ‘constant’ service named “APPCONFIG” with value {url: ‘http://hostname/some/path’} will not be mocked and therefor be available through APPCONFIG (and not APPCONFIGMock) in your Jasmine specs.
Overview of the ModuleBuilder API of ngImprovedTesting
To instantiate a "ModuleBuilder" use its static "forModules" method; it supports the same arguments as the angular.mock.module
method and therefor besides module names allows specifying module configuration functions and object literals containing service instances. The "ModuleBuilder" consists of the following instance methods:
- serviceWithMocksFor: registers a service for testing and mock specified dependencies
- serviceWithMocks: registers a service for testing and mock all dependencies
- serviceWithMocksExcept: registers a service for testing and mock dependencies except the specified
- controllerWithMocksFor: registers a controller for testing and mock specified dependencies
- controllerWithMocks: registers a controller for testing and mock all dependencies
- controllerWithMocksExcept: registers a controller for testing and mock dependencies except the specified
- filterWithMocksFor: registers a filter for testing and mock specified dependencies
- filterWithMocks: registers a filter for testing and mock all dependencies
- filterWithMocksExcept: registers a filter for testing and mock dependencies except the specified
- directiveWithMocksFor: registers a directive for testing and mock specified dependencies
- directiveWithMocks: registers a directive for testing and mock all dependencies
- directiveWithMocksExcept: registers a directive for testing and mock dependencies except the specified
- animationWithMocksFor: registers a animation for testing and mock specified dependencies
- animationWithMocks: registers a animation for testing and mock all dependencies
- animationWithMocksExcept: registers a animation for testing and mock dependencies except the specified
How to get started with ngImprovedTesting
All sources from this blog post can be found as part of a sample application:
The sample applications demonstrates three different flavors of testing:
- One that uses the $httpBackend
- Another using vanilla mocking support
- And one using ngImprovedTesting
To execute the tests on the command-line use the following commands (requires NodeJS, NPM, Bower and Grunt to be installed):
npm install
bower update
grunt
The actual sources of ngImprovedTesting itself are also hosted on GitHub:
- https://github.com/evangalen/ng-improved-testing.git: contains the source code on ngImprovedTesting itself.
- https://github.com/evangalen/ng-module-introspector.git: specifically developed AngularJS module introspector that allows us to retrieve the exact declaration of a controller, filter and service and its dependencies.
Furthermore ngImprovedTesting is also available through bower itself. You can easily install and add it to an existing project using the following command:
bower install ng-improved-testing --save-dev
Another blog post about version 0.2 of ngImprovedTesting
In case you have gotten interested there is another blog post about ngImprovedTesting 0.2 that adds the $q.tick() method to improve testing promises.
Your feedback is more than welcome
My goal for ngImprovedTesting is to ease mock testing in your AngularJS unit tests. I'm very interested in your feedback... is ngImprovedTesting any useful... and how could it be improved?