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:
-
Spies: Can wrap an existing function or be anonymous. A spy keeps records of all interactions with a function including the input arguments, the function return, or whether the function threw an error. Spies still allow the original function to execute, they just spy on the calls.
-
Stubs: Like spies, stubs are built from existing functions or can be created anonymously. Also like spies, they record all of their interactions. When you use a stub, the original function is not called. Instead, you tell the stub what you would like it to do each time it is called. By doing this, we can effectively control the flow of execution in the unit under test by controlling the output of functions with which the code interacts.
-
Mocks: Mocks are a bit different than stubs and spies. Mocks create expectations. If you tell a stub to always return the value of '42' when it receives '12' as its input, it will not complain if it is never called with '12' or if it is never called at all. The stub essentially just knows how to respond in the event that it receives a '12'. Expectations differ in that they will throw an exception if they are not met. If you have an expectation that a function will be called with an input of '12' and never is, it will throw an exception during a verify step that will cause your test to fail.
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-launcher
, karma-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:
- module.exports = function(config){
- config.set({
- basePath: '',
- frameworks: ['jasmine'],
- files: [
- 'bower_components/angular/angular.js',
- 'bower_components/angular-mocks/angular-mocks.js',
- 'bower_components/sinonjs/sinon.js',
- 'bower_components/jasmine-sinon/lib/jasmine-sinon.js',
- 'app/app.js',
- 'app/**/*.js',
- 'app/**/*.test.js'
- ],
- exclude: [],
- preprocessors: {},
- reporters: ['progress'],
- port: 9876,
- colors: true,
- autoWatch: false,
- //browsers: ['Firefox','PhantomJS'],
- browsers: ['PhantomJS'],
- singleRun: true
- });
- };
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:
- describe('MainController', function(){
- var testController,
- testScope;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: {
- refreshDefaults: function(){},
- registerItem: function(){},
- unRegisterItem: function(){}
- }
- });
- });
- });
-
- it('has default messages', function(){
- expect(testScope.helloMsg).toBe('World!');
- expect(testScope.errorMsg).toBe('');
- });
- });
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:
- var testController,
- testScope,
- fakeSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- fakeSomeService = {
- refreshDefaultsCalled: false,
- lastRegisteredItem: 'NONE',
- refreshDefaults: function(){
- this.refreshDefaultsCalled = true;
- },
- registerItem: function(item){
- this.lastRegisteredItem = item;
- },
- unRegisterItem: function(){}
- };
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: fakeSomeService
- });
- });
- });
-
- it('refreshes defaults on load', function(){
- expect(fakeSomeService.refreshDefaultsCalled).toBe(true);
- });
-
- it('registers MAIN on load', function(){
- expect(fakeSomeService.lastRegisteredItem).toBe('MAIN');
- });
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:
- var testController,
- testScope,
- fakeSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- fakeSomeService = {
- refreshDefaults: sinon.spy(),
- registerItem: sinon.spy(),
- unRegisterItem: sinon.spy()
- };
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: fakeSomeService
- });
- });
- });
-
- it('refreshes defaults on load', function(){
- expect(fakeSomeService.refreshDefaults).toHaveBeenCalled();
- });
-
- it('registers MAIN on load', function(){
- expect(fakeSomeService.registerItem).toHaveBeenCalledWith('MAIN');
- });
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:
- 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:
- var testController,
- testScope,
- stubbedSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller, SomeService){
- testScope = $rootScope.$new();
-
- stubbedSomeService = sinon.stub(SomeService);
-
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: stubbedSomeService
- });
- });
- });
-
- it('refreshes defaults on load', function(){
- expect(stubbedSomeService.refreshDefaults).toHaveBeenCalled();
- });
-
- it('registers MAIN on load', function(){
- expect(stubbedSomeService.registerItem).toHaveBeenCalledWith('MAIN');
- expect(stubbedSomeService.registerItem).toHaveBeenCalledAfter(stubbedSomeService.refreshDefaults);
- });
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.
- var testController,
- testScope,
- fakeSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
-
- fakeSomeService = {
- getMessages: function(){},
- refreshDefaults: function(){},
- registerItem: function(){}
- };
-
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: fakeSomeService
- });
- });
- });
-
- it('displays an error when there are no messages', function(){
- fakeSomeService.getMessages = function(){
- return [];
- };
-
- testController.loadMessages();
-
- expect(testScope.errorMsg).toBe('No messages available.');
- });
-
- it('filters out messages that are expired', function(){
- fakeSomeService.getMessages = function(){
- return [
- {
- id: 1,
- isExpired: true
- },
- {
- id: 2,
- isExpired: false
- }
- ];
- };
-
- testController.loadMessages();
-
- expect(testScope.messages.length).toBe(1);
- expect(testScope.messages[0].id).toBe(2);
- });
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:
- var testController,
- testScope,
- stubbedSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
-
- stubbedSomeService = {
- getMessages: sinon.stub(),
- refreshDefaults: sinon.stub(),
- registerItem: sinon.stub()
- };
-
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: stubbedSomeService
- });
- });
- });
-
- it('displays an error when there are no messages', function(){
- stubbedSomeService.getMessages.returns([]); // Stub now returns an empty array.
-
- testController.loadMessages();
-
- expect(testScope.errorMsg).toBe('No messages available.');
- });
-
- it('filters out messages that are expired', function(){
- stubbedSomeService.getMessages.returns([
- {
- id: 1,
- isExpired: true
- },
- {
- id: 2,
- isExpired : false
- }
- ]);
-
- testController.loadMessages();
-
- expect(testScope.messages.length).toBe(1);
- expect(testScope.messages[0].id).toBe(2);
- });
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.
- var testController,
- testScope,
- fakeSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
-
- fakeSomeService = {
- getMessages: function(){},
- refreshDefaults: function(){},
- registerItem: function(){},
- doCalcCb: function(){}
- };
-
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: fakeSomeService
- });
- });
- });
-
- it('populates calculation result', function(){
- var expectedResult = 8675309;
-
- fakeSomeService.doCalcCb = function(a, b, successCb){
- successCb(expectedResult);
- };
-
- testController.doCalc(1, 2);
-
- expect(testScope.calcResult).toBe(expectedResult);
- });
-
- it('displays an error when cannot get calculation result', function(){
- fakeSomeService.doCalcCb = function(a, b, successCb, errorCb){
- errorCb('Some Error');
- };
-
- testController.doCalc(1, 2);
-
- expect(testScope.errorMsg).toBe('Unable to complete calculation: Some Error');
- });
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.
- var testController,
- testScope,
- stubbedSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller){
- testScope = $rootScope.$new();
-
- stubbedSomeService = {
- getMessages: sinon.stub(),
- refreshDefaults: sinon.stub(),
- registerItem: sinon.stub(),
- doCalcCb: sinon.stub()
- };
-
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: stubbedSomeService
- });
- });
- });
-
- it('populates calculation result', function(){
- var expectedResult = 8675309;
-
- stubbedSomeService.doCalcCb.callsArgWith(2, expectedResult);
-
- testController.doCalc(1, 2);
-
- expect(testScope.calcResult).toBe(expectedResult);
- expect(stubbedSomeService.doCalcCb).toHaveBeenCalledWith(1, 2);
- });
-
- it('displays an error when cannot get calculation result', function(){
- stubbedSomeService.doCalcCb.callsArgWith(3, 'Some Error');
-
- testController.doCalc(1, 2);
-
- expect(testScope.errorMsg).toBe('Unable to complete calculation: Some Error');
- expect(stubbedSomeService.doCalcCb).toHaveBeenCalledWith(1, 2);
- });
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.
- var testController,
- testScope,
- q,
- stubbedSomeService;
-
- beforeEach(function(){
- module('SinonExample');
-
- inject(function($rootScope, $controller, $q){
- testScope = $rootScope.$new();
-
- q = $q;
-
- stubbedSomeService = {
- getMessages: sinon.stub(),
- refreshDefaults: sinon.stub(),
- registerItem: sinon.stub(),
- doCalcCb: sinon.stub(),
- doCalcPromise: sinon.stub()
- };
-
- testController = $controller('MainController', {
- $scope: testScope,
- SomeService: stubbedSomeService
- });
- });
- });
-
- it('populates calculation result', function(){
- var expectedResult = 8675309;
- var defer = q.defer();
- defer.resolve(expectedResult);
-
- stubbedSomeService.doCalcPromise.withArgs(1, 2).returns(defer.promise);
-
- testController.doCalcPromise(1, 2);
-
- testScope.$apply();
-
- expect(testScope.calcResult).toBe(expectedResult);
- });
-
- it('displays an error when cannot get calculation result', function(){
- var defer = q.defer();
- defer.reject('Some Error');
-
- stubbedSomeService.doCalcPromise.withArgs(1, 2).returns(defer.promise);
-
- testController.doCalcPromise(1, 2);
-
- testScope.$apply();
-
- expect(testScope.errorMsg).toBe('Unable to complete calculation: Some Error');
- });
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
- AngularJS
https://angularjs.org/ - Bower
http://bower.io/ - Grunt
http://gruntjs.com/ - Gulp
http://gulpjs.com/ - Jasmine
https://jasmine.github.io/ - Jasmine-sinon
https://github.com/froots/jasmine-sinon - Karma
https://karma-runner.github.io/ - Node Package Manager (npm)
https://www.npmjs.org/ - PhantomJS
http://phantomjs.org/ - Sinon.JS
http://sinonjs.org/
Software Engineering Tech Trends (SETT) is a regular publication featuring emerging trends in software engineering.