AngularJS Unit Tests with Sinon.JS

AngularJS Unit Tests with Sinon.JS

by Jason Schindler, Software Engineer

November 2014


Introduction

AngularJS is an open-source framework for building single page web applications. It utilizes two-way data binding to dynamically keep information synchronized between the model and view layers of the application and directives as a way to extend HTML to allow developers to express views in a declarative fashion. It is primarily developed and maintained by Google. For the remainder of this article, a general working knowledge of AngularJS is helpful, but not required.

The AngularJS development team considers testing an extremely important part of the development process, and it shows. AngularJS applications are easy to unit-test due to built-in dependency injection and clear separation of roles between the view, controller, service, filter, and directive layers.

Most AngularJS projects use the Jasmine BDD-friendly testing framework along with the Karma test runner for unit testing and Protractor for end-to-end or acceptance level testing. Karma was initially developed by the AngularJS team and is capable of running tests in most any framework with community plugins. Because of their easy integration with AngularJS, I'll be focusing on Jasmine and Karma for this article.

Sinon.JS is also an open-source framework. It provides spies, stubs, and mocks for use in JavaScript unit tests. It works with any unit testing framework and has no external dependencies. This article is only going to brush the surface of Sinon.JS capabilities. If you have not had an opportunity to use Sinon.JS yet, hopefully this will interest you enough to get started.

So what exactly are spies, stubs, and mocks?

When writing a unit test, you should only be concerned with the logic in the unit you are testing. However, most code does interact with other modules and their implementations can at times get in the way of tests. Spies, stubs, and mocks give us a way to describe the way our unit under test interacts with other modules. In Sinon.JS:

I mostly use stubs and anonymous spies for my tests. Anonymous spies are good for situations where the unit under test does not require a function to return any value, but I still need to assert that the call was made. Stubs are useful when I want to control the flow of execution in my test. In the examples below, I will use both anonymous spies and stubs to separate the unit under test from its dependencies.

But... Jasmine already has spies built in!

Yes it does. I have no issue with the Jasmine spies already available. I just happen to like Sinon.JS better. :-)

Adding Sinon.JS to your AngularJS project

Let's start by adding Karma and Sinon.JS to your project.

Add Karma dependencies to your project.

Note: If you are already using Karma with Jasmine you can skip this section, but please make sure that the version of your karma-jasmine package is 0.2.x. At the time of this writing, an npm install karma-jasmine command by default installs the 0.1.x version of karma-jasmine which is not compatible with the Sinon.JS Jasmine matchers below.

To get Karma in your project, we will be adding the karma package, one or more launchers, and karma-jasmine.

First, let's install Karma and PhantomJS globally so that we can run them from the command line. Karma is a unit test runner that integrates nicely with AngularJS, and PhantomJS is a headless browser that is used for web application testing.

npm install -g karma phantomjs

Now we can install the karma-jasmine package, and one or more launchers.

npm install --save-dev karma-phantomjs-launcher karma-jasmine@0.2.x

This only installs the PhantomJS launcher. You may wish to install additional launchers for other browsers on your machine. Other options include: karma-firefox-launcherkarma-chrome-launcher, and karma-ie-launcher. For a more complete list, visit npmjs.org. I tend to run unit tests almost exclusively in PhantomJS to take advantage of the speedy execution time. If you start encountering odd errors (For example: not being able to use Function.prototype.bind) it helps to run your tests using another browser to verify that PhantomJS is behaving correctly.

Note: The PhantomJS bug listed above can be overcome by using es5-shim.

Create a Karma configuration file

The easiest way to create a Karma configuration file, is to run karma init from your project folder. This will ask a number of questions about your project and generate a configuration file based on your answers. After the command has completed, a file named karma.conf.js should be available in your project. Mine looks something like this:

  1. module.exports = function(config){
  2. config.set({
  3. basePath: '',
  4. frameworks: ['jasmine'],
  5. files: [
  6. 'bower_components/angular/angular.js',
  7. 'bower_components/angular-mocks/angular-mocks.js',
  8. 'bower_components/sinonjs/sinon.js',
  9. 'bower_components/jasmine-sinon/lib/jasmine-sinon.js',
  10. 'app/app.js',
  11. 'app/**/*.js',
  12. 'app/**/*.test.js'
  13. ],
  14. exclude: [],
  15. preprocessors: {},
  16. reporters: ['progress'],
  17. port: 9876,
  18. colors: true,
  19. autoWatch: false,
  20. //browsers: ['Firefox','PhantomJS'],
  21. browsers: ['PhantomJS'],
  22. singleRun: true
  23. });
  24. };

