I'm trying to work out the best way to unit test success and error callbacks in controllers. I am able to mock out service methods, as long as the controller only uses the default $q functions such as 'then' (see the example below). I'm having an issue when the controller responds to a 'success' or 'error' promise. (Sorry if my terminology is not correct).
Here is an example controller \ service
var myControllers = angular.module('myControllers'); myControllers.controller('SimpleController', ['$scope', 'myService', function ($scope, myService) { var id = 1; $scope.loadData = function () { myService.get(id).then(function (response) { $scope.data = response.data; }); }; $scope.loadData2 = function () { myService.get(id).success(function (response) { $scope.data = response.data; }).error(function(response) { $scope.error = 'ERROR'; }); }; }]); cocoApp.service('myService', [ '$http', function($http) { function get(id) { return $http.get('/api/' + id); } } ]);
I have the following test
'use strict'; describe('SimpleControllerTests', function () { var scope; var controller; var getResponse = { data: 'this is a mocked response' }; beforeEach(angular.mock.module('myApp')); beforeEach(angular.mock.inject(function($q, $controller, $rootScope, $routeParams){ scope = $rootScope; var myServiceMock = { get: function() {} }; // setup a promise for the get var getDeferred = $q.defer(); getDeferred.resolve(getResponse); spyOn(myServiceMock, 'get').andReturn(getDeferred.promise); controller = $controller('SimpleController', { $scope: scope, myService: myServiceMock }); })); it('this tests works', function() { scope.loadData(); expect(scope.data).toEqual(getResponse.data); }); it('this doesnt work', function () { scope.loadData2(); expect(scope.data).toEqual(getResponse.data); }); });
The first test passes and the second fails with the error "TypeError: Object doesn't support property or method 'success'". I get that in this instance that getDeferred.promise does not have a success function. Okay here is the question, what is a nice way to write this test so that I can test the 'success', 'error' & 'then' conditions of a mocked service ?
I'm starting to think that I should avoid the use of success() and error() in my controllers...
EDIT
So after thinking about this some more, and thanks to the detailed answer below, I've come to the conclusion that the handling the success and error callbacks in the controller is bad. As HackedByChinese mentions below success\error is syntactic sugar that is added by $http. So, in actual fact, by trying to handle success \ error I am letting $http concerns leak into my controller, which is exactly what I was trying to avoid by wrapping the $http calls in a service. The approach I'm going to take is to change the controller not to use success \ error:
myControllers.controller('SimpleController', ['$scope', 'myService', function ($scope, myService) { var id = 1; $scope.loadData = function () { myService.get(id).then(function (response) { $scope.data = response.data; }, function (response) { $scope.error = 'ERROR'; }); }; }]);
This way I can test the error \ success conditions by calling resolve() and reject() on the deferred object:
'use strict'; describe('SimpleControllerTests', function () { var scope; var controller; var getResponse = { data: 'this is a mocked response' }; var getDeferred; var myServiceMock; //mock Application to allow us to inject our own dependencies beforeEach(angular.mock.module('myApp')); //mock the controller for the same reason and include $rootScope and $controller beforeEach(angular.mock.inject(function($q, $controller, $rootScope, $routeParams) { scope = $rootScope; myServiceMock = { get: function() {} }; // setup a promise for the get getDeferred = $q.defer(); spyOn(myServiceMock, 'get').andReturn(getDeferred.promise); controller = $controller('SimpleController', { $scope: scope, myService: myServiceMock }); })); it('should set some data on the scope when successful', function () { getDeferred.resolve(getResponse); scope.loadData(); scope.$apply(); expect(myServiceMock.get).toHaveBeenCalled(); expect(scope.data).toEqual(getResponse.data); }); it('should do something else when unsuccessful', function () { getDeferred.reject(getResponse); scope.loadData(); scope.$apply(); expect(myServiceMock.get).toHaveBeenCalled(); expect(scope.error).toEqual('ERROR'); }); });
As someone had mentioned in a deleted answer, success
and error
are syntactic sugar added by $http
so they aren't there when you create your own promise. You have two options:
$httpBackend
to setup expectations and flushThe idea is to let your myService
act like it normally would without knowing it's being tested. $httpBackend
will let you set up expectations and responses, and flush them so you can complete your tests synchronously. $http
won't be any wiser and the promise it returns will look and function like a real one. This option is good if you have simple tests with few HTTP expectations.
'use strict'; describe('SimpleControllerTests', function () { var scope; var expectedResponse = { name: 'this is a mocked response' }; var $httpBackend, $controller; beforeEach(module('myApp')); beforeEach(inject(function(_$rootScope_, _$controller_, _$httpBackend_){ // the underscores are a convention ng understands, just helps us differentiate parameters from variables $controller = _$controller_; $httpBackend = _$httpBackend_; scope = _$rootScope_; })); // makes sure all expected requests are made by the time the test ends afterEach(function() { $httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingRequest(); }); describe('should load data successfully', function() { beforeEach(function() { $httpBackend.expectGET('/api/1').response(expectedResponse); $controller('SimpleController', { $scope: scope }); // causes the http requests which will be issued by myService to be completed synchronously, and thus will process the fake response we defined above with the expectGET $httpBackend.flush(); }); it('using loadData()', function() { scope.loadData(); expect(scope.data).toEqual(expectedResponse); }); it('using loadData2()', function () { scope.loadData2(); expect(scope.data).toEqual(expectedResponse); }); }); describe('should fail to load data', function() { beforeEach(function() { $httpBackend.expectGET('/api/1').response(500); // return 500 - Server Error $controller('SimpleController', { $scope: scope }); $httpBackend.flush(); }); it('using loadData()', function() { scope.loadData(); expect(scope.error).toEqual('ERROR'); }); it('using loadData2()', function () { scope.loadData2(); expect(scope.error).toEqual('ERROR'); }); }); });
If the thing you're testing has complicated dependencies and all the set-up is a headache, you may still want to mock the services and the calls themselves as you have attempted. The difference is that you'll want to fully mock promise. The downside of this can be creating all the possible mock promises, however you could make that easier by creating your own function for creating these objects.
The reason this works is because we pretend that it resolves by invoking the handlers provided by success
, error
, or then
immediately, causing it to complete synchronously.
'use strict'; describe('SimpleControllerTests', function () { var scope; var expectedResponse = { name: 'this is a mocked response' }; var $controller, _mockMyService, _mockPromise = null; beforeEach(module('myApp')); beforeEach(inject(function(_$rootScope_, _$controller_){ $controller = _$controller_; scope = _$rootScope_; _mockMyService = { get: function() { return _mockPromise; } }; })); describe('should load data successfully', function() { beforeEach(function() { _mockPromise = { then: function(successFn) { successFn(expectedResponse); }, success: function(fn) { fn(expectedResponse); } }; $controller('SimpleController', { $scope: scope, myService: _mockMyService }); }); it('using loadData()', function() { scope.loadData(); expect(scope.data).toEqual(expectedResponse); }); it('using loadData2()', function () { scope.loadData2(); expect(scope.data).toEqual(expectedResponse); }); }); describe('should fail to load data', function() { beforeEach(function() { _mockPromise = { then: function(successFn, errorFn) { errorFn(); }, error: function(fn) { fn(); } }; $controller('SimpleController', { $scope: scope, myService: _mockMyService }); }); it('using loadData()', function() { scope.loadData(); expect(scope.error).toEqual("ERROR"); }); it('using loadData2()', function () { scope.loadData2(); expect(scope.error).toEqual("ERROR"); }); }); });
I rarely go for option 2, even in big applications.
For what it's worth, your loadData
and loadData2
http handlers have an error. They reference response.data
but the handlers will be called with the parsed response data directly, not the response object (so it should be data
instead of response.data
).
Using $httpBackend
inside a controller is a bad Idea since you are mixing concerns inside your Test. Whether you retrieve data from an Endpoint or not is not a concern of the Controller, is a concern of the DataService you are calling.
You can see this more clearly if you change the Endpoint Url inside the service you will then have to modify both tests: the service Test and the Controller Test.
Also as previously mentioned, the use of success
and error
are syntactic sugar and we should stick to the use of then
and catch
. But in reality you may find yourself in the need of testing "legacy" code. So for that I'm using this function:
function generatePromiseMock(resolve, reject) { var promise; if(resolve) { promise = q.when({data: resolve}); } else if (reject){ promise = q.reject({data: reject}); } else { throw new Error('You need to provide an argument'); } promise.success = function(fn){ return q.when(fn(resolve)); }; promise.error = function(fn) { return q.when(fn(reject)); }; return promise; }
By calling this function you will get a true promise that respond to then
and catch
methods when you need to and will also work for the success
or error
callbacks. Note that the success and error returns a promise itself so it will work with chained then
methods.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With