Every so often, you run across some action, which just fails, where the best response it to just try it again. This is particularly true when dealing with an external source, like a database or web service, which can have network or other temporary problems, which would have cleared up when you repeat the call seconds later.
Often, these actions fail by throwing an exception which makes the process of trying the call again rather cumbersome. Having to deal with this a number of times in one application, I decided to wrap the full procedure up in an angular service, which I share here with you:
Sleep function
First we need to implement a sleep function, that will just wait for a specified interval of milliseconds while returning a promise.
You would think that the best fit is to use $timeout.
The problem is, it has a bad impact on your unit tests….
The reason is, if you use $timeout, you will have at the end of your test to call $timeout.flush() as many times as $timeout was called in your implementation.
Instead, if we use the standard javascript setTimeout function, we only have to call once $rootScope.$apply() at the end of our test and we’re done.
Let’s start by defining our service with an empty implementation:
In addition to sleep, we will expose 2 other functions toAsync and retry, that we’ll explain later.
For the implementation of sleep we are going to generate our own promise, call setTimeout, and not forget to wrap the callback in a $rootScope.$apply() call.
sleep function
12345678910111213141516
varsleep=function(interval){// check parameterif(!(interval===parseFloat(interval))||interval<0)thrownewError("interval must be a positive float");vardeferred=$q.defer();// sleepsetTimeout(function(){$rootScope.$apply(function(){deferred.resolve(interval);});},interval);returndeferred.promise;};
Before we continue, let’s write some unit tests to verify the implementation.
I’m using here jasmine 2.0 which comes with a new syntax for async testing.
We have 2 options here:
First we can use the new async syntax and It’s pretty simple : when testing an async function (aka a promise), you add a done parameter to your it, and you call done() when the promise returns some result.
In addition, if your implementation uses setTimeout, you add a call to $rootScope.$apply(). The caveat of this method is that the test will wait for the timeout to callback. So it is more an end to end test than an unit test.
The second method, is to use the clock mockup provided by jasmine, that executes synchronously any code using setTimeout or setInterval.
It goes like this:
call jasmine.clock().install() in the beforeEach
call jasmine.clock().tick(xxx) in the test passing the time in milliseconds that should have elapsed
describe("Service : promiseService",function(){varservice;var$rootScope;var$httpbackend;var$http;beforeEach(module("myApp"));beforeEach(inject(function($injector){service=$injector.get("promiseService");$timeout=$injector.get("$timeout");$rootScope=$injector.get("$rootScope");$httpBackend=$injector.get("$httpBackend");$http=$injector.get("$http");}));beforeEach(function(){jasmine.clock().install();});afterEach(function(){jasmine.clock().uninstall();});vartickClock=function(){jasmine.clock().tick(50000);$rootScope.$apply();};it("should_be_defined",function(){expect(service).toBeDefined();});it("sleep_with_not_integer_interval_should_throw_exception",function(){expect(function(){service.sleep("invalid");}).toThrow(newError("interval must be a positive float"));});it("sleep_with_not_positive_integer_interval_should_throw_exception",function(){expect(function(){service.sleep(-2);}).toThrow(newError("interval must be a positive float"));});it("sleep_with_positive_integer_interval_should_succeed",function(){varinterval=100;service.sleep(interval).then(function(result){expect(result).toEqual(interval);});tickClock();});});
We have a fully tested sleep function.
toAsync function
The next function that we need to write is a function that will transform a passed function into a promise.
That way we can retry on fail, either standard functions, or promises.
The trick here, if you want your unit test to pass, is to encapsulate the call to the passed function in a try catch block.
It is similar to $q.when, but instead of passing a value or a promise, we pass a function or a promise.
toAsync
123456789101112131415
vartoAsync=function(action){if(typeofaction!=="function"){thrownewError("action must be a function");}vardeferred=$q.defer();try{varretval=action();deferred.resolve(retval);}catch(ex){deferred.reject(ex);}returndeferred.promise;};
it("toAsync_with_invalid_parameter_function_should_throw_exception",function(){expect(function(){service.toAsync("invalid");}).toThrow(newError("action must be a function"));});it("toAsync_with_valid_sync_function_should_succeed",function(){spyOn(mockHelper,'addOne').and.callThrough();varaction=function(){returnmockHelper.addOne(100);};service.toAsync(action).then(function(result){expect(result).toBe(101);expect(mockHelper.addOne).toHaveBeenCalledWith(100);});tickClock();});it("toAsync_with_valid_async_function_should_succeed",function(){spyOn(mockHelper,'getUrl').and.callThrough();$httpBackend.when('GET','/dummy').respond(mockHelper.dummyResponse);varaction=function(){returnmockHelper.getUrl();};service.toAsync(action).then(function(result){expect(mockHelper.getUrl).toHaveBeenCalled();expect(result.data).toEqual(mockHelper.dummyResponse);});$httpBackend.flush();tickClock();});it("toAsync_with_faulty_sync_function_should_succeed",function(){spyOn(mockHelper,'faultyFn').and.callThrough();varaction=function(){returnmockHelper.faultyFn();};service.toAsync(action).then(null,function(rejection){expect(mockHelper.faultyFn).toHaveBeenCalled();expect(rejection.message).toBe("I'm a faulty function");});tickClock();});varmockHelper={faultyFn:function(){thrownewError("I'm a faulty function");},addOne:function(value){returnvalue+1;},getUrl:function(){return$http.get("/dummy");},dummyResponse:{"id":1,"content":"Hello World"}};
The mockHelper object holds our unit test functions, so we can reuse and spy on them across the tests.
This is usefull for checking how many times they are called and with which parameters.
retry function
This is the last piece of the puzzle.
To be as generic as possible, our function will accept a set of parameters in order to control how many times we should retry on fail, the interval between each trial, and also an interval multiplicator if we want to add some extra delay between each trial.
The implementation is straight forward, using the building blocks we previously wrote.
In the first part we just do some argument checking, and assign default values.
In the second part, we recursivly call resolver, which, execute the passed action, and if an expection is detected, do a sleep and retry.
varretry=function(action,options){retry.DEFAULT_OPTIONS={maxRetry:3,interval:500,intervalMultiplicator:1.5};if(typeofaction!=="function"){thrownewError("action must be a function");}if(!options){options=retry.DEFAULT_OPTIONS;}else{for(varkinretry.DEFAULT_OPTIONS){if(retry.DEFAULT_OPTIONS.hasOwnProperty(k)&&!(kinoptions)){options[k]=retry.DEFAULT_OPTIONS[k];}}}varresolver=function(remainingTry,interval){varresult=toAsync(action);if(remainingTry<=1){returnresult;}returnresult.catch(function(e){returnsleep(interval).then(function(){// recursionreturnresolver(remainingTry-1,interval*options.intervalMultiplicator);});});}returnresolver(options.maxRetry,options.interval);};
it("retry_with_invalid_parameter_function_should_throw_exception",function(){expect(function(){service.retry("invalid");}).toThrow(newError("action must be a function"));});it("retry_with_faulty_sync_function_should_succeed",function(){spyOn(mockHelper,'faultyFn').and.callThrough();varaction=function(){returnmockHelper.faultyFn();};varpromise=service.retry(action);promise.then(null,function(rejection){expect(mockHelper.faultyFn).toHaveBeenCalled();expect(mockHelper.faultyFn.calls.count()).toBe(3);expect(rejection.message).toBe("I'm a faulty function");});tickClock();});it("retry_with_faulty_sync_function_and_options_should_succeed",function(){spyOn(mockHelper,'faultyFn').and.callThrough();varaction=function(){returnmockHelper.faultyFn();};varpromise=service.retry(action,{maxRetry:5});promise.then(null,function(rejection){expect(mockHelper.faultyFn).toHaveBeenCalled();expect(mockHelper.faultyFn.calls.count()).toBe(5);expect(rejection.message).toBe("I'm a faulty function");});tickClock();});it("retry_with_valid_sync_function_should_succeed",function(){spyOn(mockHelper,'addOne').and.callThrough();varaction=function(){returnmockHelper.addOne(100);};varpromise=service.retry(action);promise.then(function(result){expect(result).toBe(101);expect(mockHelper.addOne).toHaveBeenCalled();expect(mockHelper.addOne.calls.count()).toBe(1);});tickClock();});it("retry_with_valid_async_function_should_succeed",function(){spyOn(mockHelper,'getUrl').and.callThrough();$httpBackend.when('GET','/dummy').respond(mockHelper.dummyResponse);varaction=function(){returnmockHelper.getUrl();};varpromise=service.retry(action);promise.then(function(result){expect(mockHelper.getUrl).toHaveBeenCalled();expect(result.data).toEqual(mockHelper.dummyResponse);});$httpBackend.flush();tickClock();});
That’s it. Our service is fully unit tested (100% coverage!!!), and we can reuse it anywhere in our applications.