Spying on AngularJS Services That Are called Directly as Functions

english mobile

Jasmine spies are great. But when testing AngularJS services, it can really be be hard to test that the correct calls are being made to services where you’re calling the service itself instead of an attached method, such as the $interval service. When you’re testing controllers, you can directly inject your own spy if you want to test that $interval was called with the correct arguments, but how do you get hold of a function that’s not attached to an object to be able to spy on it?
It turns out that your only option is to replace the service with your spy in the config phase with either $provide.decorator, or module.decorator. For your TL;DR convenience, here is a working module that you can add to the list of modules you instantiate before each test. I’ll explain why all this works after the code.

angular.module('spies.interval', [])
    .decorator('$interval', function($delegate){
       var spy = jasmine.createSpy(‘$interval’).and.callFake(
           function()  {
               return  $delegate.apply(arguments);
           }
       )
       spy.cancel =  angular.bind($delegate, $delegate.cancel);
       spy.flush =  angular.bind($delegate, $delegate.flush);
       spyOn(spy,  'cancel').and.callThrough();
       return spy;
    })

The spy game

Let’s first think about what happens when you call spyOn(someObj, ‘someFunction’), so we can understand why we’re not using that syntax here. Jasmine looks at someObj. If someObj has a function called ‘someFunction’, jasmine overwrites that function with a generic spy which may or may not call the original someFunction. But without someObj, there is nothing to attach that replacement function to to make the replacement invisible to the calling code under test.

Knowing when to delegate

So we have to “trick” Angular into injecting our replacement into the code under test when the code asks for the $interval service. We do this by returning the actual spy rather than an altered $delegate.
This then becomes very different from injecting a fake $interval service into a controller that’s created for just one test, because when you call $interval(), it caches the method to call after the elapsed time, the elapsed time, and so forth internally so that it can work its magic. So if you only replace $interval with a spy, you can no longer test that the right things happen after the time has elapsed, because that internal state isn’t right.
So we need to go on to call the actual $interval service, and we need to call it with the arguments that our service under test is using, which is why we use apply(). Once we’ve done that, we need to make sure to return the result of the call, a promise, so that when we test that code that should be canceled in our client code does not run, that doesn’t break because there’s no promise to give cancel().
Once you’ve taken all of the above into account, you have
var spy = jasmine.createSpy(‘$interval’).and.callFake(
    function() {
        return $delegate.apply(arguments);
})

Spies upon spies

 But you can’t call cancel yet (or flush), because the spy you’re replacing $interval with doesn’t have a cancel() method or a flush() method. If you try just directly adding the functions (spy.cancel = $delegate.cancel), the functions don’t run in the same scope that we wrote to when we called $delegate.apply().  By using angular.bind, we force them into the same scope as the delegate like so:
spy.cancel = angular.bind($delegate, $delegate.cancel);
spy.flush = angular.bind($delegate, $delegate.flush);
Finally, we spy on the cancel function. This is just a convenience—you can of course spy on this function as normal from any test.
spyOn(spy, 'cancel').and.callThrough();
And that, folks, is how you create a spy to be able to check that an angular service where the function is called directly was called with the correct methods without breaking the service.

But wait, there’s more

If you’re a Jasmine expert, you’re probably thinking that I could have just used the undocumented second argument of createSpy, which simplifies the code to this:
angular.module('spies.interval', [])
     .decorator('$interval', function($delegate){
        var spy =  jasmine.createSpy('$interval', $delegate);
        spyOn(spy,  'cancel').and.callThrough();
        return spy;    
})
Unfortunately, that doesn’t work. You would expect the Strategy to do the same thing internally that my home-rolled version does, but it doesn’t. Maybe some other day I’ll dig through the code and blog about why.

How useful is this anyway?

I think the main reason to use this is when the amount of time you need to wait or the optional arguments at the end will vary based on some data. However, your test usually can’t even see the method that ultimately gets called by $interval. So it feels a bit like it should not need to know so much about it that it is looking at what arguments are being passed to this function it can’t see.
So after all this, I ultimately came to feel that though you can do this, you probably shouldn’t, because it introduces a lot of coupling between your test and the internals of the methods in your service that you’re trying to test. For example, your test should not need to know whether your service internally creates its own closure to store state to call the method from in $interval or whether it directly stores that state in the end parameters of $interval.
In Scotland, they have a saying that a gentleman is a man who knows how to play the bagpipes, but doesn’t. I often feel that programming is like that.