u720zq
Last Updated: December 29, 2017
·
32.05K
· gsans

Angular — Unit Testing with Jasmine

AngularJs Meetup South London Collection | this article

alt text

Angular was designed with testability in mind and it provides multiple options to support Unit Testing. In this article I will show you how you can setup Jasmine and write unit tests for your angular components. We will cover:

  • Introducing Jasmine syntax and main concepts
  • Unit Testing Angular Controllers, Services, Directives, Filters, Routes, Promises and Events

A full working example including all specs can be found here (plunker).

Jasmine

Writing your tests

Jasmine uses behaviour-driven notation that results in a fluent and improved testing experience. These are the main concepts:

  • Suites— describe(string, function) functions, take a title and a function containing one or more specs.
  • Specs— it(string, function) functions, take a title and a function containing one or more expectations.
  • Expectations— are assertions that evaluate to true or false. Basic syntax reads expect(actual).toBe(expected)
  • Matchers — are predefined helpers for common assertions. Eg: toBe(expected), toEqual(expected). Find a complete list here.

Jasmine 2.1 new features

Jasmine 2.1, released last 14 Nov 2014, introduced two new features. See Release notes.

  • focused specs — by using fit and fdescribe you can decide which specs or suites to run.
  • one-time setup and teardown — this can be used by calling beforeAll and afterAll.

In previous versions, similar to fit/fdescribe, you could selectively disable specs or suites with xit (shown as pending specs) and xdescribe.

Setup and teardown

A good practice to avoid code duplication on our specs is to include a setup code setting some local variables to be re-used.

use beforeEach and afterEach to do changes before and after each spec

Jasmine offers four handlers to add our setup and teardown code: beforeEach, afterEach executed for each spec and beforeAll, afterAll executed once per suite.

// single line
beforeEach(module('plunker'));

// multiple lines
beforeEach(function(){
  module('plunker');
  //...
});

Jasmine inject function uses dependency injection to resolve common services or providers, like $rootScope, $controller, $q (promises mock), $httpBackend ($http mock), and match them to the corresponding parameters. Common notations for inject are:

// Using _serviceProvider_
var $q;
beforeEach(inject(function (_$q_) {
    $q = _$q_;
}));

// Using $injector
var $q;
beforeEach(inject(function ($injector) {
    $q = $injector.get('$q');
}));

// Using an alias Eg: $$q, q, _q
var $$q;
beforeEach(inject(function ($q) {
    $$q = $q;
}));

Check out the official Angular documentation on Unit Testing for more details.

Default Matchers

These are Jasmine’s default set of matchers.

expect(fn).toThrow(e);
expect(instance).toBe(instance);
expect(mixed).toBeDefined();
expect(mixed).toBeFalsy();
expect(number).toBeGreaterThan(number);
expect(number).toBeLessThan(number);
expect(mixed).toBeNull();
expect(mixed).toBeTruthy();
expect(mixed).toBeUndefined();
expect(array).toContain(member);
expect(string).toContain(substring);
expect(mixed).toEqual(mixed);
expect(mixed).toMatch(pattern);

Check out Jasmine-Matchers for some additional matchers for Arrays, Booleans, Browser, Numbers, Exceptions, Strings, Objects and Dates.

Creating Project-Specific Matchers

Sometimes you can improve your specs or failure messages using a custom matcher library.

Let’s see how to create a myCustomMatchers library containing only one simplified matcher: toBeAllowedToDrive. A matcher must be within a factory object containing a compare function. Its signature is compare(actual, expected) returning an object like { pass: boolean, message: string }. This implementation will work both for expect(age).toBeAllowedToDrive() and (age).not.toBeAllowedToDrive().

var myCustomMatchers = {
  // toBeAllowedToDrive matcher
  // Usage: expect(age).toBeAllowedToDrive();
  //        expect(age).not.toBeAllowedToDrive();
  toBeAllowedToDrive: function() {
    return {
      compare: function(age) {
        var result = {};
        result.pass = age>16;

        if (result.pass) {
          result.message = "Expected " + age + " to be allowed to drive";
        } else {
          result.message = "Expected " + age + " to be allowed to drive, but it was not";
        }
        return result;
      }
    };
  }
};


