Setting a timeout handler on a promise in angularjs
First, I would like to say that your controller implementation should be something like this:
$scope.qPromiseCall = function() { var timeoutPromise = $timeout(function() { canceler.resolve(); //aborts the request when timed out console.log("Timed out"); }, 250); //we set a timeout for 250ms and store the promise in order to be cancelled later if the data does not arrive within 250ms var canceler = $q.defer(); $http.get("data.js", {timeout: canceler.promise} ).success(function(data){ console.log(data); $timeout.cancel(timeoutPromise); //cancel the timer when we get a response within 250ms }); }
Your tests:
it('Timeout occurs', function() { spyOn(console, 'log'); $scope.qPromiseCall(); $timeout.flush(251); //timeout occurs after 251ms //there is no http response to flush because we cancel the response in our code. Trying to call $httpBackend.flush(); will throw an exception and fail the test $scope.$apply(); expect(console.log).toHaveBeenCalledWith("Timed out"); }) it('Timeout does not occur', function() { spyOn(console, 'log'); $scope.qPromiseCall(); $timeout.flush(230); //set the timeout to occur after 230ms $httpBackend.flush(); //the response arrives before the timeout $scope.$apply(); expect(console.log).not.toHaveBeenCalledWith("Timed out"); })
Another example with promiseService.getPromise
:
app.factory("promiseService", function($q,$timeout,$http) { return { getPromise: function() { var timeoutPromise = $timeout(function() { console.log("Timed out"); defer.reject("Timed out"); //reject the service in case of timeout }, 250); var defer = $q.defer();//in a real implementation, we would call an async function and // resolve the promise after the async function finishes $timeout(function(data){//simulating an asynch function. In your app, it could be // $http or something else (this external service should be injected //so that we can mock it in unit testing) $timeout.cancel(timeoutPromise); //cancel the timeout defer.resolve(data); }); return defer.promise; } };});app.controller('MainCtrl', function($scope, $timeout, promiseService) { $scope.qPromiseCall = function() { promiseService.getPromise().then(function(data) { console.log(data); });//you could pass a second callback to handle error cases including timeout }});
Your tests are similar to the above example:
it('Timeout occurs', function() { spyOn(console, 'log'); spyOn($timeout, 'cancel'); $scope.qPromiseCall(); $timeout.flush(251); //set it to timeout $scope.$apply(); expect(console.log).toHaveBeenCalledWith("Timed out"); //expect($timeout.cancel).not.toHaveBeenCalled(); //I also use $timeout to simulate in the code so I cannot check it here because the $timeout is flushed //In real app, it is a different service })it('Timeout does not occur', function() { spyOn(console, 'log'); spyOn($timeout, 'cancel'); $scope.qPromiseCall(); $timeout.flush(230);//not timeout $scope.$apply(); expect(console.log).not.toHaveBeenCalledWith("Timed out"); expect($timeout.cancel).toHaveBeenCalled(); //also need to check whether cancel is called })
The behaviour of "failing a promise unless it is resolved with a specified timeframe" seems ideal for refactoring into a separate service/factory. This should make the code in both the new service/factory and controller clearer and more re-usable.
The controller, which I've assumed just sets the success/failure on the scope:
app.controller('MainCtrl', function($scope, failUnlessResolvedWithin, myPromiseService) { failUnlessResolvedWithin(function() { return myPromiseService.getPromise(); }, 250).then(function(result) { $scope.result = result; }, function(error) { $scope.error = error; });});
And the factory, failUnlessResolvedWithin
, creates a new promise, which effectively "intercepts" a promise from a passed in function. It returns a new one that replicates its resolve/reject behaviour, except that it also rejects the promise if it hasn't been resolved within the timeout:
app.factory('failUnlessResolvedWithin', function($q, $timeout) { return function(func, time) { var deferred = $q.defer(); $timeout(function() { deferred.reject('Not resolved within ' + time); }, time); $q.when(func()).then(function(results) { deferred.resolve(results); }, function(failure) { deferred.reject(failure); }); return deferred.promise; };});
The tests for these are a bit tricky (and long), but you can see them at http://plnkr.co/edit/3e4htwMI5fh595ggZY7h?p=preview . The main points of the tests are
The tests for the controller mocks
failUnlessResolvedWithin
with a call to$timeout
.$provide.value('failUnlessResolvedWithin', function(func, time) { return $timeout(func, time);});
This is possible since 'failUnlessResolvedWithin' is (deliberately) syntactically equivalent to
$timeout
, and done since$timeout
provides theflush
function to test various cases.The tests for the service itself uses calls
$timeout.flush
to test behaviour of the various cases of the original promise being resolved/rejected before/after the timeout.beforeEach(function() { failUnlessResolvedWithin(func, 2) .catch(function(error) { failResult = error; });});beforeEach(function() { $timeout.flush(3); $rootScope.$digest();});it('the failure callback should be called with the error from the service', function() { expect(failResult).toBe('Not resolved within 2');});
You can see all this in action at http://plnkr.co/edit/3e4htwMI5fh595ggZY7h?p=preview
My implementation of @Michal Charemza 's failUnlessResolvedWithin with a real sample.By passing deferred object to the func it reduces having to instantiate a promise in usage code "ByUserPosition". Helps me deal with firefox and geolocation.
.factory('failUnlessResolvedWithin', ['$q', '$timeout', function ($q, $timeout) { return function(func, time) { var deferred = $q.defer(); $timeout(function() { deferred.reject('Not resolved within ' + time); }, time); func(deferred); return deferred.promise; }}]) $scope.ByUserPosition = function () { var resolveBy = 1000 * 30; failUnlessResolvedWithin(function (deferred) { navigator.geolocation.getCurrentPosition( function (position) { deferred.resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }); }, function (err) { deferred.reject(err); }, { enableHighAccuracy : true, timeout: resolveBy, maximumAge: 0 }); }, resolveBy).then(findByPosition, function (data) { console.log('error', data); }); };