How To Write Automated Tests For Your Ionic App - Part 2

In this post we'll write unit tests for a controller, in an Ionic app, that deals with asynchronous code and state transitions. It might not be the easiest example to get started with unit testing, but I believe it will set a good foundation for you to write tests for your own app.

This tutorial is split up in 3 parts:
Part 1 - Introduction to Automated Testing & Frameworks
Part 2 - Unit Tests With Jasmine & Karma (this post)
Part 3 - End-To-End Tests With Jasmine & Protractor

If you followed along with my Build Your First Mobile App With The Ionic Framework tutorial, you'll already be familiar with the app we're going to test. But don't worry if you haven't, you should still be able to follow this tutorial.

The source code can be found on GitHub.

Installation

In Part 1 we've seen that we need Karma, Jasmine and angular-mocks for our unit tests, so let's install them into our project. Go to the root of your Ionic project and execute these commands:

$ npm install karma --save-dev
$ npm install karma-jasmine --save-dev
$ bower install angular-mocks#1.3.13 --save-dev

Note: Always make sure you install the version of angular-mocks that is the same version as the Angular library included in Ionic.

We'll also need to install the Karma CLI to run Karma from the command line.

$ npm install -g karma-cli

Lastly, we need to decide on a browser to run our unit tests. Karma supports most browsers, but the most popular choice is PhantomJS. It's a headless (no GUI) browser built on WebKit and is perfect for running automated unit tests.

$ npm install -g phantomjs

Note: if you want to use another browser, you'll have to install a launcher for it, so that Karma can call it. Chrome and PhantomJS launchers are included with the default installation.

Configuration

Let's create a folder to contain our unit test files:

$ mkdir -p tests/unit-tests

We'll also have to create a configuration file for Karma to instruct it which files to use for testing and which browser.

$ cd tests
$ karma init unit-tests.conf.js

Hit enter on every question to choose the default value, except for the following two questions.

Do you want to capture any browsers automatically ?  
Press tab to list possible options. Enter empty string to move to the next question.  
> PhantomJS

Next, input all the files that are needed for your tests to run. It's basically the same list of files that are included in your index.html and all the test files.

What is the location of your source and test files ?  
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".  
Enter empty string to move to the next question.  
> ../www/lib/ionic/js/ionic.bundle.js
> ../www/lib/moment.min.js
> ../www/app/**/*.js
> ../bower_components/angular-mocks/angular-mocks.js
> unit-tests/**/*.js

Writing The Tests

Now we have everything set up, let's start writing our unit tests for the doLogin function on the LoginController. Here's the code for the doLogin function:

function LoginController($state, $ionicPopup, dinnerService) {  
    var vm = this;
    vm.doLogin = function () {

        var onSuccess = function () {
            $state.go('my-dinners');    
        };

        var onError = function () {
            $ionicPopup.alert({
                 title: 'Login failed :(',
                 template: 'Please try again.'
               });
        };

        dinnerService.login(vm.username, vm.password)
                     .then(onSuccess, onError);
    }
}

I've identified 3 unit tests:

  • test that dinnerService.login is called with the username and password
  • test that on successful login, the my-dinners view will be displayed
  • test that on unsuccessful login, a popup will be displayed

Let's create the test file inside the unit-tests directory and call it login.controller.tests.js. Copy the code below into this test file. I've left a few pieces of code out, we'll have a look at these later.

describe('LoginController', function() {

    var controller, 
        deferredLogin,
        dinnerServiceMock,
        stateMock,
        ionicPopupMock;

    // TODO: Load the App Module

    // TODO: Instantiate the Controller and Mocks

    describe('#doLogin', function() {

        // TODO: Call doLogin on the Controller

        it('should call login on dinnerService', function() {
            expect(dinnerServiceMock.login).toHaveBeenCalledWith('test1', 'password1'); 
        });

        describe('when the login is executed,', function() {
            it('if successful, should change state to my-dinners', function() {

                // TODO: Mock the login response from DinnerService

                expect(stateMock.go).toHaveBeenCalledWith('my-dinners');
            });

            it('if unsuccessful, should show a popup', function() {

                // TODO: Mock the login response from DinnerService

                expect(ionicPopupMock.alert).toHaveBeenCalled();
            });
        });
    })
});

The describe function helps you organize your tests into test suites and the it function defines your actual tests, also called specs. Inside the it function, we then write what we expect to happen. In this example, we only have one expect per spec, but you can add as many as you need.

Let's run these tests now.

$ karma start unit-tests.conf.js

The tests should obviously fail because we still need to write the code to set up the controller and call the doLogin function.

LoginController #doLogin should call login on dinnerService FAILED  
    TypeError: 'undefined' is not an object (evaluating 'dinnerServiceMock.login')
        at login.controller.tests.js:18
LoginController #doLogin when the login is executed, if successful, should change state to my-dinners FAILED  
    TypeError: 'undefined' is not an object (evaluating 'stateMock.go')
        at login.controller.tests.js:26
LoginController #doLogin when the login is executed, if unsuccessful, should show a popup FAILED  
    TypeError: 'undefined' is not an object (evaluating 'ionicPopupMock.alert')
        at /login.controller.tests.js:33
Executed 3 of 3 (3 FAILED) ERROR (0.001 secs / 0.001 secs)  

You can leave this command-line window open for now. Every time you save changes in the code, Karma will automatically run the tests again.

Load the App Module

First we need to load our app module into our tests, otherwise we won't be able to access the LoginController.

