How to Unit Test with real $http calls using ngMockE2E
How to unit test with real $http calls using ngMockE2E's passThrough and ngMock.
on
This post details a workaround I use to make real HTTP calls for AngularJS Unit Tests, using ngMock & ngMockE2E.
Example Application
To explain the approach, consider the following application. Once the controller is initialised, the getMovieData() function is called, which in turn makes an HTTP call to fetch the data. Here’s the code:
var app = angular.module('moviesApp', []);
app.controller('MovieController', function movieController($scope, $http, $timeout) {
var getMovieData = function getMovieData() {
$http.get('http://www.omdbapi.com/', {
params: { s: $scope.keyword }
}).success(function(data, status, headers, config) {
$scope.movies = data.Search;
}).error(function(data, status, headers, config) {
$scope.movies = [];
});
};
/* On Load */
$scope.movies = [];
$scope.keyword = 'terminator';
getMovieData();
});
The Problem
ngMock’s $httpBackend service requires us to mock all HTTP requests used in the code under test. This is as expected, since unit tests should be run in isolation of I/O. To unit test the HTTP call in our example application, we would write the following test code:
describe('mock http test', function() {
var httpData = [{ title: 'Terminator'}];
beforeEach(inject(function(_$controller_, _$httpBackend_) {
$controller = _$controller_;
$scope = {};
$httpBackend = _$httpBackend_;
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').respond({
Search: httpData
});
}));
it('should load default movies (with mock http request)', function () {
var moviesController = $controller('MovieController', { $scope: $scope });
$httpBackend.flush();
expect($scope.movies).toEqual([httpData]);
});
});
Sometimes when I’m working through unit tests and creating the mocks, it would be nice to make a real HTTP call so that I can experiment, get some example JSON etc. Currently there is no way of doing this using ngMock.
ngMock does include ngMockE2E, which allows us to create fake backend HTTP calls, but we can only use this in the full application i.e. via the browser and not from unit tests. This is useful when prototyping an implementation of an angular app, since it allows us to code the front-end without having a real server side. It has the same API as ngMock’s $httpBackend, but also allows us to make real HTTP calls using a function called passThrough().
It means that we can take an HTTP mock such as in our first test example:
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').respond({
Search: httpData
});
And we can change it to the following:
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').passThrough();
This would cause the $http get request for the given url to make a real HTTP call. It’s possible to set different rules based on the url value, which provides the flexibility to fake some requests and make real requests for others.
It seems an obvious solution to use ngMock in conjunction with ngMockE2E so that we can use this feature and make real HTTP calls in our unit tests but… it doesn’t quite work that way!
The Solution
This is the solution that I use to easily drop in the ability to make real HTTP calls when I’m working through unit tests. This is a developer aid rather than a solution to write tests in this way, since a unit test would no longer be a unit test if it was making real http calls.
I would write a test as follows:
describe('real http tests', function() {
beforeEach(angular.mock.http.init);
afterEach(angular.mock.http.reset);
beforeEach(inject(function(_$controller_, _$httpBackend_) {
$controller = _$controller_;
$scope = {};
$httpBackend = _$httpBackend_;
// Note that this HTTP backend is ngMockE2E's, and will make a real HTTP request
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').passThrough();
}));
it('should load default movies (with real http request)', function (done) {
var moviesController = $controller('MovieController', { $scope: $scope });
setTimeout(function() {
expect($scope.movies).not.toEqual([]);
done();
}, 1000);
});
});
Key Points:
The following two lines of code initialise and reset the solution (the source code for the solution to follow):
beforeEach(angular.mock.http.init);
afterEach(angular.mock.http.reset);
It’s important call the reset function, as to not interfere with other aspects of ngMock (the reason why will be explained in more detail later in this post).
Note that we can use the passThrough function in the test:
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').passThrough();
We also now have asynchronous code, so I’ve crudely used a setTimeout to illustrate this in the assertion:
setTimeout(function() {
expect($scope.movies).not.toEqual([]);
done();
}, 1000);
NB we cannot use $httpBackend.flush()
, since we’re using ngMockE2E’s $httpBackend which doesn’t have such a function (ngMock does).
And most importantly, running this unit test will call the real HTTP endpoint!
It’s also possible to mix fake and real HTTP calls. Here’s an example:
describe('mixing real and fake http tests', function() {
beforeEach(angular.mock.http.init);
afterEach(angular.mock.http.reset);
beforeEach(inject(function(_$controller_, _$httpBackend_) {
$controller = _$controller_;
$scope = {};
$httpBackend = _$httpBackend_;
}));
it('should load default movies (with real http request)', function (done) {
// make a real http call
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').passThrough();
var moviesController = $controller('MovieController', { $scope: $scope });
setTimeout(function() {
expect($scope.movies).not.toEqual([]);
done();
}, 1000);
});
it('should search for movie (with fake http request)', function (done) {
// use fakes only
$httpBackend.whenGET('http://www.omdbapi.com/?s=terminator').respond({ Search: [{ title: 'Terminator' }] });
$httpBackend.whenGET('http://www.omdbapi.com/?s=star+wars').respond({ Search: [{ title: 'Return of the Jedi'}] });
var moviesController = $controller('MovieController', { $scope: $scope });
$scope.keyword = 'star wars';
$scope.search();
setTimeout(function() {
expect($scope.movies).toEqual([{ title: 'Return of the Jedi'}]);
done();
}, 1000);
});
});
The Code for the Solution
Here is the source code of my solution:
angular.mock.http = {};
angular.mock.http.init = function() {
angular.module('ngMock', ['ng', 'ngMockE2E']).provider({
$exceptionHandler: angular.mock.$ExceptionHandlerProvider,
$log: angular.mock.$LogProvider,
$interval: angular.mock.$IntervalProvider,
$rootElement: angular.mock.$RootElementProvider
}).config(['$provide', function($provide) {
$provide.decorator('$timeout', angular.mock.$TimeoutDecorator);
$provide.decorator('$$rAF', angular.mock.$RAFDecorator);
// From version 1.4.3 this line is removed. Uncomment for older versions.
//$provide.decorator('$$asyncCallback', angular.mock.$AsyncCallbackDecorator);
$provide.decorator('$rootScope', angular.mock.$RootScopeDecorator);
$provide.decorator('$controller', angular.mock.$ControllerDecorator);
}]);
};
angular.mock.http.reset = function() {
angular.module('ngMock', ['ng']).provider({
$browser: angular.mock.$BrowserProvider,
$exceptionHandler: angular.mock.$ExceptionHandlerProvider,
$log: angular.mock.$LogProvider,
$interval: angular.mock.$IntervalProvider,
$httpBackend: angular.mock.$HttpBackendProvider,
$rootElement: angular.mock.$RootElementProvider
}).config(['$provide', function($provide) {
$provide.decorator('$timeout', angular.mock.$TimeoutDecorator);
$provide.decorator('$$rAF', angular.mock.$RAFDecorator);
// From version 1.4.3 this line is removed. Uncomment for older versions.
//$provide.decorator('$$asyncCallback', angular.mock.$AsyncCallbackDecorator);
$provide.decorator('$rootScope', angular.mock.$RootScopeDecorator);
$provide.decorator('$controller', angular.mock.$ControllerDecorator);
}]);
};
Note that it’s important that the file is registered after ngMock, for example:
<script type="text/javascript" src="angular.js"></script>
<script type="text/javascript" src="angular-mocks.js"></script>
<script type="text/javascript" src="ngMockHttp.js"></script>
How it Works
This is a crude workaround, but it doesn’t require me to make changes to the source code of angular or angular-mocks, and with the exception of the calls to init/reset, I don’t have to change how I write the unit tests.
The two functions init and reset are detailed below:
Init
- Calling the init method, will override the module definition set in ngMock’s source code, but with following two elements removed:
- $browser: angular.mock.$BrowserProvider
- $httpBackend: angular.mock.$HttpBackendProvider
- $browser is omitted, since we want angular’s actual implementation and not ngMock’s fake $browser implementation.
- $httpBackend is omitted, since we are going to use ngMockE2E’s implementation, which we will see next.
- We’ve included ngMockE2E in the module definition, which means that its own version of $HttpBackendProvider will be used by the injector.
Given how ngMock’s bootstrap/injector initialisation works, this module definition will override the same module definition of ngMock in the angular-mocks.js source code when we call the inject
function in our tests.
Reset
- The module definition for ngMock, is the same as in ngMock’s source code.
- By doing this, we are restoring ngMock’s implementation of:
- $browser: angular.mock.$BrowserProvider
- $httpBackend: angular.mock.$HttpBackendProvider
It’s important to restore this, since other services that we may wish to test, such as $timeout or $interval require ngMock’s fake $browser implementation.
Caveats
This is a developer aid as opposed to a full solution that considers every possible combination of using angular’s native $BrowserProvider with ngMockE2E’s $HttpBackendProvider and ngMock’s service implementation i.e. $timeout, $interval etc.
It may be necessary to code around this limitation by keeping such tests in separate describe blocks so that the angular.mock.http.reset can be used to restore the fake $browser implementation when required.
Alternatives
The approach presented in this post is ideal for my workflow, since I can easily drop in real HTTP calls without having to make big changes to the code for my tests. I can remove the beforeEach/afterEach lines of code that register the angular.mock.http functionality I added once I’m ready to turn the real http calls into fakes.
If you have a scenario where the long term solution is to have integration tests as opposed to unit tests, you may find a solution such as ngMidwayTester more suitable.
Plunkr
Here’s a Plunkr with an example.
Github Project
There is a Github project: ngMockHttp. The source file in question is: index.js