There are a number of options available here. As long as your files array includes the appropriate source files, dependencies, and test files, and you have installed launchers for the items in your browsers array, you should be good to go.

Note: The bower_components folder in the example above is where Bower places your dependencies by default.

Getting Sinon.JS

In addition to Sinon.JS, we will also be using jasmine-sinon which adds a number of Sinon.JS matchers to Jasmine for us.

If you are using Bower, getting Sinon.JS in your project is as simple as:

bower install --save-dev sinonjs jasmine-sinon

If you are not, please visit the links above and pull down the JavaScript files needed and place them within your project.

Once Sinon.JS and jasmine-sinon are available, make sure they are loaded in the files array of your karma.conf.js. An example of this is provided above.

Incorporating into your build

Karma plugins are available for Grunt and Gulp. If you aren't using a build system, you can run your unit tests by executing karma start in your project folder.

Useful AngularJS/Sinon.JS recipes

Great! You should now have Sinon.JS available to your AngularJS project. Let's go through a few ways to Sinon-ify our tests.

Use anonymous spies (or stubs) instead of NOOP or short functions.

Consider the following AngularJS controller test:

  1. describe('MainController', function(){
  2. var testController,
  3. testScope;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. inject(function($rootScope, $controller){
  9. testScope = $rootScope.$new();
  10. testController = $controller('MainController', {
  11. $scope: testScope,
  12. SomeService: {
  13. refreshDefaults: function(){},
  14. registerItem: function(){},
  15. unRegisterItem: function(){}
  16. }
  17. });
  18. });
  19. });
  20.  
  21. it('has default messages', function(){
  22. expect(testScope.helloMsg).toBe('World!');
  23. expect(testScope.errorMsg).toBe('');
  24. });
  25. });

In this example, MainController receives $scope and SomeService dependencies. In order to properly isolate the operations of SomeService from the controller under test, I have assigned empty (or NOOP) functions to the properties in SomeService that the controller code is using. This is a sensible starting point, and correctly detaches any code in SomeService from my item under test.

So let's complicate things a small bit. If I don't call SomeService.refreshDefaults() before using the rest of the service, things may break. Also, I want to know that I have correctly registered MAIN with the service. Starting from the point above, the next logical step would look something like this:

  1. var testController,
  2. testScope,
  3. fakeSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. fakeSomeService = {
  9. refreshDefaultsCalled: false,
  10. lastRegisteredItem: 'NONE',
  11. refreshDefaults: function(){
  12. this.refreshDefaultsCalled = true;
  13. },
  14. registerItem: function(item){
  15. this.lastRegisteredItem = item;
  16. },
  17. unRegisterItem: function(){}
  18. };
  19.  
  20. inject(function($rootScope, $controller){
  21. testScope = $rootScope.$new();
  22. testController = $controller('MainController', {
  23. $scope: testScope,
  24. SomeService: fakeSomeService
  25. });
  26. });
  27. });
  28.  
  29. it('refreshes defaults on load', function(){
  30. expect(fakeSomeService.refreshDefaultsCalled).toBe(true);
  31. });
  32.  
  33. it('registers MAIN on load', function(){
  34. expect(fakeSomeService.lastRegisteredItem).toBe('MAIN');
  35. });

While completely usable, this test is starting to smell a little funny. We have created a fake version of SomeService that tracks if refreshDefaults was called and the final argument passed to registerItem. It isn't difficult to imagine additional scenarios that muddy the water further. For example, tracking the number of times that refreshDefaults is called or the value of the 3rd item that was registered.

This is an excellent use case for anonymous spies. Sinon.JS spies will record when they are called, as well as the inputs and outputs of each call. In our case, we are using anonymous spies so tracking outputs isn't needed.