describe("Custom matcher: 'toBeAllowedToDrive'", function() {
  var John = 17,
    Mary = 16;

  // Custom Matchers must be added using beforeEach
  beforeEach(function() {
    jasmine.addMatchers(myCustomMatchers);
  });

  it("should allow John to drive", function() {
    expect(John).toBeAllowedToDrive();
    // replaces 
    expect(John).toBeGreaterThan(16);
  });

  it("should not allow Mary to drive", function() {
    expect(Mary).not.toBeAllowedToDrive();
    // replaces 
    expect(Mary).not.toBeGreaterThan(16);
  });
});

We can see how we improved specs readability on the previous code block. Messages will also improve future maintenance and debugging experience.

Angular Unit Testing

Setting up a Test Runner Page

Find the steps to setup Jasmine here. You can use Jasmine’s SpecRunner.html to start or create your own, either way, it should look similar to this:

<!-- Jasmine dependencies -->
<link rel="stylesheet" href="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.css" />
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.js"></script>
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine-html.js"></script>
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/boot.js"></script>

<!-- Angular dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.7/angular.js"></script>
<script src="https://code.angularjs.org/1.3.5/angular-mocks.js"></script>

<!-- Application -->
<link rel="stylesheet" href="style.css" />
<script src="app.js"></script>

<!-- Tests + Jasmine Bootstrap -->
<script src="appSpec.js"></script>
<script src="jasmineBootstrap.js"></script>

Important files to notice:

  • app.js — contains our Angular application (usually minified version)
  • angular-mocks.js — contains mocks for core Angular services and allows us to inject them in tests
  • appSpec.js — contains our specs

Testing a Controller

Let’s take a very simple controller that sets a title property on the scope.

app.controller('MainCtrl', function($scope) {
  $scope.title = 'Hello World';
});

To help testing this controller we will use a common setup using beforeEach. This will load the application and get hold of the controller provider.

use module(‘name’) function to load the corresponding component so it’s available in your tests

On our test we are instantiating the controller using an empty scope and checking the title for the expected value.

// Suite
describe('Testing a Hello World controller', function() {
  var $controller;

  // Setup for all tests
  beforeEach(function(){
    // loads the app module
    module('plunker');
    inject(function(_$controller_){
      // inject removes the underscores and finds the $controller Provider
      $controller = _$controller_;
    });
  });

  // Test (spec)
  it('should say \'Hello World\'', function() {
    var $scope = {};
    // $controller takes an object containing a reference to the $scope
    var controller = $controller('MainCtrl', { $scope: $scope });
    // the assertion checks the expected result
    expect($scope.title).toEqual('Hello World');
  });

  // ... Other tests here ...
});

Testing a Service

We are going to create LanguagesService, with only one method that returns an array of available languages for the application.

// Languages Service
app.factory('LanguagesService', function(){
  var lng = {}, 
    _languages = ['en', 'es', 'fr'];

  lng.get = function() {
    return _languages;
  }

  return lng;
});

Similar to our previous example we instantiate the service using beforeEach. As we said, this is a good practice even if we only have one spec. On this occasion we are checking each individual language and the total count.

describe('Testing Languages Service', function(){
  var LanguagesService;

  beforeEach(function(){
    module('plunker');
    inject(function($injector){
      LanguagesService = $injector.get('LanguagesService');
    });
  });

  it('should return available languages', function() {
    var languages = LanguagesService.get();
    expect(languages).toContain('en');
    expect(languages).toContain('es');
    expect(languages).toContain('fr');
    expect(languages.length).toEqual(3);
  });
});

Testing a Directive

Directives usually encapsulate complex functionality and interactions so writing comprehensive unit tests become a mandatory task. We will use a very basic directive, myProfile, that renders a user profile so you can grasp the basic idea. Check out these articles to get an introduction on directives: handling scope, using dynamic templates.

app.directive('myProfile', function(){
  return {
    restrict: 'E',
    template: '<div>{{user.name}}</div>',
    //templateUrl: 'path/template.tpl.html'
    scope: {
        user: '=data'
    },
    replace: true
  };  
});

