If you have ever taken the step to building something more complex than a simple To-Do List application with AngularJS or any other app-development technology, you know that implementing new features often involves having to fix older parts of your code. This is a common problem with many technologies; no matter the language your software is built with, you will most definitely break things that previously worked when releasing new builds.
This is where Test-Driven Development, or unit testing, comes into play.
Unit testing helps you answer all the 'Is this going to work if I insert X?' or Does X still return the same results now that I have implemented Y?' scenarios. It does it by simulating actions, or cases, with mock data and checking if everything is outputted in the format you expect it to be.
This guide is a great starting point for your journey in testing Angular applications and unit testing in general. In order to get the most out of the guide, you need to have some knowledge of JavaScipt and building Angular applications.
Angular is built with testing in mind. The framework allows simulation of server-side requests and abstraction of the Document Object Model (DOM), thus providing an environment for testing out numerous scenarios. Additionally, Angular's dependency injection allows every component to be mocked and tested in different scopes.
We'll start off by setting up the environment and looking at how Karma, Jasmine, and Angular Mocks work together to provide an easy and seamless testing experience in Angular. Then, we will have a look at the different building blocks of tests. Lastly, we will use the newly acquired knowledge to build sample tests for controllers, services, directives, filters, promises and events.
Every great developer knows his or her tools' uses. Understanding your tools for testing is essential before diving into writing tests.
Karma is an environment which runs the tests of your application. Simply put, it's your testing server. Originally started as a university thesis, Karma aims to make a framework-agnostic environment that automates the running of your unit tests. In its core, it is a Node server that watches for changes in your testing and application files, and when such changes occur, it runs them in a browser and checks for mistakes.
Jasmine is an unit testing framework for JavaScript. It is the most popular framework for testing JavaScript applications, mostly because it is quite simple to start with and flexible enough to cover a wide range of scenarios.
Angular Mocks is an Angular module that is used to mock components that already exist in the application. Its role is to inject various components of your Angular application (controllers, services, factories, directives, filters) and make them available for unit tests. It can be said that Angular Mocks is the middleman between the Angular components in your application and the unit testing environment.
To ensure a speedy and efficient setup, I recommend using the Node Package Manager (npm) to maintain the dependencies of your Angular project.
First, install the Karma CLI globally. We'll need this to be able to run the karma
command directly from the command line.
1npm install -g karma-cli
Then, create a directory where you'll store your project files. Open your terminal and run the following commands:
1mkdir myitemsapp
2cd myitemsapp
Once you are in the directory, start setting up your project dependencies.
First, initialize your package.json
file:
1 npm init
You will get asked several questions regarding your project's details. You can skip them and simply copy the contents below in your package.json
file.
1{
2 "name": "myitemsapp",
3 "version": "1.0.0",
4 "description": "",
5 "main": "karma.conf.js",
6 "directories": {
7 "test": "tests"
8 },
9 "dependencies": {
10 "angular": "^1.5.7",
11 "save": "^2.3.0"
12 },
13 "devDependencies": {},
14 "scripts": {
15 "test": "echo \"Error: no test specified\" && exit 1"
16 },
17 "author": "",
18 "license": "ISC"
19}
Then, start installing your project's dependencies, starting with Angular:
1npm install angular --save
2npm install karma --save-dev
3npm install karma-jasmine jasmine-core --save-dev
4npm install angular-mocks --save-dev
Next, you have to choose a browser launcher for Karma to use: You can use one for Chrome(npm install karma-chrome-launcher --save-dev) , Firefox , Internet Explorer, Opera, PhantomJS and others.
I will go with Google Chrome:
1npm install karma-chrome-launcher --save-dev
Configure your testing environment using a configuration file (karma.conf.js
). This is similar to configuring package.json
, which we used to configure the project environment.
There are many options for configuring Karma. The most essential ones are the following:
Before starting, create two folders in your working directory.
1mkdir app
2mkdir tests
We'll use app
to store our applicaton code and tests
to keep our tests. This way, we'll keep the two separated. In more advanced cases, you would opt for a more generic approach by using wildcards and storing the test and application files together.
With your terminal, run the following command in the directory of the project:
1karma init
Answer the questions as following:
jasmine
.no
Chrome
.app/**.js
tests/**.js
node_modules/angular/angular.js
node_modules/angular-mocks/angular-mocks.js
yes
In the end, you should end up with a karma.conf.js
file looking like this:
1module.exports = function(config) {
2 config.set({
3 // base path that will be used to resolve all patterns (eg. files, exclude)
4 basePath: "",
5 // frameworks to use
6 // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
7 frameworks: ["jasmine"],
8 // list of files / patterns to load in the browser
9 files: [
10 "node_modules/angular/angular.js",
11 "node_modules/angular-mocks/angular-mocks.js",
12 "app/**.js",
13 "tests/**.js"
14 ],
15 // list of files to exclude
16 exclude: [],
17 // preprocess matching files before serving them to the browser
18 // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
19 preprocessors: {},
20 // test results reporter to use
21 // possible values: 'dots', 'progress'
22 // available reporters: https://npmjs.org/browse/keyword/karma-reporter
23 reporters: ["progress"],
24 // web server port
25 port: 9876,
26 // enable / disable colors in the output (reporters and logs)
27 colors: true,
28 // level of logging
29 // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
30 logLevel: config.LOG_INFO,
31 // enable / disable watching file and executing tests whenever any file changes
32 autoWatch: true,
33 // start these browsers
34 // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
35 browsers: ["Chrome"],
36 // Continuous Integration mode
37 // if true, Karma captures browsers, runs the tests and exits
38 singleRun: false,
39
40 // Concurrency Level
41 // how many browser should be started simultaneous
42 concurrency: Infinity
43 });
44};
Once you are done with this step, you are ready to dive into testing features.
To run your tests, you can run either of these commands in the terminal:
1karma start
2npm test
Writing tests revolves around events and their expected outcomes. When you start writing the tests for your application, you must always think in terms of the desired outcomes of your app components' processes.
Although the terms that are going to be used are specific to Jasmine, they share many similarities with terms used with other behavior-driven testing frameworks:
describe(string, function)
it(string, function)
expect(actual).toBe(expected)
toEqual(expected)
To give you a better idea of what matchers can do, here is a list of the most essential matchers:
1expect(fn).toThrow(e);
2expect(instance).toBe(instance);
3expect(mixed).toBeDefined();
4expect(mixed).toBeFalsy();
5expect(number).toBeGreaterThan(number);
6expect(number).toBeLessThan(number);
7expect(mixed).toBeNull();
8expect(mixed).toBeTruthy();
9expect(mixed).toBeUndefined();
10expect(array).toContain(member);
11expect(string).toContain(substring);
12expect(mixed).toEqual(mixed);
13expect(mixed).toMatch(pattern);
Teardowns are а building block fоr tests and are used to "prepare" the code for its specs in a particular suite. Suppose that before or after each spec (i.e a describe
function), certain setups have to be done so that you can test a function. Instead of doing it for every spec, you can write a beforeEach
or an afterEach
function in your suite to do that.
1// single line
2beforeEach(module("itemsApp"));
3
4// multiple lines
5beforeEach(function() {
6 module("itemsApp");
7 //...
8});
In the block above, you can see two ways in which you can initialize the Angular module in a testing suite using teardown.
Injecting is essential feature of unit testing Angular applications. It is characterised by the inject
function (provided by the Angular mocks module). Injecting can be regarded as a way of accessing Angular's built-in constructs or your application's constructs by giving the correct arguments. This gives you the ability to access the code of your application and put mock data through it in order to test it.
Injecting gives access to Angular's building blocks -- $service, $controller, $filter , $directive, $factory. Additionally, it allows to mock built-in variables such as $rootScope and $q. It also provides $httpBackend, which simulates server-side requests in the testing envirionemnt.
1// Using _serviceProvider_ notation
2var $q;
3beforeEach(
4 inject(function(_$q_) {
5 $q = _$q_;
6 })
7);
8
9// Using $injector
10var $q;
11beforeEach(
12 inject(function($injector) {
13 $q = $injector.get("$q");
14 })
15);
16
17// Using an alias Eg: $$q, q, _q
18var $$q;
19beforeEach(
20 inject(function($q) {
21 $$q = $q;
22 })
23);
In the code snippet above, you can see three ways of injecting and instantiating the Angular $q variable, which is used for asynchronous requests, in the testing environment.
Time to put all this knowledge to use. Let's do go through some scenarios that you might encounter when writing your tests:
First, let's instantiate an Angular applicaiton in app.js
.
Code
1// app/app.js
2
3angular.module("ItemsApp", []);
Then, write a simple controller:
1//app/app.js
2angular.module('ItemsApp', [])
3 .controller('MainCtrl', function($scope) {
4 $scope.title = 'Hello Pluralsight';
We have a controller with one simple $scope
variable attached to it. Let's write a test to see if the scope variable contains the value we expect it to have:
Test
1//tests/tests.js
2// Suite
3describe("Testing a Hello Pluralsight controller", function() {});
Let's go through building our first test step-by-step. First, we use the describe
function to make a new testing suite for the controller. It contains a message as its first argument and a function that is going to contain the tests as the second argument.
Next, let's inject the controller in the suite:
1//tests/tests.js
2
3describe("Testing a Hello Pluralsight controller", function() {
4 var $controller;
5
6 // Setup for all tests
7 beforeEach(function() {
8 // loads the app module
9 module("ItemsApp");
10 inject(function(_$controller_) {
11 // inject removes the underscores and finds the $controller Provider
12 $controller = _$controller_;
13 });
14 });
15});
Here, using beforeEach
, we define our teardown flow. Before each of the tests, we are going to inject the controller provider from Angular and make it available for testing.
Next, we'll move to the the tests themselves:
1//tests/tests.js
2
3// Suite
4describe("Testing a Hello Pluralsight controller", function() {
5 var $controller;
6
7 // Setup for all tests
8 beforeEach(function() {
9 // loads the app module
10 module("ItemsApp");
11 inject(function(_$controller_) {
12 // inject removes the underscores and finds the $controller Provider
13 $controller = _$controller_;
14 });
15 });
16
17 // Test (spec)
18 it("should say 'Hello Pluralsight'", function() {
19 var $scope = {};
20 // $controller takes an object containing a reference to the $scope
21 var controller = $controller("MainCtrl", { $scope: $scope });
22 // the assertion checks the expected result
23 expect($scope.title).toEqual("Hello Pluralsight");
24 });
25
26 // ... Other tests here ...
27});
We start our first test specification (spec) with the it
function. The first argument is a message, and the second argument is the function with the test. First, we instantiate MainCtrl
that we created in the application. Then, we use a matcher to check if the $scope.title
variable is equal to the value we assigned in the application.
Next, we are going to add a service to our application in order to test it. We'll write a service with one method, get()
that returns an array. Just below your controller code, add the following snippet:
Code
1//app/app.js
2
3angular.module('ItemsApp', [])
4//..
5// MainController
6//..
7.factory('ItemsService', function(){
8 var is = {},
9 _items = ['hat', 'book', 'pen'];
10
11 is.get = function() {
12 return _items;
13 }
14
15 return is;
16})
Here is how we are going to test it:
Test
1describe('Testing Languages Service', function(){
2 var LanguagesService;
3
4 beforeEach(function(){
5 module('ItemsApp');
6 inject(function($injector){
7 ItemsService = $injector.get('ItemsService');
8 });
9 });
10
11 it('should return all items', function() {
12 var items = ItemsService.get();
13 expect(items).toContain('hat');
14 expect(items).toContain('book');
15 expect(items).toContain('pen');
16 expect(items.length).toEqual(3);
17 });
18});
Even though we have only one spec, we stick to the good practice of using beforeEach
in our suite in order to instantiate what we need.
In the spec, we use the toContain
matcher to check the contents of the array that the get()
method returns. In the end, we use languages.length
with the toEqual
matcher to check the length of the array.
Directives differ in terms of purpose and structure than services and controllers. Thus, they are tested using different approach.
Directives have their own encapsulated scope which gets its data from an outer scope, a controller. Directives first get "compiled" (in the Angular.js sense), and then their scope gets filled with data. To properly test them, we must simulate the same process and see if we get the desirable outcomes.
We are going to add a simple directive that will display the user's profile. It will get its profile data from outside and apply it into its scope.
Code
1//app/app.js
2angular
3 .module("ItemsApp", [])
4 //rest of the app
5
6 .directive("userProfile", function() {
7 return {
8 restrict: "E",
9 template: "<div>{{user.name}}</div>",
10 scope: {
11 user: "=data"
12 },
13 replace: true
14 };
15 });
Test
1//tests/tests.js
2describe("Testing user-profile directive", function() {
3 var $rootScope, $compile, element, scope;
4
5 beforeEach(function() {
6 module("ItemsApp");
7 inject(function($injector) {
8 $rootScope = $injector.get("$rootScope");
9 $compile = $injector.get("$compile");
10 element = angular.element('<user-profile data="user"></user-profile>');
11 scope = $rootScope.$new();
12 // wrap scope changes using $apply
13 scope.$apply(function() {
14 scope.user = { name: "John" };
15 $compile(element)(scope);
16 });
17 });
18 });
19
20 it("Name should be rendered", function() {
21 expect(element[0].innerText).toEqual("John");
22 });
23});
The code might be confusing at first. Upon second glance, however, it's pretty consistent with Angular basics:
$rootScope
and create a new one using $rootScope.$new()
.element
using angular.element()
.$compile
will get the element and apply a scope to it. It's essentially the function that parses the HTML element, finds the directive it corresponds to, and attaches its template and scope into the outer scope.{name: 'John'}
) to a scope.When you're done, the element
variable will be filled with HTML generated by the directive in your code. All you need to do is use a spec to test if it returned the result you wanted.
Filters are used to transform data in Angular applications. Compared to other constructs, they are relatively easy to test. For this guide, we are going to create a filter that reverses strings:
Code
1//app/app.js
2angular
3 .module("ItemsApp", [])
4 //rest of the app
5 .filter("reverse", [
6 function() {
7 return function(string) {
8 return string
9 .split("")
10 .reverse()
11 .join("");
12 };
13 }
14 ]);
Test
1//tests/tests.js
2
3describe("Testing reverse filter", function() {
4 var reverse;
5 beforeEach(function() {
6 module("ItemsApp");
7 inject(function($filter) {
8 //initialize your filter
9 reverse = $filter("reverse", {});
10 });
11 });
12
13 it("Should reverse a string", function() {
14 expect(reverse("rahil")).toBe("lihar");
15 expect(reverse("don")).toBe("nod");
16 //expect(reverse('jam')).toBe('oops'); // this test should fail
17 });
18});
Here, we inject the filter and assign it to a variable reverse
. Then, we use the variable to call the filter and test whether the result is reversed.
Promises are the standard tool for handling client-server communication. Angular services such as ngResource and $http use promises to interact with the back-end service.
For this example, we are going to take the ItemsService
and mock it so that it simulates server-side interaction.
Code
1//app/app.js
2angular
3 .module("ItemsApp", [])
4 //rest of the app
5 .factory("ItemsServiceServer", [
6 "$http",
7 "$q",
8 function($http, $q) {
9 var is = {};
10 is.get = function() {
11 var deferred = $q.defer();
12 $http
13 .get("items.json") //'items.json will be mocked in the test'
14 .then(function(response) {
15 deferred.resolve(response);
16 })
17 .catch(function(error) {
18 deferred.reject(error);
19 });
20 return deferred.promise;
21 };
22 return is;
23 }
24 ]);
Test
1//tests/tests.js
2describe("Testing Items Service - server-side", function() {
3 var ItemsServiceServer,
4 $httpBackend,
5 jsonResponse = ["hat", "book", "pen"]; //this is what the mock service is going to return
6
7 beforeEach(function() {
8 module("ItemsApp");
9 inject(function($injector) {
10 ItemsServiceServer = $injector.get("ItemsServiceServer");
11 // set up the mock http service
12 $httpBackend = $injector.get("$httpBackend");
13
14 // backend definition response common for all tests
15 $httpBackend
16 .whenGET("items.json") //must match the 'url' called by $http in the code
17 .respond(jsonResponse);
18 });
19 });
20
21 it("should return all items", function(done) {
22 // service returns a promise
23 var promise = ItemsServiceServer.get();
24 // use promise as usual
25 promise.then(function(items) {
26 // same tests as before
27 expect(items.data).toContain("hat");
28 expect(items.data).toContain("book");
29 expect(items.data).toContain("pen");
30 expect(items.data.length).toEqual(3);
31 // Spec waits till done is called or Timeout kicks in
32 done();
33 });
34 // flushes pending requests
35 $httpBackend.flush();
36 });
37});
As you can see, there are several differences in testing a service with and without promises. Here, Angular mocks' $httpBackend is used to simulate a server-side request. First, we instantiate a variable jsonResponse
that has to be structured in the same way we expect to retreive the server-side data.
Then, in the beforeEach
block, we inject $httpBackend
and use whenGET()
to assign jsonResponse
as the response of items.json
.
The testing part is similar to the one we did with the service. Note that items.data
is used instead of items
because the data of the response (jsonResponse
) is contained into the data
property of the response object.
We call done()
to finish the test after the promise returns. If done()
is not called, then the promise has timed out, and the test fails.
We end the spec with a $httpBackend.flush()
, which lets $httpBackend
to respond to other request directed to it in the rest of the tests.
The last thing we'll test will be events. Events can be spawned with $broadcast
and caught with $on
. We'll make a service that will broadcast when a new item is added. We'll test if the event is broadcast, if it's caught by a controller, and if its contents match the contents of the broadcast.
Code
1//app/app.js
2angular
3 .module("ItemsApp", [])
4 //rest of the app
5 .factory("appBroadcaster", [
6 "$rootScope",
7 function($rootScope) {
8 var abc = {};
9
10 abc.itemAdded = function(item) {
11 $rootScope.$broadcast("item:added", item);
12 };
13
14 return abc;
15 }
16 ]);
In your MainController
, add a listener:
1//app/app.js
2angular
3 .module("ItemsApp", [])
4 //rest of the app
5
6 //update MainCtrl by injecting $rootScope and adding a listener
7 .controller("MainCtrl", function($scope, $rootScope) {
8 $scope.title = "Hello Pluralsight";
9
10 $rootScope.$on("item:added", function(event, item) {
11 $scope.item = item;
12 });
13 });
Test
1//tests/tests.js
2describe("appBroadcaster", function() {
3 var appBroadcaster,
4 $rootScope,
5 $scope,
6 $controller,
7 item = { name: "Pillow", id: 1 }; //what is going to be broadcast
8
9 beforeEach(function() {
10 module("ItemsApp");
11 inject(function($injector) {
12 appBroadcaster = $injector.get("appBroadcaster"); //get the service
13 $rootScope = $injector.get("$rootScope"); //get the $rootScope
14 $controller = $injector.get("$controller");
15 $scope = $rootScope.$new();
16 });
17 spyOn($rootScope, "$broadcast").and.callThrough(); //spy on $rootScope $broadcast event
18 spyOn($rootScope, "$on").and.callThrough(); //spy on $rootScope $on event
19 });
20
21 it("should broadcast 'item:added' message", function() {
22 // avoid calling $broadcast implementation
23 $rootScope.$broadcast.and.stub();
24 appBroadcaster.itemAdded(item);
25 expect($rootScope.$broadcast).toHaveBeenCalled(); //check if there was a broadcast
26 expect($rootScope.$broadcast).toHaveBeenCalledWith("item:added", item); //check if the broadcasted message is right
27 });
28
29 it("should trigger 'item:added' listener", function() {
30 // instantiate controller
31 $controller("MainCtrl", { $scope: $scope });
32 // trigger event
33 appBroadcaster.itemAdded(item); //pass the item variable for broadcasting
34 expect($rootScope.$on).toHaveBeenCalled();
35 expect($rootScope.$on).toHaveBeenCalledWith(
36 "item:added",
37 jasmine.any(Function)
38 );
39 expect($scope.item).toEqual(item); //match the broadcasted message with the received message
40 });
41});
To check if a function gets called, Jasmine provides spies. Here, we can see an implementation of spies by using spyOn()
with .and.callThrough()
. callThrough()
and stub();
enables you not only to detects if the funciton is called, but also enables you to get the original implementation of the function and check its arguments.
From the tests we just wrote, we can see a clear pattern emerging:
$controller
service.$compile
directives.This guide featured only two files being tested -- app.js
for the code and tests.js
for the tests. However, larger and more complex projects may force you to opt for a different file structure:
You'd want to group your code files and test file together, using the .spec.js
suffix to differentiate test files.
1app/some-controller.js
2app/some-controller.spec.js
3app/some-directive.js
4app/some-directive.spec.js
5app/some-service.js
6app/some-service.spec.js
This is everything you need to know in order to start testing your application. I have made a Github repository with the code from the guide in case you missed something.