Here are the same tests using Sinon.JS:

  1. var testController,
  2. testScope,
  3. fakeSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. fakeSomeService = {
  9. refreshDefaults: sinon.spy(),
  10. registerItem: sinon.spy(),
  11. unRegisterItem: sinon.spy()
  12. };
  13.  
  14. inject(function($rootScope, $controller){
  15. testScope = $rootScope.$new();
  16. testController = $controller('MainController', {
  17. $scope: testScope,
  18. SomeService: fakeSomeService
  19. });
  20. });
  21. });
  22.  
  23. it('refreshes defaults on load', function(){
  24. expect(fakeSomeService.refreshDefaults).toHaveBeenCalled();
  25. });
  26.  
  27. it('registers MAIN on load', function(){
  28. expect(fakeSomeService.registerItem).toHaveBeenCalledWith('MAIN');
  29. });

Isn't that better? By replacing our NOOP functions with anonymous Sinon.JS spies, we have gained the ability to glance into the calls that have occurred without writing additional code just to do so. Additionally, we can now inspect specific calls or even the order of the calls if needed:

  1. expect(fakeSomeService.registerItem).toHaveBeenCalledAfter(fakeSomeService.refreshDefaults);

If you want to switch out all current (and future) functions on a AngularJS service, there is an additional step you can take. You can use sinon.stub(serviceInstance) to switch all service functions with stubs. Because stubs do not call through to the original function and because they are also spies, we can get the same functionality at the anonymous spies above by stubbing the entire service. For example:

  1. var testController,
  2. testScope,
  3. stubbedSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. inject(function($rootScope, $controller, SomeService){
  9. testScope = $rootScope.$new();
  10.  
  11. stubbedSomeService = sinon.stub(SomeService);
  12.  
  13. testController = $controller('MainController', {
  14. $scope: testScope,
  15. SomeService: stubbedSomeService
  16. });
  17. });
  18. });
  19.  
  20. it('refreshes defaults on load', function(){
  21. expect(stubbedSomeService.refreshDefaults).toHaveBeenCalled();
  22. });
  23.  
  24. it('registers MAIN on load', function(){
  25. expect(stubbedSomeService.registerItem).toHaveBeenCalledWith('MAIN');
  26. expect(stubbedSomeService.registerItem).toHaveBeenCalledAfter(stubbedSomeService.refreshDefaults);
  27. });

By injecting an instance of SomeService in our beforeEach function, we were able to stub all of the functions available to service consumers with one call. Because stubs do not call through to the original methods and are also spies, the functionality of our test doesn't change.

Warning: Using this method to stub an entire service should only be used when you have a very good understanding of what functionality the service provides. It is usually best to only do this with services that you have written as part of your application. Also, please remember that stubs only work with functions. If you are storing strings or other non-function values as properties on your service, those will remain unchanged.

Use stubs to control flow of execution

Much like the initial example in the previous section, a mocked or faked service can become unwieldy when it needs to be modified to control the flow of execution in the item under test. For example, let's say that our service has a method called getMessages which returns an array of messages given a registered item.

  1. var testController,
  2. testScope,
  3. fakeSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. inject(function($rootScope, $controller){
  9. testScope = $rootScope.$new();
  10.  
  11. fakeSomeService = {
  12. getMessages: function(){},
  13. refreshDefaults: function(){},
  14. registerItem: function(){}
  15. };
  16.  
  17. testController = $controller('MainController', {
  18. $scope: testScope,
  19. SomeService: fakeSomeService
  20. });
  21. });
  22. });
  23.  
  24. it('displays an error when there are no messages', function(){
  25. fakeSomeService.getMessages = function(){
  26. return [];
  27. };
  28.  
  29. testController.loadMessages();
  30.  
  31. expect(testScope.errorMsg).toBe('No messages available.');
  32. });
  33.  
  34. it('filters out messages that are expired', function(){
  35. fakeSomeService.getMessages = function(){
  36. return [
  37. {
  38. id: 1,
  39. isExpired: true
  40. },
  41. {
  42. id: 2,
  43. isExpired: false
  44. }
  45. ];
  46. };
  47.  
  48. testController.loadMessages();
  49.  
  50. expect(testScope.messages.length).toBe(1);
  51. expect(testScope.messages[0].id).toBe(2);
  52. });