This time, we are creating a new $scope and passing it to $compile. We wrap our changes to $scope using $apply to replace {{user.name}} with the final value and compile it. Doing it like this, it renders with the right context. We don’t need to call $digest separately as $apply internally calls $digest once finishes evaluating all changes.

describe('Testing my-directive', function() {
  var $rootScope, $compile, element, scope;

  beforeEach(function(){
    module('plunker');
    inject(function($injector){
      $rootScope = $injector.get('$rootScope');
      $compile = $injector.get('$compile');
      element = angular.element('<my-profile data="user"></my-profile>');
      scope = $rootScope.$new();
      // wrap scope changes using $apply
      scope.$apply(function(){
        scope.user = { name: "John" };
        $compile(element)(scope);
      });
    });
  });

  it('Name should be rendered', function() {
    expect(element[0].innerText).toEqual('John');
  });
});

Testing a Filter

Filters are functions that transform input data into a user readable format. We will write a custom uppercase filter, myUpper, using the standard String.prototype.toUpperCase(). This is only for simplicity as angular has its own uppercaseFilter implementation.

app.filter('myUpper', function() {
  return function(input) {
    return input.toUpperCase();
  };
});

Filters can be injected using their registered name on the angular DI engine, like myUpperFilter(input, [arguments]), or if we want to test many filters we can inject the $filter Provider once and instantiate each filter using their name, like $filter(‘myUpper’)(input, [arguments]).

describe('Testing myUpper Filter', function(){
  var myUpperFilter, $filter;

  beforeEach(function(){
    module('plunker');
    inject(function($injector){
      // append Filter to the filter name
      myUpperFilter = $injector.get('myUpperFilter');

      // usign $filter Provider
      $filter = $injector.get('$filter');
    });
  });

  it('should uppercase input', function(){
    expect(myUpperFilter('Home')).toEqual('HOME');
    // using $filter
    expect($filter('myUpper')('Home')).toEqual('HOME');
  })
})

Check out this article about injecting filters that also covers how to chain and compose multiple filters.

Testing Routes

Routes are sometimes left out but it is usually seen as a good practice for double-entry bookkeeping. In our example, we will use a very simple route configuration with only one route pointing to home.

app.config(function($routeProvider){
  $routeProvider.when('/home', {
    templateUrl: 'home.tpl.html',
    controller: 'MainCtrl'
  })
  .otherwise({ redirectTo:'/home' });
});

Our test will use the application’s $route setup to check our expectations. We will check that when we navigate to ‘/home’ our route will be used properly so it uses the corresponding template and controller. Finally, we check that any other routes redirect you to home as well.

describe('Testing Routes', function(){
  var $route, $rootScope, $location, $httpBackend;

  beforeEach(function(){
    module('plunker');

    inject(function($injector){
      $route = $injector.get('$route');
      $rootScope = $injector.get('$rootScope');
      $location = $injector.get('$location');
      $httpBackend = $injector.get('$httpBackend');

      $httpBackend.when('GET', 'home.tpl.html').respond('home');
    });
  })

  it('should navigate to home', function(){
    // navigate using $apply to safely run the $digest cycle
    $rootScope.$apply(function() {
      $location.path('/home');
    });
    expect($location.path()).toBe('/home');
    expect($route.current.templateUrl).toBe('home.tpl.html');
    expect($route.current.controller).toBe('MainCtrl');
  })

  it('should redirect not registered urls to home', function(){
    // navigate using $apply to safely run the $digest cycle
    $rootScope.$apply(function() {
      $location.path('/other');
    });
    expect($location.path()).toBe('/home');
    expect($route.current.templateUrl).toBe('home.tpl.html');
    expect($route.current.controller).toBe('MainCtrl');
  })
})

Use $apply instead of $digest as it wraps your code inside a try/catch besides also calling $digest

Testing a Promise

Promises are becoming more popular and Angular services like $http or ng-resource make use of them. See a previous article to learn more about promises.

Jasmine 2.0 introduced a new syntax for asynchronous specs using an optional done callback parameter like it(title, function(done){…})
We have changed our previous service using a promise on top of the promise used by $http. We have used Array.prototype.map() to transform the raw JSON format to a flat array instead of using for.

