AngularJS Unit Testing With Karma and Jasmine
One of the benefits of using AngularJS is that it is designed with testability in mind. Having integrated unit testing can help prevent bugs as projects grow in complexity and the number of developers. It also provides new devs with documentation on how to use existing code.
In my last article, I explained how to use Test-Driven Development (TDD) to create a simple Angular 2 application. This time around, I will be taking a different approach for unit testing an Angular application. In this post I will write unit tests for an existing reference Angular application and explain how to make good use of testing tools for Angular.
Tests or Code First?
Some places require their developers to use TDD while some do not. The approach you use is up to either your individual or organizational philosophy. TDD does offer quite a few benefits if you can get in to the habit. Regardless of the approach used, you will be far better off with unit tests in place than if you have none.
As projects grow in complexity and mature, having good automated testing in place can save a lot of headaches. When a new developer joins an existing project, unit tests can be a valuable source of documentation to teach how the code is supposed to function. If they are assigned to fix a bug in code that they did not write, and make a change that fixes their small issue but breaks other requirements, the unit tests will let them know that there is an issue with their code change.
Catching problems like these at the development stage will save time. If there are no unit tests to let the developer know there is an issue early on, their changes could potentially make it to testers or even production before the new issue is caught. Unit tests may not catch every bug that will be introduced, but it should give a level of confidence that business logic will continue to function in the same way.
Who has time to write tests?
As developers we’ve all been here. I know I have. When we work on projects we always have tight deadlines and things need to get done. Unit testing is thought of as optional since it doesn’t have a direct effect on how the product functions. But if you commit to writing tests early on in a project it will save time in the long run.
Writing tests can seem tedious at times, but good tests can be a safety net that can prevent you from introducing regression bugs. The time you save can be better spent on adding new features or refactoring your code.
What We Are Testing
For this article, I have put together a simple AngularJS application that can be downloaded from Github here. As I discuss topics, I will be using this application to give examples to demonstrate.
The example application is a book inventory application that performs basic CRUD operations.
Karma
We will be using Karma as a test runner for our test cases. To set up Karma, first you will need to install the Karma command line interface using npm install karma-cli -g
.
In order for Karma to run our tests we will need a karma.conf.js
configuration file to our project. We can create this file ourselves or have karma-cli
generate one for us by using karma init
on the command line. Below is my example config file:
karma.conf.js
module.exports = function(config) { config.set({ // base path that will be used to resolve all patterns (eg. files, exclude) basePath: '', // frameworks to use // available frameworks: https://npmjs.org/browse/keyword/karma-adapter frameworks: ['jasmine'], // list of files / patterns to load in the browser files: [ 'client/lib/angular/angular.js', 'client/lib/angular-mocks/angular-mocks.js', 'client/lib/angular-ui-router/angular-ui-router.js', 'client/lib/angular-ui-router/release/angular-ui-router.js', 'client/lib/lodash/lodash.js', 'client/lib/moment/moment.js', 'client/app/app.js', 'client/app/modules.js', 'client/app/main.js', 'client/app/**/*.js', 'client/test/**/*.spec.js', 'client/test/**/*.spec.js' ], // list of files to exclude exclude: [ ], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { }, // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter reporters: ['progress'], // web server port port: 9876, // enable / disable colors in the output (reporters and logs) colors: true, // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG logLevel: config.LOG_INFO, // enable / disable watching file and executing tests whenever any file changes autoWatch: true, // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher browsers: ['PhantomJS'], // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits singleRun: false, // Concurrency level // how many browser should be started simultaneous concurrency: Infinity }) }
The important part here is the files property. We need to tell Karma where to find all of our JavaScript libraries, source files, and finally our test specs.
After we have this file in place, we can run our tests by running karma start
on the command line. With autoWatch
set to true, Karma will watch all of our files for changes and automatically re-run all of our tests every time a file is modified.
File Structure/Naming Convention
If you download the example code, you’ll see in the client folder I have the source code for our Angular application in the app/ folder. The test specs can be found in the test/ folder. The test directory structure is set up to mirror the structure of the app/ folder so that it is immediately obvious what test specs are trying to test.
The naming convention for the tests is to add spec to the file name of the target file that is being tested. In this example I use the format fileName.spec.js
, but it is common to use fileNameSpec.js
as well.
Setting Up Our tests
You’ll see in the examples below that all of our test specs are set up similarly. Each spec is contained in a describe function like this:
describe('Component we are testing', function() { });
We give a descriptive name to describe what we are testing. If we have failing tests this will print in your test output so make sure it tells you enough that you know where to look for issues.
You can nest these describe functions as well. So if you want to have a separate block for each function in a controller you could do the following:
describe('Controller - Example Controller', function() { describe('add()', function() { // test add function here }); describe('view()', function() { // test view function here }); describe('submit()', function() { // test submit function here }); });
The other part of our setup that you will see that our specs have in common are the beforeEach
and afterEach
functions.
describe('Controller', function() { beforeEach(function() { // Perform set up here - runs before each individual test }); afterEach(function() { // Runs after each individual test }); });
These functions are run either before or after each individual test and allow us to run set up and tear down tasks to give us control over what we are testing.
Like the describe blocks above, these can also be nested if you need to do things differently for different groups of test.
describe('Controller - Example Controller', function() { beforeEach(function() { // Perform set up here - runs before each individual test }); afterEach(function() { // Runs after each individual test }); describe('add()', function() { beforeEach(function() { // Additional setup for add() }); afterEach(function() { // Additional tear down for add() }); }); describe('view()', function() { beforeEach(function() { // Additional setup for view() }); afterEach(function() { // Additional tear down for view() }); }); describe('submit()', function() { beforeEach(function() { // Additional setup for submit() }); afterEach(function() { // Additional tear down for submit() }); }); });
Most of our tests also use an inject block which is used for injecting Angular components. Example:
describe('Controller', function() { var controller; var $state; var $q; beforeEach(function() { inject(function($controller, _$state_, _$q_) { $state = _$state_; $q = _$q_; controller = $controller('ExampleController', {}); }); }); });
If you add underscores before and after the Angular components, you can then store them in variables in case you need to access them outside the inject function. Also we will need $controller
to create our controllers and inject dependencies for testing.
You will see these blocks throughout our example specs below.
Testing Controllers
First we will write unit tests for one of our application’s controllers. Controllers contain our view logic and our application has three controllers. The first is for the main page of the app that lists the current book inventory.
booksController.spec.js
'use strict'; describe('Controller - Books Controller', function() { var booksController; var booksServiceMock; var $rootScope; var $state; var $q; var deferredListResponse; var mockBookList; beforeEach(function() { module('ui.router.state'); module('book-inventory-app.books'); booksServiceMock = jasmine.createSpyObj('BooksService', ['getBooks', 'deleteBook']); mockBookList = [{ id: '1'}, { id: '2' }, { id: '3'}]; inject(function($controller, _$rootScope_, _$state_, _$q_) { $rootScope = _$rootScope_; $state = _$state_; $q = _$q_; deferredListResponse = $q.defer(); booksServiceMock.getBooks.and.returnValue(deferredListResponse.promise); deferredListResponse.resolve(mockBookList); booksController = $controller('BooksController', { $state: $state, BooksService: booksServiceMock }); spyOn($state, 'go'); $rootScope.$apply(); }); }); });
To start with, here is the setup for our booksController test spec. You can see we declare some variables that will be used at the top. Then in the beforeEach
block we use module()
to include any of our Angular modules that we will need to set up our controller.
This controller has a dependency on one of our application’s services so we need to create a mock service. You’ll see we create booksServiceMock
using jasmine.createSpyObj
which is a built-in Jasmine utility function for creating mock objects. We just need to specify the name of the mock object and an array of all the function names that our controller will call. If you look a few lines down from where we create booksServiceMock
you’ll see we can also specify return values. Using booksServiceMock.getBooks.and.returnValue
we specify that when this function is called in the controller it will return a promise.
You can also create spy functions from existing objects using spyOn
. In this example we are spying on the go function in angular’s $state
component.
Now that we have the setup ready, let’s take a look at the tests for this controller:
it('should load a list of books on inititialization', function() { expect(booksController.booksList).toBeDefined(); expect(booksController.booksList).toBe(mockBookList); expect(booksController.booksList.length).toEqual(3); })
Here we are testing that our books service provided the mock data list we provided in the setup.
it('should take the user to the edit screen', function() { expect($state.go).not.toHaveBeenCalled(); booksController.editBook('1'); expect($state.go).toHaveBeenCalledWith('editBook', {id:'1'}) }) it('should take the user to the add screen', function() { expect($state.go).not.toHaveBeenCalled(); booksController.addBook(); expect($state.go).toHaveBeenCalledWith('addBook') })
We use our $state.go
spy function that we created earlier to test that it is being called by the editBook
and addBook
functions with the data we expect.
it('should select a book for the details view', function() { expect(booksController.selectedBook).toBeUndefined(); booksController.selectBook(mockBookList[0]); expect(booksController.selectedBook).toBe(mockBookList[0]); booksController.selectBook(mockBookList[2]); expect(booksController.selectedBook).toBe(mockBookList[2]); }) it('should call the service to delete a book', function() { var deferred = $q.defer(); booksServiceMock.deleteBook.and.returnValue(deferred.promise); booksController.deleteBook('2'); expect(booksServiceMock.deleteBook).toHaveBeenCalledWith('2'); }) it('should reload the book list after successfully deleting a book', function() { var deferred = $q.defer(); booksServiceMock.deleteBook.and.returnValue(deferred.promise); booksServiceMock.getBooks.calls.reset(); expect(booksServiceMock.getBooks).not.toHaveBeenCalled(); booksController.deleteBook('1'); deferred.resolve(true); $rootScope.$apply(); expect(booksServiceMock.getBooks).toHaveBeenCalled(); })
You can see in this test that we can reset the calls made on our spy functions by calling calls.reset()
. That way we can isolate out when a spy function was called only when expected so that we are not getting false positives in our tests.
Testing Services
Now that we’ve wrote tests for our controller, we should add testing for our books service. The books service is responsible for communicating with our backend server. It performs basic CRUD operations.
To test components that make HTTP calls we can use $httpBackend
. This mocks a HTTP server so that we don’t need to have our backend server running while we run our unit tests.
To use $httpBackend
first, we tell it what calls we are expecting. Then we call .flush()
and verifyNoOutstandingRequest()
to first pass all the outstanding HTTP calls through our mock backend and then verify that there are no calls made that we weren’t expecting.
Here’s the setup for our service’s test spec:
booksService.spec.js
'use strict'; describe('Services - Books Services', function() { var booksService; var $httpBackend; beforeEach(function() { module('book-inventory-app.books'); inject(function(BooksService, _$httpBackend_) { $httpBackend = _$httpBackend_; booksService = BooksService; $httpBackend.verifyNoOutstandingRequest(); }); }); afterEach(function() { $httpBackend.flush(); $httpBackend.verifyNoOutstandingRequest(); }); });
The setup is similar to our controller test except that we aren’t creating the service within the inject block. Angular creates our service for us and we simply store a reference to it for use in the tests.
And you’ll see we are calling flush()
and verifyNoOutstandingRequest()
in the afterEach
block as discussed above. This will process all HTTP calls made during the test and then makes sure no calls were made that weren’t expected.
Now for the test cases:
it('should make a GET call to retrieve a list of books', function() { $httpBackend.expectGET('/api/books').respond(200, []); booksService.getBooks(); }); it('should make a GET call to retrieve a book', function() { $httpBackend.expectGET('/api/book/9').respond(200, {}); booksService.getBook('9'); }); it('should make a POST call to create a new book', function() { var mockBook = { id: '111' }; $httpBackend.expectPOST('/api/book/', mockBook).respond(200, true); booksService.createBook(mockBook); }); it ('should make a PUT call to update an existing book', function() { var mockBook = { id: '999' }; $httpBackend.expectPUT('/api/book/999', mockBook).respond(200, true); booksService.saveBook(mockBook, mockBook.id); }); it('should make a DELETE call to remove a book', function() { $httpBackend.expectDELETE('/api/book/123').respond(200, true); booksService.deleteBook('123'); });
They all follow a similar pattern. We tell the mock backend what call(s) we expect then call the service function that will make that particular backend call.
We can tell the backend the HTTP verb, url, and the request body if it is a POST or PUT. Then we can specify a response code and data.
Testing Filters
The final component we will look at unit testing is filters. Filters are usually fairly simple and you’ll see in the example below that testing them is less complicated than controllers or services.
formatName.spec.js
'use strict'; describe('Filter - Format Name Filter', function() { var formatNameFilter; beforeEach(function() { module('book-inventory-app.filters'); inject(function($filter) { formatNameFilter = $filter('formatName'); }) }); });
To set up our filter tests the only thing we need to do is create an instance of our filter using Angular’s $filter
and store a reference to it.
With that set up we are now ready to test out the filter. There are two behaviors we want to test.
CodeProject
it('should return an empty string for empty input', function() { expect(formatNameFilter('')).toEqual(''); }); it('should return the input with the first letter in each work capitalized and the rest un lower case', function() { expect(formatNameFilter('JOHN DOE')).toEqual('John Doe'); expect(formatNameFilter('jOHN dOE')).toEqual('John Doe'); expect(formatNameFilter('john doe')).toEqual('John Doe'); expect(formatNameFilter('John Doe')).toEqual('John Doe'); });
Here we give the filter multiple strings with different casings that we expect to have the same result.
Wrap Up
In this article I’ve discussed why we need unit tests and shown examples of how to implement testing in Angular projects. If you are looking to add integrated unit testing to your Angular project I hope this will help guide you in the right direction.
For more examples you can check out the example application here.
Thanks for reading, and feel free to leave any questions or comments you might have below!
Reference: | AngularJS Unit Testing With Karma & Jasmine from our WCG partner Matthew Brown at the Keyhole Software blog. |