As you can see above, these tests need to alter the behavior of the getMessages method to verify that the item under test takes the correct action. To accomplish this, we are modifying the fakeSomeService in each individual test to return pre-determined data sets. This illustrates a simple starting point for Sinon.JS stubs.

We touched on stubs in the prior section, but ignored their most common use case. A stub can control how it will respond to calls. Here is an updated version of this test using Sinon.JS stubs:

  1. var testController,
  2. testScope,
  3. stubbedSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. inject(function($rootScope, $controller){
  9. testScope = $rootScope.$new();
  10.  
  11. stubbedSomeService = {
  12. getMessages: sinon.stub(),
  13. refreshDefaults: sinon.stub(),
  14. registerItem: sinon.stub()
  15. };
  16.  
  17. testController = $controller('MainController', {
  18. $scope: testScope,
  19. SomeService: stubbedSomeService
  20. });
  21. });
  22. });
  23.  
  24. it('displays an error when there are no messages', function(){
  25. stubbedSomeService.getMessages.returns([]); // Stub now returns an empty array.
  26.  
  27. testController.loadMessages();
  28.  
  29. expect(testScope.errorMsg).toBe('No messages available.');
  30. });
  31.  
  32. it('filters out messages that are expired', function(){
  33. stubbedSomeService.getMessages.returns([
  34. {
  35. id: 1,
  36. isExpired: true
  37. },
  38. {
  39. id: 2,
  40. isExpired : false
  41. }
  42. ]);
  43.  
  44. testController.loadMessages();
  45.  
  46. expect(testScope.messages.length).toBe(1);
  47. expect(testScope.messages[0].id).toBe(2);
  48. });

By using the returns method on the stub, we were able to tell the stub what to return when called and control the flow of execution. This difference alone may seem trivial, but Sinon.JS stubs are capable of much more detailed behavior. Consider the following:

stub.withArgs(arg1, arg2).onCall(2).throws()

This stub will throw an exception the 3rd time it is called with arg1 and arg2.

Due to the asynchronous nature of web programs, many AngularJS services will have functions that either execute a callback or resolve a promise when data is available. First, let's take a look at handling callbacks.

  1. var testController,
  2. testScope,
  3. fakeSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. inject(function($rootScope, $controller){
  9. testScope = $rootScope.$new();
  10.  
  11. fakeSomeService = {
  12. getMessages: function(){},
  13. refreshDefaults: function(){},
  14. registerItem: function(){},
  15. doCalcCb: function(){}
  16. };
  17.  
  18. testController = $controller('MainController', {
  19. $scope: testScope,
  20. SomeService: fakeSomeService
  21. });
  22. });
  23. });
  24.  
  25. it('populates calculation result', function(){
  26. var expectedResult = 8675309;
  27.  
  28. fakeSomeService.doCalcCb = function(a, b, successCb){
  29. successCb(expectedResult);
  30. };
  31.  
  32. testController.doCalc(1, 2);
  33.  
  34. expect(testScope.calcResult).toBe(expectedResult);
  35. });
  36.  
  37. it('displays an error when cannot get calculation result', function(){
  38. fakeSomeService.doCalcCb = function(a, b, successCb, errorCb){
  39. errorCb('Some Error');
  40. };
  41.  
  42. testController.doCalc(1, 2);
  43.  
  44. expect(testScope.errorMsg).toBe('Unable to complete calculation: Some Error');
  45. });