app.factory('LanguagesServicePromise', ['$http', '$q', function($http, $q){
  var lng = {};
  lng.get = function() {
    var deferred = $q.defer();
    $http.get('languages.json')
    .then(function(response){
       var languages = response.data.map(function(item){
         return item.name;
       });
       deferred.resolve(languages);
    })
    .catch(function(response){
      deferred.reject(response);
    });
    return deferred.promise;
  }

  return lng;
}]);

Note you could simply return $http.get(‘languages.json’) promise if you don’t need to transform the response data
Testing this service has nothing new besides the use of done and $httpBackend.flush() to flush all pending mock requests.

describe('Testing Languages Service - Promise', function(){
  var LanguagesServicePromise, 
    $httpBackend, 
    jsonResponse = [{"name":"en"}, {"name":"es"}, {"name":"fr"}];

  beforeEach(function(){
    module('plunker');
    inject(function($injector){
      LanguagesServicePromise = $injector.get('LanguagesServicePromise');
      // set up the mock http service
      $httpBackend = $injector.get('$httpBackend');

      // backend definition response common for all tests
      $httpBackend.whenGET('languages.json')
        .respond( jsonResponse );
    });
  });

  it('should return available languages', function(done) {
    // service returns a promise
    var promise = LanguagesServicePromise.get();
    // use promise as usual
    promise.then(function(languages){
      // same tests as before
      expect(languages).toContain('en');
      expect(languages).toContain('es');
      expect(languages).toContain('fr');
      expect(languages.length).toEqual(3);
      // Spec waits till done is called or Timeout kicks in
      done();
    });
    // flushes pending requests
    $httpBackend.flush();
  });
});

Testing Angular Events ($broadcast/$on)

We will create a message bus with only an event to broadcast user details to different components on our application when the user logs in.

app.factory("messageBus", ['$rootScope', function($rootScope) {
  var bus = {};

  bus.userLogged = function(user) {
    $rootScope.$broadcast("global.user.logged", user);
  };

  return bus;
}]);

On our controller we will register the following listener to respond to global.user.logged events.

$rootScope.$on("global.user.logged", function(event, user) {
    $scope.user = user;
  });

In our test we will check that $broadcast and $on functions are called with the right parameters and listeners are registered and triggered when global.user.logged event is dispatched.

describe("Message Bus", function() {
    var messageBus, $rootScope, $scope, $controller, 
      user = { name: "John", id: 1 };

    beforeEach(function() {
      module("plunker");
      inject(function($injector) {
          messageBus = $injector.get("messageBus");
          $rootScope = $injector.get("$rootScope");
          $controller = $injector.get('$controller');
          $scope = $rootScope.$new();
      });
      spyOn($rootScope, '$broadcast').and.callThrough();
      spyOn($rootScope, '$on').and.callThrough();
    });

    it("should broadcast 'global.user.logged' message", function() {
        // avoid calling $broadcast implementation
        $rootScope.$broadcast.and.stub();
        messageBus.userLogged(user);
        expect($rootScope.$broadcast).toHaveBeenCalled();
        expect($rootScope.$broadcast).toHaveBeenCalledWith("global.user.logged", user);
    });

    it("should trigger 'global.user.logged' listener", function() {
        // instantiate controller
        $controller('MainCtrl', { $scope: $scope });
        // trigger event
        messageBus.userLogged(user);
        expect($rootScope.$on).toHaveBeenCalled();
        expect($rootScope.$on).toHaveBeenCalledWith('global.user.logged', jasmine.any(Function));
        expect($scope.user).toEqual(user);
    }); 
});

In order to track function calls Jasmine provides spies. Spies allow many configurations. We are using spyOn().and.callThrough() and spyOn().and.stub(). Both configurations track calling information, but only callThrough will call to the original implementation.

More Angular Unit Testing Examples

If you need more examples please feel free to contact me at gerarddotsansatgmaildotcom or head to Angular Unit Tests in GitHub!

Resources

AngularJs Guide Unit Testing — Source angular-mocks.js
An Introduction to Unit Testing in AngularJs Applications, Sébastien Fragnaud

AngularJs Meetup South London Collection | this article