Unit Testing JSData Code in AngularJS Services
What is JSData?
JSData is a feature rich data modeling library for JavaScript with good support for AngularJS. Although the documentation isn't perfect, it's not too difficult to get started with the library. Testing the code which uses the library, is a different matter altogether. There is a mocking library available, but it literally has no documentation. You will need to make do with a short demo lacking any comments or have to fall back to reading source code. In this blog post I'm summing up what I learned during my struggles to write meaningful unit tests for AngularJS services relying on JSData.
Setting Up the Testing Environment
I decided to use Jasmine as the test framework and Karma as the test runner, therefore you'll need to have both of them installed as development dependencies, along with at least one launcher (Chrome in my case):
npm install --save-dev jasmine
npm install --save-dev karma
npm install --save-dev karma-chrome-launcher
You can now initialize Karma to create the karma.conf.js
file (you can just accept the default answer to all questions):
node node_modules/karma/bin/karma init
You could launch karma
directly instead via node
if, you installed it globally. However, this would require you to have any Karma plugins installed globally, as well. I prefer adding a script to package.json
and running Karma tests with NPM:
{
// ...
"scripts": {
"test": "node node_modules/karma/bin/karma start karma.conf.js"
},
// ...
}
The test subject for my unit tests will be the following minimal AngularJS service:
angular.module('app',['js-data'])
.service('User', ['DS', function (DS) {
return DS.defineResource('user');
}])
.service('UserService', ['User', function(User) {
this.getUsers = function() {
return User.findAll();
};
this.getUser = function(id) {
return User.find(id);
};
}]);
Assuming, you're installing dependencies with Bower, you will only need to install two packages with their dependencies:
bower install --save js-data-angular
bower install --save-dev js-data-angular-mocks
All relevant source files now need to be listed in karma.conf.js
:
files: [
'bower_components/angular/angular.js',
'bower_components/angular-mocks/angular-mocks.js',
'bower_components/js-data/dist/js-data.js',
'bower_components/js-data-angular/dist/js-data-angular.js',
'bower_components/js-data-angular-mocks/dist/js-data-angular-mocks.js',
'src/*.js',
'test/*.js'
],
Since JSData mocking library depends on Sinon.JS, a Karma plugin for it is also required:
npm install --save-dev karma-sinon
It must be added to karma.conf.js
, as well:
frameworks: ['jasmine', 'sinon'],
To test that everything is set up correctly, we can use the following test:
describe('UserService', function() {
var UserService;
var DS;
beforeEach(function() {
module('app');
});
beforeEach(function() {
module('js-data-mocks');
});
beforeEach(function() {
inject(function(_UserService_, _DS_) {
UserService = _UserService_;
DS = _DS_;
})
});
describe('getUsers', function() {
it('calls findAll', function() {
DS.expectFindAll('user')
.respond([]);
UserService.getUsers();
expect(DS.findAll.callCount).toBe(1);
});
});
});
Time to finally run the test:
PS> npm test
> jsdata-unit-testing@1.0.0 test D:\Users\Damir\Temp\JSData
> node node_modules/karma/bin/karma start karma.conf.js
20 12 2015 11:06:10.518:WARN [karma]: No captured browser, open http://localhost:9876/
20 12 2015 11:06:10.528:INFO [karma]: Karma v0.13.15 server started at http://localhost:9876/
20 12 2015 11:06:10.541:INFO [launcher]: Starting browser Chrome
20 12 2015 11:06:11.986:INFO [Chrome 47.0.2526 (Windows 10 0.0.0)]: Connected on socket CjwejI151WEsMDpJAAAA with id 90620045
Chrome 47.0.2526 (Windows 10 0.0.0): Executed 1 of 1 SUCCESS (0.045 secs / 0.041 secs)
Testing Asynchronous Methods
All JSData methods which retrieve data from the data source are asynchronous and return promises. If you're only interested in the request being made, this can safely be ignored as in our first test. However, if you also want to check the data that was returned and how it was processed locally, you will need to take advantage of asynchronous support in Jasmine and have the promises resolve:
it('returns correct data', function(done) {
var expected = [{id: 1, username: 'damir'}];
DS.expectFindAll('user')
.respond(expected);
UserService.getUsers()
.then(function(actual) {
expect(actual).toEqual(expected);
done();
});
DS.flush();
});
Notice the following:
- The test function now expects
done
argument which needs to be called to signal that the test has concluded. - The synchronous part of the test ends with a call to
DS.flush()
which resolves all pending promises.
When additional arguments are passed to the method call, you can check their values, as well:
describe('getUser', function() {
it('is called with correct arguments', function() {
var expected = {id: 1, username: 'damir'};
DS.expectFind('user', 1)
.respond(expected);
UserService.getUser(1)
.then(function(actual) {
expect(actual).toEqual(expected);
done();
});
expect(DS.find.callCount).toBe(1);
});
});
You might want to call another asynchronous method inside the promise callback, but because of AngularJS internals, any attempts to call DS.flush()
inside the callback will fail:
Error: [$rootScope:inprog] $digest already in progress
To avoid the restriction, finally()
callback can be used instead. The value that the promise resolves to, will still need to be grabbed in then()
callback:
it('can be called multiple times', function(done) {
var expected1 = [{id: 1, username: 'damir'}];
var expected2 = [
{id: 1, username: 'damir'},
{id: 2, username: 'admin'}
];
DS.expectFindAll('user')
.respond(expected1);
DS.expectFindAll('user')
.respond(expected2);
var actual1;
UserService.getUsers()
.then(function(result) {
actual1 = result;
}).finally(function() {
expect(actual1).toEqual(expected1);
UserService.getUsers()
.then(function(actual2) {
expect(actual2).toEqual(expected2);
done();
});
DS.flush();
});
DS.flush();
});
Of course, to wait for the promise to resolve, you need a reference to it. Usually, this only means that the method under test needs to return this promise. When this is not possible (e.g. automatic refresh based on timeout), other means need to be used. AngularJS events are one of the available options:
angular.module('app',['js-data'])
.service('User', ['DS', function (DS) {
return DS.defineResource('user');
}])
.service('UserService', ['User', '$timeout', '$rootScope', function(User, $timeout, $rootScope) {
this.enableAutoRefresh = function(refreshPeriod) {
var fetchOnce = function() {
User.findAll()
.then(function(users) {
$rootScope.$broadcast('usersRefreshed', users);
});
};
var fetchPeriodically = function() {
$timeout(fetchOnce, refreshPeriod)
.then(fetchPeriodically, fetchPeriodically);
};
fetchPeriodically();
};
}]);
I'm using AngularJS's $timeout
service to implement auto refresh and publish a usersRefreshed
event every time the findAll()
promise resolves using the $broadcast
method from the AngularJS scope. This allows me to wait for the event in the test and check the data sent along with it:
describe('UserService', function() {
var UserService;
var DS;
var $timeout;
var $rootScope;
beforeEach(function() {
module('app');
});
beforeEach(function() {
module('js-data-mocks');
});
beforeEach(function() {
inject(function(_UserService_, _DS_, _$timeout_, _$rootScope_) {
UserService = _UserService_;
DS = _DS_;
$timeout = _$timeout_;
$rootScope = _$rootScope_;
})
});
describe('enableAutoRefresh', function() {
it('calls findAll after refreshPeriod', function(done) {
var refreshPeriod = 1000;
var expected = [{id: 1, username: 'damir'}];
DS.expectFindAll('user')
.respond(expected);
UserService.enableAutoRefresh(refreshPeriod);
$rootScope.$on('usersRefreshed', function(event, actual) {
expect(actual).toEqual(expected);
done();
});
$timeout.flush(refreshPeriod);
DS.flush();
});
});
});
I'm also taking advantage of AngularJS's mocked $timeout
implementation with flush()
method to trigger timed events from the test code independently of passed time.
Testing synchronous methods
Not all JSData methods are asynchronous. Those that only work with local data and don't rely on external data sources, don't need to be. They are also mocked with Sinon.JS framework, although none of them is used in the sample tests. As expected, testing the synchronous methods is even simpler, once you now how:
describe('getCachedUsers', function() {
it('returns users without asynchronous calls', function() {
var expected = [{id: 1, username: 'damir'}];
DS.getAll.withArgs('user')
.returns(expected);
var actual = UserService.getCachedUsers();
expect(actual).toEqual(expected);
});
});
For more information, refer to Sinon.JS stub documentation. All JSData synchronous methods are actually implemented as stubs.
Testing Model Lifecycle Hooks and Events
Since js-data-mocks
AngularJS module replaces all synchronous and asynchronous methods with their mocked counterparts, neither model lifecycle hooks nor events are executed. To test those, the mocking module must not be loaded. This will prevent you from calling any methods retrieving data from external resources, but you will be able to test events and hooks by directly calling the methods that trigger them:
describe('events', function() {
var User;
var $rootScope;
beforeEach(function() {
module('app');
});
beforeEach(function () {
inject(function (_User_, _$rootScope_) {
User = _User_;
$rootScope = _$rootScope_;
})
});
it('are only called when methods are not mocked', function() {
User.on('DS.afterInject', function(resource, user) {
user.processed = true;
});
var user = User.inject({id: 1, username: 'damir'});
expect(user.processed).toBe(true);
});
});
In the worst case, you might need to refactor the code a bit in order to test some of the events, but it's nothing that you couldn't work your away around. There's also no need to have separate test files for tests requiring mocked JSData and those requiring the real one. You just need to set the describe
scopes correctly:
describe('UserService', function() {
beforeEach(function() {
module('app');
});
describe('mocked JSData', function() {
var UserService;
var DS;
var $timeout;
var $rootScope;
beforeEach(function () {
module('js-data-mocks');
});
beforeEach(function () {
inject(function (_UserService_, _DS_, _$timeout_, _$rootScope_) {
UserService = _UserService_;
DS = _DS_;
$timeout = _$timeout_;
$rootScope = _$rootScope_;
})
});
// tests that require mocking
});
describe('real JSData', function() {
var User;
var $rootScope;
beforeEach(function () {
inject(function (_User_, _$rootScope_) {
User = _User_;
$rootScope = _$rootScope_;
})
});
// tests that work without mocking
});
});
Happy unit testing!