// load the module for our app
beforeEach(module('app'));  

The beforeEach function is a Jasmine function that will run before each test spec.

The module function is a function in the angular-mocks library.

Instantiate the Controller and Mocks

The second piece of code we're going to add will instantiate the LoginController and mock all external dependencies: dinnerService, $state and $ionicPopup.

// instantiate the controller and mocks for every test
beforeEach(inject(function($controller, $q) {  
    deferredLogin = $q.defer();

    // mock dinnerService
    dinnerServiceMock = {
        login: jasmine.createSpy('login spy')
                      .and.returnValue(deferredLogin.promise)           
    };

    // mock $state
    stateMock = jasmine.createSpyObj('$state spy', ['go']);

    // mock $ionicPopup
    ionicPopupMock = jasmine.createSpyObj('$ionicPopup spy', ['alert']);

    // instantiate LoginController
    controller = $controller('LoginController', { 
                    '$ionicPopup': ionicPopupMock, 
                    '$state': stateMock, 
                    'DinnerService': dinnerServiceMock }
                 );
}));

There is a lot happening here, so let's break it down.

The inject function comes from the angular-mocks library and it can inject any dependency you need. In this case we're injecting $controller, which we will use later to instantiate the LoginController, and $q.

We are creating mocks in 2 different ways: by just creating a new object and defining the same functions as the real implementation, like we do with dinnerServiceMock, and by using jasmine.createSpyObj.

// mock dinnerService
dinnerServiceMock = {  
    login: jasmine.createSpy('login spy')
                  .and.returnValue(deferredLogin.promise)           
};

We're using the jasmine.createSpy function to define a spy on the dinnerService.login function so we can mock the implementation of this function and set up a deferred object that is returned to the tests. We will use this deferred object later to manipulate the result of the login function.

// mock $state
stateMock = jasmine.createSpyObj('$state spy', ['go']);

// mock $ionicPopup
ionicPopupMock = jasmine.createSpyObj('$ionicPopup spy', ['alert']);  

The stateMock automatically has a spy on the function go, because it's included in the second argument of createSpyObj. The same goes for the ionicPopupMock.

These spies allow us to track calls to the functions and all arguments.

At the end of the beforeEach we instantiate the LoginController and inject the mocks instead of the real implementations.

Call doLogin on the Controller

All we have to do now to run the first test, is call the doLogin function on the controller. Don't mind the $rootScope right now, we'll come back to it later.

// call doLogin on the controller for every test
beforeEach(inject(function(_$rootScope_) {  
    $rootScope = _$rootScope_;
    controller.username = 'test1';
    controller.password = 'password1';
    controller.doLogin();
}));

Let's save the changes and have a look at the output of our tests, by now I'm expecting the first one to succeed, the other 2 are still missing some code, so these should fail.

LoginController #doLogin when the login is executed, if successful, should change state to my-dinners FAILED  
    Expected spy $state spy.go to have been called with [ 'my-dinners' ] but it was never called.
        at login.controller.tests.js:55
LoginController #doLogin when the login is executed, if unsuccessful, should show a popup FAILED  
    Expected spy $ionicPopup spy.alert to have been called.
        at login.controller.tests.js:62
Executed 3 of 3 (2 FAILED) (0.001 secs / 0.019 secs)  

Mock the login response from DinnerService

For our 2 remaining tests, we just need to resolve or reject the promise that dinnerService.login returns.

Add this code in the second test to simulate a successful login:

deferredLogin.resolve();  
$rootScope.$digest();

Add this code in the third test to simulate a unsuccessful login:

deferredLogin.reject();  
$rootScope.$digest();

Remember that we injected the $rootScope earlier? Any time we resolve or reject a promise we'll have to call $scope.$digest next because promises are only resolved when Angular's digest cycle is triggered. I haven't defined a $scope for the controller, so I'm using $rootScope here.

Let's save the changes and have a look at the output of our tests, by now all 3 tests should pass.

LoginController #doLogin when the login is executed, if successful, should change state to my-dinners FAILED  
    Error: Unexpected request: GET app/dinners/my-dinners.html
    No more request expected
        at ...
LoginController #doLogin when the login is executed, if unsuccessful, should show a popup FAILED  
    Error: Unexpected request: GET app/dinners/my-dinners.html
    No more request expected
        at ...
Executed 3 of 3 (2 FAILED) (0.001 secs / 0.024 secs)  

2 FAILED...!?!?! :(

This one had me scratching my head for a while, at first I thought that I hadn't set up my mocks correctly, but that code was pretty straight forward. So some Googling later I finally found out that the problem was caused by the $ionicTemplateCache service trying to preload all templates in the app.

Disable $ionicTemplateCache

There are multiple solutions mentioned here, but the one that worked for me is adding this code after loading the app module:

// disable template caching
beforeEach(module(function($provide, $urlRouterProvider) {  
    $provide.value('$ionicTemplateCache', function(){} );
    $urlRouterProvider.deferIntercept();
}));

Again, save the changes... and we're finally there! All 3 tests have passed!

Executed 3 of 3 SUCCESS (0 secs / 0.023 secs)  

The source code can be found on GitHub.


I hope you have all the information you need now to start writing your own unit tests. If you have any questions, let me know in the comments and stay tuned for Part 3 in which we will write End-To-End tests.

Follow me on Twitter @ashteya and sign up for my weekly emails to get new tutorials.

If you found this article useful, could you hit the share buttons so that others can benefit from it, too? Thanks!

comments powered by Disqus