By Andrey Zhilinsky
Any programmer using or starting to use AngularJS for unit testing knows there is an apparent lack of reliable step-by-step resources to help them on their daily journey. When initially working with the framework, many programmers have to start by consulting independent sources and looking for ways to solve issues using dispersed internet resources. Finding detailed instructions on how to do a certain task on a real project seemingly don’t exist. Of course, there are open forums where developers talk about common problems and possible solutions, but they are basic guidelines at best. When you encounter issues that require in-depth analysis of available solutions, you are bound to waste time doing the research yourself.
This is why some of the conclusions made from direct experience are shared in the following article. This guide is different from others because it is based on a real project developed for a contemporary market and relies on the latest version of AngularJS. If you are using AngularJS, or plan to start in the near future, please read on.
Technologies Used
The stack of technologies is the following: The Web application itself is written in AngularJS, the testing runner is Karma, and the testing framework is Jasmine. They were chosen for the project as the most widely used and popular frameworks, making them reliable and easier to work with. Another benefit is their constant evolution and development, making each a flexible solution for changing times.
Mocking of Service Methods
The most usable way to mock a service method is by using spyOn. You need to inject the service to mock and define what the mock should do. It can call through to the real function or call the defined fake method.
var API; beforeEach(inject(function (_API_) { API = _API_; spyOn(API, 'getPreview').and.callFake(function () { return 'fake response'; }); }));
The spy could be tested by the following methods:
expect(API.getPreview).toHaveBeenCalledWith('test param');
or
expect(API.getPreview).toHaveBeenCalled();
Mocking of Read-only Properties
This method is very helpful to mock properties of navigator, window, and document objects, and also DOM elements.
function setUserAgent (value) { Object.defineProperty(navigator, 'userAgent', { value: value, writable: true }); }
or
function setHeihgt (offsetHeight) { Object.defineProperty(element[0], 'offsetHeight', { value: offsetHeight, writable: true }); }
Mocking $document
Working with a real $document is not a good idea because there are Karma scripts in the document’s body, as well as other essential runner content, which you can damage during the test. You have to be very careful and clean up after each test. A better way is to create an empty document and perform testing on it.
beforeEach(function () { module(function ($provide) { var doc = document. implementation.createHTMLDocument(); $provide.value('$document', angular.element(doc)); }); });
Mocking $window
Angular’s $window object is simply a reference to the global window object. The goal is easy mocking of the window object.
beforeEach(function () { module(function ($provide) { $provide.value('$window', {location: {search: '?pid=123&lang=en'}}); }); });
Mocking Method Factories
You need to use jasmine.createSpy() to mock the function. The callThrough() and callFake() could be used in the same way as in spyOn().
beforeEach(function () { module(function ($provide) { $provide.value('$uiViewScroll', jasmine.createSpy()); }); });
Mocking Filter
The filter could be mocked in the same way as method factories. Note that you need to add ‘Filter’ to the name.
beforeEach(function () { module(function ($provide) { var spy = jasmine.createSpy().and. callFake(function (text) { return text; }); $provide.value('someFilter', spy); }); });
Mocking Modal Dialog
We use btfModal in this project for modal dialogs. The following code shows how to mock and test it.
beforeEach(function () { active = false; activate = jasmine.createSpy(); deactivate = jasmine.createSpy(); mockModal = function () { return { activate: activate, deactivate: deactivate, active: function () { return active; } }; }; module(function ($provide) { $provide.value('btfModal', mockModal); }); }); expect(activate).toHaveBeenCalled(); expect(deactivate).toHaveBeenCalled();
Mock Whole Directive
Sometimes, to test a parent directive you need to mock the child one. You can replace the directive definition in the module.
beforeEach(function () { angular.module("lorw").directive("loader", function() { return { priority: 100000, terminal: true, link: function() {} }; }); });
Using describe()
Nested describe() methods are very useful. For instance, you can use them to test specific parameters.
describe('Config service', function () { function ParamMock(details) { return _.extend(_.transform(['pid', 'lang', 'fw', 'mode'], function(result, param) { result[param] = _.constant(); }), details); } beforeEach(module('lorw')); it('should have isAdvanced method defined', inject(function (Config) { expect(Config.isAdvanced).toBeDefined(); })); describe('advanced mode', function () { beforeEach(module(function ($provide) { $provide.value('param', new ParamMock({mode: _.constant('a')})); })); it('should identify when we are in advanced mode', inject(function (Config) { expect(Config.isAdvanced()).toBeTruthy(); })); }); });
Asynchronous Test
First, you need to mock the async method call. Pay attention to the fact that you can simulate a promise rejection.
var $q, $timeout; var HTTPConnector; var fakeResponse = { success: true, note: 'test file' }; var fakeError; beforeEach(inject(function (_$q_, _$timeout_, _HTTPConnector_) { $q = _$q_; $timeout = _$timeout_; HTTPConnector = _HTTPConnector_; spyOn(HTTPConnector, 'sendRequest').and. callFake(function () { return promised(fakeResponse, fakeError); }); })); function promised(data, error) { return $q(function (resolve, reject) { $timeout(function () { if (error) { reject(error); } else { resolve(data); } }, 0); }); }
Second, the done argument should be included to the method. The done() function is passed to it(), beforeEach(), and afterEach().
Call it after all processing is complete.
Note that you need to call $timeout.flush() all pending tasks.
it('should return API call results', function (done) { inject(function (APIConnector) { var testResponse = { err: '', results: 'test response' }; fakeResponse = { data: testResponse }; APIConnector.sendRequest('method', 'data'). then(function (response) { expect(response).toBe(testResponse); }, function (error) { expect(error).toBe('this should never be called'); }); $timeout.flush(); done(); }); });
Test Directives
You need to inject $compile service to render the directive. Also, you need $rootScope to create the directive’s scope.
Please remember that you need to initiate a digest cycle after directive rendering. Also, you need to do that after directive’s scope change.
var $compile, $rootScope; var scope, element; beforeEach(inject(function (_$compile_, _$rootScope_) { $compile = _$compile_; $rootScope = _$rootScope_; scope = $rootScope.$new(); element = $compile('<span file-status= "status"></span>')(scope); scope.$digest(); )); it('R should be red ', function () { scope.status = 'R'; scope.$apply(); expect(element.hasClass('red')).toBeTruthy(); });
Using Events
Use the triggerHandler method to simulate an event.
element = $compile('<combobox upordown></combobox>')(scope); ... element.triggerHandler('mousedown');
It is a regular task when you need to simulate an event and check how it was processed.
var keyDown = { type: 'keydown', which: 27, preventDefault: jasmine.createSpy() }; it('should handle keydown event for escape', function () { element.triggerHandler(keyDown); expect(keyDown.preventDefault).toHaveBeenCalled(); });
Another common test is checking if the handler is bound to the event.
beforeEach(inject(function (_$compile_, _$rootScope_) { $compile = _$compile_; $rootScope = _$rootScope_; scope = $rootScope.$new(); element = $compile('<div draggable> <div class="modal-header"></div></div>')(scope); scope.$digest(); }); beforeEach(function () { spyOn($document, 'bind').and.callThrough(); }); it('should start handle mousemove event on mouse down', function () { header.triggerHandler(mouseDown); expect($document.bind).toHaveBeenCalledWith('mousemove', jasmine.any(Function)); });
Working with Angular Events
You can simply use $broadcast or $emit to fire an event. The only peculiarity is that you need to start the $digest cycle by calling $apply() of the element’s scope or $rootScope.
it('should show / hide alert with specified message', function () { $rootScope.$broadcast('success', 'test message'); element.isolateScope().$apply(); expect(element.isolateScope().status).toBe('test message'); });
Testing Web Forms
To test form validation, you need to set a control value. You can do that by using $setViewValue.
beforeEach(function () { scope = $rootScope.$new(); element = $compile('<form name="testForm"><input name="testInput" test-validate ng-model="text"></textarea></form>')(scope); }); ... it('should validate', function () { scope.testForm.testInput.$setViewValue('1234567890123'); ... });
Static Web Site Content
Sometimes, you just need to skip loading the resources. To do that, you can use the following code:
beforeEach(inject(function (_$httpBackend_) { $httpBackend = _$httpBackend_; $httpBackend.whenGET("i18n/en.json").respond({}); $httpBackend.expectGET("i18n/en.json"); }));
As for static content such as images, add the following to the karma.conf.js:
{pattern: path.join(conf.paths.src, 'img/**/*.png'), watched: false, included: false, served: true}
Also, you need to add the proxies option. Note that you need to append Karma’s base path “/base/src” to the static files path.
proxies : { '/img/': '/base/src/img/' }
Conclusion
AngularJS was created with easy testing features in mind; therefore, apps written in the framework are intrinsically simple to test. Adding Karma and Jasmine to the mix allows you to effortlessly reach 100 percent unit test coverage of a frontend app of any complexity. Hopefully, this short guide helps you achieve that in your day-to-day programming job.
About the Author
Andrey Zhilinsky is a senior developer at Itransition, an international software development and systems integration company, where he is responsible for projects focused on automation in small and midsized enterprises, mobile in automation, and generation of dynamic content and Web forms using ASP.NET MVC, Entity Framework, and AngularJS. He graduated from Belarusian State University with a degree in economic cybernetics and worked in a division of the National Bank before programming for clients in Germany, the U.K., the U.S.A., and Canada. For more information, please visit http://www.itransition.com/.
This article was contributed for exclusive use on Developer.com.