Table of contents

< Previous chapter

Last updated .

Unit testing

With its modular approach and reliance on dependency injection, Angular was built from the ground up with unit testing in mind. And coding in javascript means you are going to get no help from a compiler. Finding an excuse for not unit testing has become hard.

The use of dependency injection implies that a component's dependencies can be mocked, so the component can be tested in isolation. This makes the test more focused, less error prone and easier to maintain. For example, an XHR request, can be simulated using mocking. Angular abstracts the DOM away, so you can test a component without having to manipulate the DOM directly.

Setting up the test bench

In chapter one of this series I promised that I wouldn't require installation of any 3rd party tools or frameworks. Well, with unit testing, I am afraid there is no way around it. A testing framework is required and so is a runtime environment to actually run a test. Various options are available. A popular choice is to use a testing framework called Jasmine, and a test runner called Karma. Karma requires Node.js, which is why I installed that first. It's really easy to install - just leave options at their default in the install wizard. Don't worry if you haven't a clue about node.js; we'll just use its command prompt.

Once Node.js is installed, Karma can be installed. Karma is a command-line tool, which loads the javascript files in the project, starts a web browser instance to run tests and displays the test results in the console. To install it, start the Node.js command prompt and change the directory to where your project is located. I have mine at C:\Projects\AngularProject\AngularProject; your's will be different, obviously:

CD to project directory
CD to project directory

Then, in the command prompt, type npm install -g karma. This will download and install Karma.

Install Karma
Install Karma

Now, Karma has to be configured for the project. The configuration process ends up in a karma.config.js configuration file being added to the current directory. It is just a json file that you can edit later by hand, if need be. To start configuration, type karma init karma.config.js in the command prompt. Now, you will be asked a series of questions. If you fuck it up on the way, you can close the command prompt and start over. This is how I replied to the first three questions (for the third one, select your preferred browser and in the next line press Enter to proceed):

First three steps in configuration
First three steps in configuration

In the next step you tell Karma where your javascript files are located. First, you must give the paths to angular.js and angular-mocks.js. The paths are relative to the current directory. For the following, I am going to assume you keep your javascript files in an App folder which is a child of the project directory (the current directory). The paths I entered (see below) will make Karma find *.js and *test.js files in the App folder or in any descendant folder thereof. In the final step, be sure to answer "yes" to have Karma watch changes to files and then automatically start a test. Close the console by pressing ctrl+c.

Final steps in configuration
Final steps in configuration

Writing tests with Jasmine

Now, everything is ready to run some tests, except we have no test to run. So let's write some, starting with the simplest one possible. A test is basically a comparison of an expected value or result with a real value or result. Jasmine offers a number of "matchers" to perform various kinds of comparisons, for example toEqual, toBeNull, toThrow. A matcher is then used in combination with an expect function, for example expect(someValue).toEqual("foobar"). You will recognize that pattern if you've ever done unit testing before.

One or a few expect statements are grouped and documented by wrapping them in a function with the odd name it. By the same principle, a series of it statements are grouped in a descibe wrapper, like this:

App/some.test.js describe("example test", function () { it("should be true", function () { expect("foo").toBe("foo"); }); });

If you've got Karma running, it should pick up the change once you save the above test script to a file in the App folder. Otherwise, start it by opening a Node.js command window, navigate to the project folder and type karma start karma.config.js. The program spawns a web browser and runs the test. The expected result is this:

The first green test
The first green test

Tests sometimes fail. To see that happening, change the above code to expect "foo" to equal "bar". Now, Karma picks up the change when the file is saved and runs the test again, now with a more red result:

A failed test
A failed test

As you can see, Karma reports what the problem is and which file it is about.

New stuff

"match" function: An individual test is performed by calling a Jasmine "match" function, such as toBe or toBeDefined together with the expect function.

it function: Tests of an individual item, such as a single property, are grouped and documented with a call of the it function.

describe function: Tests of an individual feature are grouped and documented with a call of the describe function, and so is the whole test.

Testing an Angular controller

To write the first test of something Angular I started by deleting all the script files in the App folder. Then I added a file creating an Angular module and another file with a script to create a controller:

App/app.js ( function () { angular.module("example", []); } )();

There is no need to create a html page, since it will not be involved in a test anyway. The controller simply contains a password property and is able to evaluate the strength of the password with a simplistic algorithm:

App/PasswordController.js ( function () { function PasswordController(logger) { var vm = this; vm.password = "sa"; vm.strength = 1; vm.setPassword = function (password) { logger.log("Password changed"); vm.password = password; if (password.length > 8) vm.strength = 3; else if (password.length > 3) vm.strength = 2; else vm.strength = 1; }; } angular.module("example").controller("passwordController", ["$log", PasswordController]); } )();

And now for the test. I added a javascript file to contain the tests of this controller. It would be relevant to test that the controller can be created, that a password can be set and that it grades passwords correctly:

