This document should cover approaches and examples to unit testing MobileCaddy applications
Versions
This document should apply to all versions of MobileCaddy package and supporting libraries.
Concept
Unit testing is an important tool in the developer’s chest. Adopting a Test Driven Development approach can heavily help in identifying failure scenarios and aid project structure, whilst simultaneously increasing confidence in your codes resilience.
Code structure – Controllers vs Services
Despite what many getting started guides for AngularJS and Ionicv1 say, AngularJS controllers should be be code-light. In most scenarios Services should contain the bulk of logic within your applications. As well as being best practice in regards to separation of concerns, it also benefits the developer when it comes to writing unit tests. Unit testing controllers isn’t so straightforward, whereas services are much easier to test.
Configuring and Running Unit Tests
MobileCaddy shell/starter apps come with a framework for unit-testing already in place, and utilise Karma and Jasmine, for running and writing your tests respectively.
The karma configuration is placed in the ~/tests/my.conf.js file, which shouldn’t need any changes unless you pull in 3rd party libraries into your project.
Karma can be run with the following command; this will run a single test run, by default, but this option can be modified in the Karma configuration file.
1 2 3 |
grunt karma |
Writing Tests
Each Angular Service should have it’s own test spec file, and this should be located in the ~/tests/Services directory, and be sensibly named.
To start let’s say we have a service as follows;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/** * MyService * * @description My service for testing against * sync status. */ (function() { 'use strict'; angular .module('starter.services') .factory('MyService', MyService); MyService.$inject = []; function MyService() { return { getOne: getOne }; function getOne(){ return 1; } } })(); |
At the most basic level, a test spec could be like this, showing a single test suite, with a single test case (spec), which in turn has a single assertion in place.
1 2 3 4 5 6 7 8 9 10 11 |
describe("A suite is just a function", function() { var a; it("and so is a spec", function() { a = true; expect(a).toBe(true); }); }); |
For us to test out Angular Service though, we need to make sure our suite has access to the dependant service, so we add some injection code. We’ll also update our example to add a test in for our getOne function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
describe("Testing MyService", function() { beforeEach(module('starter.services')); beforeEach(inject(function (_$rootScope_, _MyService_) { MyService = _MyService_; $rootScope = _$rootScope_; })); // before each it("does get 1", function() { a = MyService.getOne(); expect(a).toBe(1); }); }); |
If we run our test now, we will see that our MyService is tested with our grunt task that we get output that includes the following, and we can see our service was tested, with all tests passing and with 100% coverage (for our service);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
PhantomJS 2.1.1 (Linux 0.0.0): Executed 66 of 66 SUCCESS (0.179 secs / 0.165 secs) --------------------|----------|----------|----------|----------|----------------| File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines | --------------------|----------|----------|----------|----------|----------------| … … … … … … my.service.js | 100 | 100 | 100 | 100 | | network.service.js| 100 | 83.33 | 100 | 100 | | … … … … … … --------------------|----------|----------|----------|----------|----------------| |
Mocking MobileCaddy Libraries
It is possible to mock the calls MobileCaddy libraries – such as devUtils – in you test specs. Let’s say we have increased our MyService service to make use of the devUtils, as follows;
my.service.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
MyService.$inject = ['devUtils', 'logger']; function MyService(devUtils, logger) { return { read: read, insert: insert }; // Call MobileCaddy devUtils to read all records from a table function read(){ return new Promise(function(resolve, reject) { devUtils.readRecords('Account__ap', []).then(function (resObject) { // return all the records resolve(resObject.records); }).catch(function (e) { logger.error('read', e); reject(e); }); }); } // Call MobileCaddy devUtils to insert an object in a table function insert(obj){ return new Promise(function(resolve, reject) { devUtils.insertRecord('Account__ap', obj).then(function (resObject) { resolve(resObject); }).catch(function (e) { logger.error('insert', e); reject(e); }); }); } } |
Things to note in our test code is that since we are using asynchronous calls we are passing a done function into our specs, and then calling this upon completion. We also want to make sure that we are testing rejections from our devUtils promises, to make sure that they are not just being dropped by our services.
myService.tests.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
describe("Testing MyService", function() { // Runs before each suite - sets up mocks beforeEach(module('starter.services')); var utilsMock; beforeEach(function() { // Set up our mock Spy object for devUtils calls we are interested in utilsMock = jasmine.createSpyObj('devUtils', ['readRecords', 'insertRecord']); module(function($provide) { $provide.value('devUtils', utilsMock); }); }); // before each /* READ - PROMISED SUCCESS */ describe("read Success", function() { beforeEach(inject(function (_$rootScope_, _MyService_) { MyService = _MyService_; $rootScope = _$rootScope_; })); // My read test case - note the 'done' being passed in, for handling // of asynchronous calls it("does a read", function(done) { // set our mock of readRecords utilsMock.readRecords.and.callFake(function () { return new Promise(function (resolve, reject) { resolve({records: [{'id': 1}, {'id': 2}]}); }); }); // Call the function we are testing MyService.read().then(function(res){ expect(res.length).toBe(2); // Could do further assertions to make sure our response has been formatted // as per the logic in the service // call 'done' to complete the test once our async calls have returned done(); }); }); }); /* INSERT - PROMISED SUCCESS */ describe("insert Success", function() { beforeEach(inject(function (_$rootScope_, _MyService_) { MyService = _MyService_; $rootScope = _$rootScope_; })); it("inserts a record", function(done) { // set our mock of readRecords // Here we can also do assertions to make sure the correct table is called // and that our incoming object is as expected. utilsMock.insertRecord.and.callFake(function (table, obj) { console.log() return new Promise(function (resolve, reject) { expect(table).toBe('Account__ap'); expect(obj.key).toBe(123); resolve('ok'); }); }); MyService.insert({'key' : 123}).then(function(res){ expect(res).toBe('ok'); done(); }); }); }); /* PROMISED REJECTION */ // Standard code that can be used for more or less all services that reject, // with minor changes. describe('promise rejections failure', function(){ beforeEach(inject(function (_$rootScope_, _MyService_) { MyService = _MyService_; $rootScope = _$rootScope_; // REJECT mock for READ utilsMock.readRecords.and.callFake(function () { return new Promise(function (resolve, reject) { reject("MY-ERROR"); }); }); // REJECT mock for INSERT utilsMock.insertRecord.and.callFake(function () { return new Promise(function (resolve, reject) { reject("MY-ERROR"); }); }); })); // Test case for READ it('read promise rejection', function(done) { var testError = function(res) { expect(res).toBe("MY-ERROR"); done(); }; MyService.read(1) .catch(testError); }); // Test case for INSERT it('write promise rejection', function(done) { var testError = function(res) { expect(res).toBe("MY-ERROR"); done(); }; MyService.insert({}) .catch(testError); }); }); }); |
It is possible to mock the calls to other services within your application, in a similar fashion. For example you could mock calls to the SyncService also.
Bitbucket Pipelines Integration
To enable Bitbucket pipelines so that the unit tests are run on each commit first enable pipelines on your repository. Second, create a bitbutcket-pipelines.yml file that contains this content;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# You can use a Docker image from Docker Hub or your own container # registry for your build environment. image: node:5.11.0 pipelines: default: - step: script: # Modify the commands below to build your repository. - apt-get update - apt-get -y upgrade - apt-get -y install ruby-full rubygems - npm --version - npm install - npm install -g grunt - npm install -g grunt-cli - gem install sass - npm install grunt-contrib-sass --save-dev - grunt devsetup - grunt unit-test |
Coverage Options
Coverage information can be output into local html files, rather than standard text form. This is useful during local test writing, as it gives you information on the lines of code that have been covered.
To achieve this comment out the following lines from the ~/tests/my.conf.js
1 2 3 4 5 |
// coverageReporter: { // type: 'text' // }, |
With this commented out, when you run the unit tests you will get files saved under the ~/tests/coverage directory. Open the index file here and you can dig down through your services.
Troubleshooting
- “Error: Timeout – Async callback was not invoked within timeout” – Seeing this error can often be the case when your test spec is calling asynchronous functions. The handling of asynchronous call flow requires the done() function to be passed and called within your spec. See the examples above.
References
- Karma Homepage
- Jasmine Homepage
- Getting Started with JavaScript Promises – Google Developers
- JavaScript Promises – MDN