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.
###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 theusername
andpassword
- 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)
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.