On my Ecommerce MEAN project, I have this AngularJs controller that calls a Catalog
service which in turn returns a $resource
promise object:
'use strict';
angular.module('pinkShop')
.controller('CategoryListCtrl', function ($scope, Catalog, $TreeDnDConvert) {
$scope.categories = {};
$scope.loadTable = function () {
return Catalog.query().$promise.then(function (categories) {
//display the returned data
$scope.categories = $TreeDnDConvert.line2tree(categories, '_id', 'parent');
return categories;
});
};
});
Inside loadTable
function, Catalog.query()
asynchronously calls a remote API and returns a $promise
object.
Once $promise
is fulfilled, a JSON data will be available for the controller’s consumption and to be assigned to the $scope.categories
.
JSON data structure once async call is completed:
[
{ '_id': '580c6648d8d62233f4d92db4', 'name': 'All', '__v': 4, 'slug': 'all', 'children': ['580c6648d8d62233f4d92db5', '580c6648d8d62233f4d92db6', '580c6648d8d62233f4d92db7', '580c6648d8d62233f4d92db8'], 'ancestors': [] },
{ '_id': '580c6648d8d62233f4d92db5', 'name': 'Home', 'parent': '580c6648d8d62233f4d92db4', '__v': 0, 'slug': 'home', 'children': [], 'ancestors': ['580c6648d8d62233f4d92db4'] },
{ '_id': '580c6648d8d62233f4d92db8', 'name': 'Hunting', 'parent': '580c6648d8d62233f4d92db4', '__v': 0, 'slug': 'hunting', 'children': [], 'ancestors': ['580c6648d8d62233f4d92db4'] }
]
I want to write a unit test that will do some assertions when loadTable
function is called. The test should verify that $scope.categories
is populated with JSON data.
Also the test should check if $TreeDnDConvert.line2tree
is called at least once.
Since loadTable
is returning an asynchronous call, this can be a little bit tricky to unit test.
The solution is to mock Catalog
service on my unit test environment.
Also the mocked service should mimick the real async call.
For unit tests, I used $q for running asynchronous calls and SinonJs for mocking the Catalog
service.
I start with the unit test code below:
describe('Controller: CategoryCtrl', function () {
var CategoryListCtrl, scope, deferred, TreeDnDConvert, returnedResource,
categories = [
{ '_id': '580c6648d8d62233f4d92db4', 'name': 'All', '__v': 4, 'slug': 'all', 'children': ['580c6648d8d62233f4d92db5', '580c6648d8d62233f4d92db6', '580c6648d8d62233f4d92db7', '580c6648d8d62233f4d92db8'], 'ancestors': [] },
{ '_id': '580c6648d8d62233f4d92db5', 'name': 'Home', 'parent': '580c6648d8d62233f4d92db4', '__v': 0, 'slug': 'home', 'children': [], 'ancestors': ['580c6648d8d62233f4d92db4'] },
{ '_id': '580c6648d8d62233f4d92db8', 'name': 'Hunting', 'parent': '580c6648d8d62233f4d92db4', '__v': 0, 'slug': 'hunting', 'children': [], 'ancestors': ['580c6648d8d62233f4d92db4'] }
];
// load the controller's module
beforeEach(module('pinkShop'));
beforeEach(inject(function ($rootScope, _$q_) {
scope = $rootScope.$new();
deferred = _$q_.defer();
returnedResource = {
$promise: deferred.promise
};
}));
});
As you see, I’m loading $q on the beginning of the test and assigned it’s defer()
function result to a global variable ( deferred
). Next is to attached the deferred.promise
object inside
returnedResource
$promise property. This will be used later to mimic the Catalog.query()
result.
Next is to write the actual controller’s unit test:
describe('Controller: CategoryCtrl', function () {
......more code before here
describe('CategoryListCtrl', function () {
beforeEach(inject(function ($controller, _$TreeDnDConvert_) {
//loading the controller's actual dependency service
TreeDnDConvert = _$TreeDnDConvert_;
//mocking the Catalog service
var mockedCatalog = sinon.stub({
query: function () { }
});
//start spying on TreeDnDConvert service
sinon.spy(TreeDnDConvert, 'line2tree');
}));
//some clean-up process after every test invocation
afterEach(function () {
TreeDnDConvert.line2tree.restore();
scope.categories = {};
});
});
});
Now the fun begins! If you notice, I’m loading the actual $TreeDnDConvert
service and start spying on it using sinon.spy
.
On the other hand, I’m not loading the real Catalog
service but instead mocking it using sinon.stub
.
Also, I’m assigning a query
function to the mocked version so it’ll look like Catalog.query()
.
Remember that calling Catalog.query()
will return a $promise object, right? Next is to recreate that same behavior on my test.
describe('Controller: CategoryCtrl', function () {
......more code before here
describe('CategoryListCtrl', function () {
beforeEach(inject(function ($controller, _$TreeDnDConvert_) {
... more code before here
//create controller
CategoryListCtrl = $controller('CategoryListCtrl', {
$scope: scope,
Catalog: mockedCatalog, //inject the mocked service
$TreeDnDConvert: TreeDnDConvert
});
//set the mocked Catalog service to return a promise
mockedCatalog.query.returns(returnedResource);
//call the function under test
scope.loadTable();
//resolve the mock Catalog service query call with hard coded data
deferred.resolve(categories);
//manually call $digest
scope.$digest();
}));
//some clean-up process after every test invocation
afterEach(function () {
TreeDnDConvert.line2tree.restore();
scope.categories = {};
});
//test assertions will be here
});
});
This is the interesting part! Let me break it down:
First I recreated the CategoryListCtrl
using AngularJs $controller
. Also, it’s important to mention that I’m injecting the mockedCatalog
to the controller’s dependency instead of the real Catalog
service.
Next is to set the mockedCatalog
service to return the $promise
I created earlier. Checkout returnedResource
from the beginning.
Call the actual loadTable
function to fill the data on the scope. Going back to the actual controller, I’m expecting that calling loadTable
will assign returned JSON data to the $scope.categories
.
Remember I’m mimicking an async call here ( Catalog.query().$promise
) however this won’t get resolved till I manually call deferred.resolve()
.
To return the hard coded test data, I need to call deferred.resolve(categories)
. The variable categories
represents the actual JSON data returned from the API call.
Lastly, call AngularJs $digest
to kick off the watchers on the scope thus detecting any changes to the scope.categories
. Therefore, if my unit test asserts that scope.categories
has data
at this point, I can conclude that the controller is behaving as expected and test should pass.
With that in mind, I can now write my test assertions. Awesome!
it('should call TreeDnDConvert.line2tree', function () {
assert(TreeDnDConvert.line2tree.calledOnce);
});
it('should get all the categories', function () {
expect(angular.equals({}, scope.categories)).to.equal(false);
expect(scope.categories[0].name.toLowerCase()).to.equal('all');
});
'use strict';
describe('Controller: CategoryCtrl', function () {
var CategoryListCtrl, scope, deferred, TreeDnDConvert, returnedResource,
categories = [
{ '_id': '580c6648d8d62233f4d92db4', 'name': 'All', '__v': 4, 'slug': 'all', 'children': ['580c6648d8d62233f4d92db5', '580c6648d8d62233f4d92db6', '580c6648d8d62233f4d92db7', '580c6648d8d62233f4d92db8'], 'ancestors': [] },
{ '_id': '580c6648d8d62233f4d92db5', 'name': 'Home', 'parent': '580c6648d8d62233f4d92db4', '__v': 0, 'slug': 'home', 'children': [], 'ancestors': ['580c6648d8d62233f4d92db4'] },
{ '_id': '580c6648d8d62233f4d92db8', 'name': 'Hunting', 'parent': '580c6648d8d62233f4d92db4', '__v': 0, 'slug': 'hunting', 'children': [], 'ancestors': ['580c6648d8d62233f4d92db4'] }
];
// load the controller's module
beforeEach(module('pinkShop'));
beforeEach(inject(function ($rootScope, _$q_) {
scope = $rootScope.$new();
deferred = _$q_.defer();
returnedResource = {
$promise: deferred.promise
};
}));
describe('CategoryListCtrl', function () {
beforeEach(inject(function ($controller, _$TreeDnDConvert_) {
//loading the controller's actual dependency service
TreeDnDConvert = _$TreeDnDConvert_;
//mocking the Catalog service
var mockedCatalog = sinon.stub({
query: function () { }
});
//start spying on TreeDnDConvert service
sinon.spy(TreeDnDConvert, 'line2tree');
//create controller
CategoryListCtrl = $controller('CategoryListCtrl', {
$scope: scope,
Catalog: mockedCatalog, //inject the mocked service
$TreeDnDConvert: TreeDnDConvert
});
//set the mocked Catalog service to return a promise
mockedCatalog.query.returns(returnedResource);
//call the function under test
scope.loadTable();
//resolve the mock Catalog service query call with hard coded data
deferred.resolve(categories);
//manually call $digest
scope.$digest();
}));
//some clean-up process after every test invocation
afterEach(function () {
TreeDnDConvert.line2tree.restore();
scope.categories = {};
});
it('should call TreeDnDConvert.line2tree', function () {
assert(TreeDnDConvert.line2tree.calledOnce);
});
it('should get all the categories', function () {
expect(angular.equals({}, scope.categories)).to.equal(false);
expect(scope.categories[0].name.toLowerCase()).to.equal('all');
});
});
});
Happy unit testing! :)