Much like our other non-Sinon examples, these tests are manually executing callback functions in order to verify that the controller behaves correctly. Using Sinon.JS, invoking callbacks is simple.

  1. var testController,
  2. testScope,
  3. stubbedSomeService;
  4.  
  5. beforeEach(function(){
  6. module('SinonExample');
  7.  
  8. inject(function($rootScope, $controller){
  9. testScope = $rootScope.$new();
  10.  
  11. stubbedSomeService = {
  12. getMessages: sinon.stub(),
  13. refreshDefaults: sinon.stub(),
  14. registerItem: sinon.stub(),
  15. doCalcCb: sinon.stub()
  16. };
  17.  
  18. testController = $controller('MainController', {
  19. $scope: testScope,
  20. SomeService: stubbedSomeService
  21. });
  22. });
  23. });
  24.  
  25. it('populates calculation result', function(){
  26. var expectedResult = 8675309;
  27.  
  28. stubbedSomeService.doCalcCb.callsArgWith(2, expectedResult);
  29.  
  30. testController.doCalc(1, 2);
  31.  
  32. expect(testScope.calcResult).toBe(expectedResult);
  33. expect(stubbedSomeService.doCalcCb).toHaveBeenCalledWith(1, 2);
  34. });
  35.  
  36. it('displays an error when cannot get calculation result', function(){
  37. stubbedSomeService.doCalcCb.callsArgWith(3, 'Some Error');
  38.  
  39. testController.doCalc(1, 2);
  40.  
  41. expect(testScope.errorMsg).toBe('Unable to complete calculation: Some Error');
  42. expect(stubbedSomeService.doCalcCb).toHaveBeenCalledWith(1, 2);
  43. });

By using the callsArgWith function on our stub, we were able to instruct the stub to call one of the (0 indexed) arguments that were passed into it and pass the desired values. Also notice that it was trivial to add expect statements to verify that the controller is correctly passing the first two arguments to the service.

If we used promises instead of callbacks, we have to do a little extra work.

  1. var testController,
  2. testScope,
  3. q,
  4. stubbedSomeService;
  5.  
  6. beforeEach(function(){
  7. module('SinonExample');
  8.  
  9. inject(function($rootScope, $controller, $q){
  10. testScope = $rootScope.$new();
  11.  
  12. q = $q;
  13.  
  14. stubbedSomeService = {
  15. getMessages: sinon.stub(),
  16. refreshDefaults: sinon.stub(),
  17. registerItem: sinon.stub(),
  18. doCalcCb: sinon.stub(),
  19. doCalcPromise: sinon.stub()
  20. };
  21.  
  22. testController = $controller('MainController', {
  23. $scope: testScope,
  24. SomeService: stubbedSomeService
  25. });
  26. });
  27. });
  28.  
  29. it('populates calculation result', function(){
  30. var expectedResult = 8675309;
  31. var defer = q.defer();
  32. defer.resolve(expectedResult);
  33.  
  34. stubbedSomeService.doCalcPromise.withArgs(1, 2).returns(defer.promise);
  35.  
  36. testController.doCalcPromise(1, 2);
  37.  
  38. testScope.$apply();
  39.  
  40. expect(testScope.calcResult).toBe(expectedResult);
  41. });
  42.  
  43. it('displays an error when cannot get calculation result', function(){
  44. var defer = q.defer();
  45. defer.reject('Some Error');
  46.  
  47. stubbedSomeService.doCalcPromise.withArgs(1, 2).returns(defer.promise);
  48.  
  49. testController.doCalcPromise(1, 2);
  50.  
  51. testScope.$apply();
  52.  
  53. expect(testScope.errorMsg).toBe('Unable to complete calculation: Some Error');
  54. });

In these tests, we injected $q and used it to create and prime the promise that is returned by the stub of each service method. Once the promise has been returned, we need to trigger a digest cycle by invoking the $apply method on our scope.

Summary

Properly separating the logic you are testing from the implementation of their dependencies can be tricky, and home-grown solutions are prone to error. Using a spy framework makes your tests easier to read and maintain while providing greater choice in test assertions and execution. Sinon.JS is an excellent framework and pairs nicely with the dependency injection available in AngularJS to give the developer complete control over the code that is being tested.

In this article, I have illustrated a few examples of unit tests that were made easier to read and modify by the introduction of Sinon.JS spies and stubs. We used anonymous spies and stubs to replace NOOP functions in order to easily build assertions around functions that provide no discoverable value to our item under test. We also used stubs to easily control the flow of execution of our item under test including the use of callback methods and promises. We have only scratched the surface of what Sinon.JS is capable of. For more information on what Sinon.JS can bring to your unit tests, visit the official documentation.

References

Software Engineering Tech Trends (SETT) is a regular publication featuring emerging trends in software engineering.


secret