Angular Unit Testing part 1 - Local Storage


In my last blog post, we went over one strategy for utilizing HTML5 Local Storage to help provide offline access functionality to an application. In this tutorial, we'll go over how to build unit tests against an Angular service that works with Local Storage.


Tools and Setup###

Unit testing is a huge topic. So, for the sake of brevity, I'm going to list out the high-level info on the tools we're using and include links to the resources so you can read more in depth about these tools as needed.

This tutorial starts off at the point where these things are already set up within the project.

Make sure to go into your config section or file for grunt-karma and add Jasmine as the framework:

//grunt config for Karma
karma: {
	options: {
    	frameworks: ['jasmine']
        ...
    }
}

Setting up a test helper###

It's highly likely that if we're persisting one type of data to local storage, we'll have other services that do the same with other types of data. For instance, the example I used in the last post was a widgetService and a localStorageWidgetService. Maybe in that same app you would have data services to work with orders - ordersService and localStorageOrdersService. That being the case, let's create a common helper that can be used to generate our mocks/spies needed to unit test the localStorage functionality.

Let's create a new folder at the project root directory (or wherever makes the most sense in your application) named test-helpers. Next, create a javascript file in that directory named appropriately. I chose "localStorageMockAndSpies".

Now let's start coding! Start off with an IIFE so we don't pollute the global scope:

(function(self) {

	//helper guts goes here :)


	//if module.exports, then this should work with require() if needed - I've not tested this yet though :)
}((module||{}).exports || window));

Next, let's set up a couple variables that are going to be private to this scope. Since we're mocking the window.localStorage object, we'll need both a data store and an object with a compatible API for interacting with that data store:

//data store for holding objects
var localStore = {};

//localStorage mock for interacting with localStore
var fakeLocalStorage = {
	getItem: function (key) {
    	return localStore[key];
    },
    setItem: function (key, value) {
    	localStore[key] = val+'';
    },
    removeItem: function (key) {
    	delete localStore[key];
    },
    clear: function() {
    	localStore = {};
    }
}

This is a bit of a naïve mock because it doesn't worry about size limits and other such aspects of the full window.localStorage API. We're just testing core functionality here.

Next we're going to create the function we'll actually be exposing. This function will take a window object and will either put our fake in place of the localStorage property, or will use jasmine's .andCallFake(function(){...}) functionality to redirect interactions with it. The reason for this is that PhantomJS has the localStorage object locked down so that we can't overwrite it. In order to do this, we'll be using Jasmine's spyOn constructs. There are many cool things you can do with Jasmine's spies - here's a great cheat sheet I found online for them!

function setupMockLocalStorage(windowObject) {
	//first, check to see if the browser is phantom
    if(windowObject.navigator && windowObject.navigator.userAgent.match(/Phantom/g)) {
    	//localStorage object being read-only, we have to spy and redirect function calls...
        spyOn(windowObject.localStorage, 'getItem')
        	.andCallFake(fakeLocalStorage.getItem);
        spyOn(windowObject.localStorage, 'setItem')
        	.andCallFake(fakeLocalStorage.setItem);
        spyOn(windowObject.localStorage, 'removeItem')
        	.andCallFake(fakeLocalStorage.removeItem);
        spyOn(windowObject.localStorage, 'clear')
        	.andCallFake(fakeLocalStorage.clear);    
    } else {
    	//Anything other than Phantom, we can just replace the definition for windowObject.localStorage with our own custom one
        Object.defineProperty(windowObject, 'localStorage', {value: fakeLocalStorage, writable: true});
        //Create our spies so we can tell when functions were called, etc...
        //using .andCallThrough() tells the spy to allow the function to go ahead and get called rather than redirecting to another function
        spyOn(windowObject.localStorage, 'getItem')
        	.andCallThrough();
        spyOn(windowObject.localStorage, 'setItem')
        	.andCallThrough();
        spyOn(windowObject.localStorage, 'removeItem')
        	.andCallThrough();
        spyOn(windowObject.localStorage, 'clear')
        	.andCallThrough();
    }
}

//here's the object that actually gets exposed to be used by test fixtures
self.localStorageMockSpy = {
	setup: setupMockLocalStorage
};

Last thing to do with our helper is to make sure that our grunt config section for karma pulls in our helper file(s):

//grunt config for Karma
karma: {
	options: {
    	files: [
        	//list out all files that should be pulled in, including our helpers
            ...,
            'test-helpers/*.js',
            ...
        ]
        ...
    }
}

Writing our test fixture###

I typically keep my tests along-side my app files - if I have a localStorageWidgetSvc.js file, I'll have a localStorageWidgetSvc-spec.js file as a sibling to it. Use the structure that works best for you, and create a test file for the service.

The first thing we want to put in our test file is a describe block. This is a Jasmine construct for having a high-level description for the individual tests within. You may have one describe block per file, or if the functionality is large you may have one per logical chunk of functionality. This being a fairly simple bit of code, we'll just use one:

describe('localStorageWidgetService', function() {
	//individual tests and stuff go here...
});

Next, let's create some test widget data and variables to hold references to items we'll retrieve via angular's $inject functionality.

//key to use to access localStorage items:
//NOTE: This MUST match the key that the localStorageWidgetService uses
var LS_KEY = 'my.app.widgets';