App/PasswordController.test.js ( function () { describe("passwordController", function () { // Create the root module beforeEach(angular.mock.module("example")); var passwordController; var scope; // Inject $controllerProvider, $rootScopeProvider and $log // and create the controller beforeEach(angular.mock.inject(function ($controller, $rootScope, $log) { scope = $rootScope.$new(); var locals = { $scope: scope, }; var bindingsParams = { $log: $log, }; passwordController = $controller("passwordController as vm", locals, bindingsParams); })); describe("When it is constructed", function () { it("Can be constructed", function () { expect(passwordController).toBeDefined(); expect(passwordController).not.toBeNull(); }); it("Can initialize properties", function () { expect(passwordController.password).toBe("sa"); expect(passwordController.strength).toBe(1); }); }); describe("Grade", function () { it("Can set password", function () { passwordController.setPassword("123456789"); expect(passwordController.password).toBe("123456789"); }); it("Can grade strong password", function () { passwordController.setPassword("123456789"); expect(passwordController.strength).toBe(3); }); }); }); } )();

To digest what goes on in this somewhat lengthy script, first notice that the whole script is wrapped in a describe block and so are the tests themselves. The test code is preceded by some initialization. In order to test a controller, it must first be created. And to create a controller, a scope and a module must be created, because that is what a controller depends on. A beforeEach statement is a Jasmine construct which is run prior to each test (prior to each it statement, to be precise). We can then use that to run initialization code that must be run before a test. In this case, the module is first created.

After that, the controller is instantiated in a separate beforeEach statement. A controller is created by calling the Angular $controller provider and a scope is created by using the $rootScope provider. These providers are injected by calling the angular.mock.inject function. In addition, since this particular controller depends on the Angular $log service, it is also injected and relayed to the controller in the third parameter in the call to $controller: the bindingsParams object. Notice also the syntax for creating the controller in this example: it is defined with the "controllerAs" syntax.

The tests themselves are nothing interesting, except that they demonstrate various Jasmine "matcher" functions. The four tests are run by Karma, resulting in this report:

The result of testing the controller
The result of testing the controller

New stuff

beforeEach function: The Jasmine beforeEach function is called before each test (i.e. before each call of the it function), and is the place to put test initialization code.

angular.mock.module function: The module function is used to create an Angular module object as part of the test initialization.

angular.mock.inject function: The inject function is used for injecting Angular or custom services into a function as arguments.

$rootScope.$new function: This function creates a scope object.

Testing a directive

Most directives are quite a bit more complex than controllers, and so is the testing of directives. A directive is declared in the markup as an element or an element attribute. When Angular boots it "compiles" the DOM, meaning it runs through all the DOM nodes and instantiates controllers, directives, etc from the tags and attributes found. To prepare a test for a directive, then, is to simulate this process by having Angular "compile" a piece of html, thus instantiating the directive object.

I need a directive to test on, and as an example of testing a directive, I looked back to a directive from chapter 12, which I'll repeat here, in slightly moderated form:

App/IsolatedDirective.js ( function () { var app = angular.module("example"); function IsolatedDirective() { return { scope: { callbackFunc: "&" }, template: "<button ng-click='clickHandler( { event: $event } )'>Click me!</button>", controller: function ($scope) { $scope.clickHandler = function (ev) { if ($scope.callbackFunc) $scope.callbackFunc({ param: ev.event, msg: "Hi from directive" }); }; }, }; } app.directive("isolated", [IsolatedDirective]); } )();

As you no doubt recall, the point of making this directive was to experiment with the { & } isolated scope, whereby a consumer of the directive can pass on a callback function to the directive. In this example, that callback is then called when a button is clicked. It would be relevant to test that this callback mechanism is working and the correct parameters passed. I have set up this test suite for the directive:

App/IsolatedDirective.test.js ( function () { // Let Angular compile html to an element // and instantiate directive function getCompiledElement(compiler, scope, html) { var elm = angular.element(html); var compiledElement = compiler(elm)(scope); scope.$digest(); return compiledElement; } describe("myDirective", function () { // Create the root module beforeEach(angular.mock.module("example")); var element, scope, controller; beforeEach(angular.mock.inject(function ($compile, $rootScope) { // Instantiate directive and get html element element = getCompiledElement($compile, $rootScope.$new(), "<div isolated></div>"); // Grab controller instance controller = element.controller("isolated"); // Grab scope. Depends on type of scope. // See angular.element documentation. scope = element.isolateScope() || element.scope(); })); it("must have controller", function () { expect(controller).toBeDefined(); expect(controller).not.toBeNull(); }); it("Element contents must match", function () { expect(element.html()).toBe('<button ng-click="clickHandler( { event: $event } )">Click me!</button>'); }); it("clickHandler function working", function () { expect(scope.clickHandler).toBeDefined(); var ev = { event: {}, }; var receivedArg = null; var someCallback = function (arg) { receivedArg = arg; }; scope.callbackFunc = someCallback; scope.clickHandler(ev); expect(receivedArg).toBeDefined(); expect(receivedArg).not.toBeNull(); expect(receivedArg.param).toBe(ev.event); expect(receivedArg.msg).toBe("Hi from directive"); }); it("clickHandler tolerates lack of callback func", function () { scope.callbackFunc = null; scope.clickHandler(); }); }); } )();

As you can see, I pulled out some of the initialization code into a getCompiledElement function that I believe is generic enough that it can be used in the test of any directive. In that function, the Angular $compile provider is called upon to compile a html snippet to an element object, instantiating the directive on the way. The html that is passed to the function is just a random html tag containing the directive attribute. The initialization also establishes references to the controller and scope objects of the directive.

The tests involve various sanity checks for undefined and null. But also the html that the directive produces is tested and the behaviour of the directive's clickHandler function.

New stuff

$compile: A mocked directive instance is created by having Angular "compile" an html snippet to produce a compiled element and the directive, including scope and controller instances.

< Previous chapter

2016 by Niels Hede Pedersen Linked in