Unit Test AngularJS Controller With Jasmine
Last time I demonstrated how to Consume RESTful API With Angular-HAL, and now I want to show you how to unit test my AngularJS controller code withJasmine, a behavior-driven development framework for testing JavaScript code.
BDD and Jasmine
I’m a big fan of Test-Driven Development (TDD). And I always want to write unit test for my code. But not everyone agrees with me. There is a cost to write unit tests, and the benefit is in the long term, so it is not easy to convince others, especially managers or clients, that time spent on unit test will improve software quality and save money in the future. It is harder when developers write unit tests which don’t make sense. Sometime I even got frustrated when I tried to write unit test code for the sake of unit testing.
So what exactly shall I test? How shall I write unit tests? Dan North introduced Behavior-driven development, and he answered lots of my questions regarding the unit test. So what is Behavior-Driven Development?
Behavior-driven development is about implementing an application by describing its behavior from the perspective of its stakeholders.
My understanding is, “BDD is TDD done right”. When we try to unit test a component, the first thing to think is how we want this component to behave, which is under different conditions, we expect it to do something or not to do something, to be in certain state or not to be in certain state.
There are many JavaScript frameworks for unit test, like QUint, Mocha, and Jasmine. The reason I choose Jasmine is the AngularJS example project angular-seed uses Jasmine for unit test. And I love it right after I figured out how to use it.
With Jasmine, we first describe
our test suite, and then use it
function to give our test case specification. In the spec, we expect
the behaviors of our testing target. We also use beforeEach
and afterEach
to setup or tear down test environment for our spec.
Test Simple Controller
The best way to learn is to practice. Let’s start with a very simple AngularJS controller, for my About page.
'use strict'; angular.module('jiwhizWeb').controller('AboutController', ['$rootScope', '$scope', function($rootScope, $scope) { $rootScope.activeMenu = { 'home' : '', 'blog' : '', 'about' : 'active', 'contact' : '', 'admin' : '' }; $rootScope.showTitle = true; $rootScope.page_title = 'About Me'; $rootScope.page_description = 'Here is my story.'; } ]);
This is the simplest page in my blog application, and no complex logic in the controller code. It only sets the active menu item, shows the title and sets page title with description.
The unit test for this controller will be also simple. We expect the page to do exactly what we designed, which are to set the “About” menu item as active, to show the title, and to have correct page title and description.
'use strict'; describe('Controller: public/AboutController', function() { var $rootScope, $scope, $controller; beforeEach(module('jiwhizWeb')); beforeEach(inject(function(_$rootScope_, _$controller_){ $rootScope = _$rootScope_; $scope = $rootScope.$new(); $controller = _$controller_; $controller('AboutController', {'$rootScope' : $rootScope, '$scope': $scope}); })); it('should make about menu item active.', function() { expect($rootScope.activeMenu.about == 'active'); }); it('should show title.', function() { expect($rootScope.showTitle == true); }); it('should have correct page title.', function() { expect($rootScope.page_title).toEqual('About Me'); }); it('should have correct page description.', function() { expect($rootScope.page_description).toEqual('Here is my story.'); }); });
In unit test code, we call Jasmine function describe
for our test suite, and we pass the suite title string and function of our test suite. And inside this suite function, we call Jasmine function it
for our test specs, and we also pass the spec title string and spec function. Inside our spec functions, we callexpect
function with actual value, which is chained with a Matcher function, which takes the expected value. Here I used toEqual
matcher function. See Jasmine Documentation for more matcher functions and how to define your custom matcher functions.
The first beforeEach
is to load our application module – jiwhizWeb
. And the second beforeEach
is to inject dependencies from AngularJS, like $rootScope
. Since we want to use same variable name"$rootScope"
in our suite function, and let $rootScope
be used by our spec functions, we have to apply a trick, that is to use underscore to wrap the in-coming parameter _$rootScope_
, and AngularJS Mock will correctly resolve it to the reference to actual $rootScope
. We can use it to create our suite variable $scope
by $scope = $rootScope.$new();
. Last, we use $controller
to initialize our AboutController
and inject all dependencies.
Test Controller with Promise
OK, the AboutController
is too simple. Let’s try another more complicated controller,BlogListController
, which uses promise to Consume RESTful API With Angular-HAL.
'use strict'; angular.module('jiwhizWeb').controller('BlogListController', ['$rootScope', '$scope', '$timeout', 'WebsiteService', function($rootScope, $scope, $timeout, WebsiteService) { $rootScope.activeMenu = { 'home' : '', 'blog' : 'active', 'about' : '', 'contact' : '', 'admin' : '' }; $rootScope.showTitle = true; $rootScope.page_title = 'My Personal Blog'; $rootScope.page_description = 'Some of my thoughts and experiences.'; var setup = function( pageNumber ) { WebsiteService.load() .then( function( websiteResource ) { return websiteResource.$get('blogs', {'page': pageNumber, 'size':10, 'sort':null}); }) .then( function( resource ) { $scope.page = resource.page; $scope.page.currentPage = $scope.page.number + 1; return resource.$get('blogPostList'); }) .then( function( blogPostList ) { $scope.blogs = blogPostList; blogPostList.forEach( function( blog ) { blog.contentFirstParagraph = getFirstSection(blog.content); // load author profile blog.$get('author').then(function(author) { blog.author = author; }); }); }) ; }; setup(0); $scope.selectBlogPage = function(pageNumber) { setup(pageNumber-1); //Spring HATEOAS page starts with 0 }; } ]);
Here we have setup
function to load list of blogs for specific page, and load each blog author. The challenge is how to test those chain of promises, which are asynchronous JavaScript operations. By searching Stack Overflow and reading other developers’ blog, I found the way to use AngularJS$q.defer()
to resolve the promise in unit test.
'use strict'; describe('Controller: public/BlogListController', function() { var $rootScope, $scope; var $controller, service; var mockWebsite = { $get: function(rel) {} }; var mockResource = { page: { size: 10, totalElements: 100, totalPages: 4, number: 0 }, $get: function(rel) {} }; var mockBlogPostList = [ { title: 'Test Blog', content: '<p>This is first paragraph.</p> Other parts...', $get: function(rel) {} }, { title: 'Another Blog', content: '<p>I came second.</p>', $get: function(rel) {} } ]; var mockAuthor = { displayName: 'author' }; beforeEach(module('jiwhizWeb')); beforeEach(inject(function(_$rootScope_, _$controller_, _$q_, _WebsiteService_) { $rootScope = _$rootScope_; $scope = $rootScope.$new(); $controller = _$controller_; service = _WebsiteService_; var websiteDeferred = _$q_.defer(); websiteDeferred.resolve(mockWebsite); spyOn(service, 'load').andReturn(websiteDeferred.promise); var blogsDeferred = _$q_.defer(); blogsDeferred.resolve(mockResource); spyOn(mockWebsite, '$get').andReturn(blogsDeferred.promise); var blogListDeferred = _$q_.defer(); blogListDeferred.resolve(mockBlogPostList); spyOn(mockResource, '$get').andReturn(blogListDeferred.promise); var authorDeferred = _$q_.defer(); authorDeferred.resolve(mockAuthor); spyOn(mockBlogPostList[0], '$get').andReturn(authorDeferred.promise); spyOn(mockBlogPostList[1], '$get').andReturn(authorDeferred.promise); $controller('BlogListController', {'$rootScope' : $rootScope, '$scope': $scope, 'WebsiteService': service}); $rootScope.$apply(); // promises are resolved/dispatched only on next $digest cycle })); it('should make Blog menu item active.', function() { expect($rootScope.activeMenu.blog == 'active'); }); it('should have selectBlogPage() function.', function() { expect($scope.selectBlogPage).toBeDefined(); }); describe('BlogListController setup(pageNumber) function', function() { it('should have currentPage set to 1.', function() { expect($scope.page.currentPage).toBe(1); }); it('should have two blogs and first one is Test Blog.', function() { expect($scope.blogs.length).toEqual(2); expect($scope.blogs[0].title).toEqual('Test Blog'); }); it('should have first blog with title "Test Blog" and author "author"', function() { expect($scope.blogs[0].title).toEqual('Test Blog'); expect($scope.blogs[0].author.displayName).toEqual('author'); }); it('should have second blog with title "Another Blog" and author "author"', function() { expect($scope.blogs[1].title).toEqual('Another Blog'); expect($scope.blogs[1].author.displayName).toEqual('author'); }); }); });
The trick is to use AngularJS deferred object by calling $q.deferred()
, and use deferred APIresolve(value)
to return our mock resources. Then use Jasmine spyOn()
function to stub our resource function and return promise object associated with the deferred object. After we setup all the mock up resources along the chain of promises, and initialize BlogListController
, we have to do a final step $rootScope.$apply();
to trigger AngularJS $digest cycle programmatically, so our promises can be resolved.
Conclusion
Writing unit test is not easy. But it is so important, because writing unit test can actually make you think hard to write clean and elegant code.
With behavior-driven development, we can describe our test cases in a more understandable way, and it helps better communication between developers and business. Business can tell user stories, and developer can translate those stories into unit test, so when requirements change, we can easily find out what part of code we need to change as well.
My experience with BDD and Jasmine is very pleasant, and later I will explore more about end to end test with Protractor.
Reference: | Unit Test AngularJS Controller With Jasmine from our WCG partner Yuan Ji at the Jiwhiz blog. |