//will define before each test
var fakeWidgets;

//these will be injected by the angular DI system
var localStorageWidgetService, $window;

Now we need to populate those references. In order to do so, we'll use two other Jasmine constructs: beforeEach & afterEach. As their names imply, these are fixtures that are used before and after each test. Before each test runs we want to get dependencies, instantiate objects, etc... After each test runs we want to clean stuff up.

beforeEach(function(){
	//pull in our angular module for our widgets
    module('widgets');
    
    //inject our dependencies.
    //instead of using $window, use _$window_ so that you can have your local reference named $window.  It will pull in the appropriate item.
    inject(function (_$window_, _localStorageWidgetService_) {
    	$window = _$window_;
        localStorageWidgetService = _localStorageWidgetService_;
    });
        
    //set up our fake tickets
    fakeWidgets = [
		{id: 1, name: 'widget1', price: 15.75},
    	{id: 2, name: 'widget2', price: 22.25},
    	{id: 3, name: 'widget3', price: 50.00}
	];
    
    //now that our $window object is populated, set up the mock and spies for its localStorage property:
    window.localStorageMockSpy.setup($window);    
});

afterEach(function(){
	localStorageWidgetService.clear();
});

Alright, now we just need to start building out our tests! Jasmine has another construct for defining tests: it('should...', function(){...}). This is great because you can make you tests read like you'd say them to a person - "it should store widgets in local storage" for instance. Within each it block, there can be one or more expect statements. These are the actual tests that pass or fail, and there are a lot of cool things you can do with them. The Jasmine documentation is a great resource for diving deeper into these. Let's finish up by writing a few tests of our own!

//test that we can set the entire set of widgets with our convenience property
it('should save all widgets to local storage with convenience property', function () {
	localStorageWidgetService.widgets = fakeWidgets;
    //when we retrieve widgets, it should be what we set it to
    expect(localStorageWidgetService.widgets).toEqual(fakeWidgets);
    //Now, since we have a spy we can tell if the underlying localStorage function was actually called and with the correct params.
    //Remember, localStorage is just [string]KEY:[string]VALUE pairs.  Objects are stringified before persisting into it.
    expect($window.localStorage.setItem).toHaveBeenCalledWith(LS_KEY, JSON.stringify(fakeWidgets));
});

If you are curious about how localStorageWidgetService.widgets works the way it does, see this article on using Object.defineProperty to create custom getter/setter functions :)

Okay, let's write a couple more! Let's test the functionality of retrieving a widget by ID, adding/updating a single widget, removing a single widget, and clearing all widgets:

it('should retrieve a widget by ID', function() {
	//put all widgets in localStorage
    localStorageWidgetService.widgets = fakeWidgets;
    
    var widget = localStorageWidgetService.getById(fakeWidgets[2].id);
    
    //test that we got a widget back and that it's the one we want:
    expect(widget).toBeDefined();
    //Jasmine even has equality comparators on objects
    expect(widget).toEqual(fakeWidgets[2]);    
});


it('should allow adding or updating of a single widget', function() {
	//put all widgets in localStorage
	localStorageWidgetService.widgets = fakeWidgets;
    
    //creat a new widget
    var newWidget = {id: 25, name: 'newWidget25', price: 22.75};
    
    //add it to localStorage
    localStorageWidgetService.add(newWidget);
    
    //test it out, we should have 4 widgets in localStorage now
    expect(localStorageWidgetService.widgets.length).toBe(fakeWidgets.length+1);
    
    //Now, update a widget
    fakeWidgets[0].price = 17.50;
    localStorageWidgetService.add(fakeWidgets[0]);
    
    //retrieve that widget
    var lsWidget = localStorageWidgetService.getById(fakeWidgets[0].id);
    
    //test it out - price should match
    expect(lsWidget.price).toBe(fakeWidgets[0].price);
    
    //and, we should still have only 4 items in localStorage
    expect(localStorageWidgetService.widgets.length).toBe(fakeWidgets.length+1);    
});


it('should allow us to remove a single widget by ID', function() {
	//put all widgets in localStorage
	localStorageWidgetService.widgets = fakeWidgets;
    
    //remove a single item
    localStorageWidgetService.remove(fakeWidgets[1].id);
    
    //test it out!
    var lsDeletedWidget = localStorageWidgetService.getById(fakeWidgets[1].id);
    //should be null
    expect(lsDeletedWidget).toBe(null);
    //we should now have just 2 widgets in localStorage
    expect(localStorageWidgetService.widgets.length).toBe(fakeWidgets.length-1);    
});


it('should allow us to clear all widgets from localStorage', function() {
	//put all widgets in localStorage
	localStorageWidgetService.widgets = fakeWidgets;
    
    //tell it to remove them all
    localStorageWidgetService.clear();
    
    //test it out - verify function call with our spy!
    expect($window.localStorage.removeItem).toHaveBeenCalledWith(LS_KEY);
    
    //see that our .widgets property is now just an empty array
    expect(localStorageWidgetService.widgets.length).toBe(0);
});

And there we have it - mocking, spying on, and testing an Angular service that interacts with localStorage :) As with all things programming, this isn't the only way to accomplish the goal. So, feel free to leave a comment with things that could be improved!