Testing AngularJS Templates, Directives, Controllers, Services, Factories, and Filters



Angular is a framework that offers impressive testing ability through unit tests and template-based testing, which we will consider template-based unit testing.

For those unfamiliar, Unit Testing is where a developer tests a single function with no execution of other functions. All other functions should be mocked out so that any bug found could only be caused by code within the function being tested. Unit testing can be tedious, but the ability to find minute bugs that would otherwise be overlooked is priceless. Most of the tedious stuff in unit testing can be ignored if you copy and paste code, they also help you understand the nuances of your own code better as you’ll have to read it and understand the concepts in order to write a unit test. They are extremely useful when you find that one bug that you missed while coding and running your site.

A typical argument against unit testing is that a breaking change in one part of the code could break something in another part of the code and you would never notice it because the unit test mocks everything out. This would only be true if the breaking code were not unit tested as well, or if the developer broke the API contract and didn’t inform anyone. These situations can be avoided and shouldn’t persuade anyone from unit testing.

Another common testing practice is Integration Testing. Integration testing is a pretty broad range of testing practices, from unit-like integration tests where you may be testing individual functions but those functions also interact with other functions, to testing with mocked out services (or “fakes”), to using the test framework to click buttons on a website and expect certain visual responses, to full integration testing where the website interacts with real services.

Angular Template testing is a mix between unit and integration testing. You still need to create unit tests, but you will be interrogating and using the HTML to run the test, this implies an integration of HTML, Javascript, and several layers of functions that your individual tests will need to execute. They don’t execute real services, so the execution time is still exceedingly fast.

Another common practice in some JQuery websites is to generate HTML fixtures, which are a full DOM or webpage with all of the data in place. All of the Javascript is run against the DOM, which could lead to event listeners being attached to elements and not relinquishing control, possibly leading to a memory leak in PhantomJS. I will not show this method of testing since Angular doesn’t need to use it. If you do want to test your whole website, I suggest using Selenium, which works with NodeJS as well.

Speed comparison:

Unit test: 0.2ms to 1.0ms for a controller test
Template test: 9ms to 22ms for a small template
HTML fixture test: 715ms for a small page

Unit Tests

Unit tests are one of the easiest test forms to create. Below are several examples for the various Angular 1.x object types.

Directive

A directive is a view that binds to a template. It can include a “link” function which is essentially a controller that has access to the directive element (post compile controller). At a minimum a directive should include a unit test for each “code path” within each function in the “link”.

  describe('something directive', function () {

    var $compile, $rootScope, elem, scope, isolateScope;

    beforeEach(module('namespace.something'));

    beforeEach(inject(function (_$compile_, _$rootScope_) {
      $compile = _$compile_;
      $rootScope = _$rootScope_;

      //create a temporary scope here
      scope = $rootScope.$new();

      //variables that'll be injected go here
      //you can also mock out or spy on services here
      scope.myScopeVar = "test";

      compileDirective();
    }));

    //helper function for creating the directive
    function compileDirective(template){
        elem = $compile(template || '<something my-variable="myScopeVar"></something>')(scope);
        scope.$digest();

        isolateScope = elem.isolateScope();
    }

    //one describe per function
    describe('myFunction method', function(){
      //one 'it' per code path (if/else branch)
      it('does something', function () {
        //as many expects as you want
        expect(isolateScope.myScopeVar).toEqual("test");
        isolateScope.myFunction();
        expect(isolateScope.myScopeVar).toEqual("test2");
      });
    });
  });

Directive Templates

While it’s one thing to test a directive’s functions, it’s a different method to test the template. This method is a mix of unit testing and integration as stated previously since it requires the template to execute multiple functions at once.

  describe( 'something template', function() {
    var $compile,
        $httpBackend,
        $q,
        scope,
        ...;

    beforeEach(module('namespace.something'));
    //include the Angular templates
    beforeEach(module('templates'));

    beforeEach(inject(function(_$compile_, _$httpBackend_, _$q_, ...){
      $compile = _$compile_;
      $httpBackend = _$httpBackend_;
      $q = _$q_;
      ...

      scope = $rootScope.$new();

      spyOn(SomeService, "someMethod");
      spyOn(AnotherService, "anotherMethod").and.returnValue($q.when("test"));

      scope.myScopeVar = "test";

      compileDirective();
    }));

    function compileDirective(template){
      elem = $compile(template || '<something my-variable="myScopeVar"></something>')(scope);
      scope.$digest();
      isolateScope = elem.isolateScope();
    }

    describe('button element', function() {
      beforeEach(function(){
        spyOn(isolateScope, 'doAction');
      });

      it('calls doAction method when clicked', function() {
        //you can click the button here
        elem.find('.submit-button').triggerHandler('click');

        //and expect it to call the function.
        //a unit test would be created for the function itself.
        expect(isolateScope.doAction).toHaveBeenCalled();
      });
    });

    //shows how elements react to variable changes.
    describe('spinner element', function() {
      beforeEach(function(){
        isolateScope.isLoading = true;
        scope.$digest();
      });

      it('shows when isLoading is true', function() {
        elem.find('.spinner').hasClass('ng-hide')).toBe(false);
      });
    });
  });

Controller

A controller can be separate from a directive, but they’re best when paired with a directive. You may have a controller per route, or a main controller, or a controller for each directive, but beyond that you should never have a controller bound to the HTML without being part of a directive. Remember that it’s best to use link functions with directives, so avoid unnecessary controllers.

  describe( 'SomethingController', function() {
    var $rootScope,$q, $controller, scope, SomeService;
  
    beforeEach(module('namespace.something'));

    beforeEach(inject(function (_$rootScope_, _$q_, _$controller_, _SomeService_){
      $rootScope = _$rootScope_;
      $q = _$q_;
      $controller = _$controller_;
      SomeService = _SomeService_;

      scope = $rootScope.$new();

      spyOn(SomeService, 'someMethod').and.returnValue($q.when());

      createController();
    }));

    function createController(){
      $controller("SomethingController", {
        $scope: scope
        ... //any other parameters can be passed here, services should be spied on.
      });
    }

    describe('doSomething method', function(){
      it('should call the SomeService.someMethod function to do something', function() {
        scope.doSomething('test');
        $timeout.flush(); //this allows an async method to return

        expect(SomeService.someMethod).toHaveBeenCalled();

        //check that the function set the right variables
        expect(scope.myVariable).toEqual('test');
      });

      it('should fail the SomeService.someMethod function and catch the error', function() { 
        //this rewrites a previously defined spy
        SomeService.someMethod.and.returnValue($q.reject());
        scope.doSomething('test');
        $timeout.flush(); //this allows an async method to return

        expect(SomeService.someMethod).toHaveBeenCalled();

        //check that the function set the right variables
        expect(scope.myVariable).not.toEqual('test');
        expect(scope.showError).not.toEqual(true);
      });
    });
  });

Service

A service is a utility that it active for all views of your single page app. It controls your HTTP calls and might store some information (hopefully using a Data Store like JSData). It provides a means to access the data or retrieve the data, or provide a way for one directive or controller to communicate to another directive or controller without using the $rootScope.

  describe( 'CartService', function() {
    var $q, $timeout, CartService, ShoppingCart, CartItem;

    var shoppingCart = {
        id: 'ABC123'
    };

    var cartItems = [
      {
        cartItemId: "df123",
        creditCardEnding: 7777,
        price: 2,
        chargePrice: 1,
        quantity: 2,
        guest: { idGuest: 1 },
        guests: [{}],
        taxPrice: 1
      },
      {
        cartItemId: "ab456",
        creditCardEnding: 7777,
        price: 2,
        chargePrice: 1,
        guest: { idGuest: 2 },
        guests: [{}]
      }
    ];

    beforeEach(module('namespace.something'));

    //our code uses JSData for data storage. Let's mock out the objects.
    beforeEach(module(function($provide) {
      $provide.value('ShoppingCart', {
        findAll: function () {},
        ejectAll: function(){}
      });

      $provide.value('CartItem', {
        filter: function () {},
        getAll: function(){},
        ejectAll: function(){}
      });
    }));

    beforeEach(inject(function(_$q_, _$timeout_, _CartService_){
      $q = _$q_;
      $timeout = _$timeout_;
      CartService = _CartService_;

      spyOn(ShoppingCart, 'findAll').and.returnValue($q.when(shoppingCart));
      spyOn(CartItem, 'filter').and.returnValue(cartItems);
      spyOn(CartItem, 'getAll').and.returnValue(cartItems);
      spyOn(CartItem, 'ejectAll');
      spyOn(ShoppingCart, 'ejectAll');
    }));

    describe('getShoppingCart method', function(){
      it('should get the shopping cart items', function(){
        var response = null;
        CartService.getShoppingCart().then(function(data){
          response = data;
        });
        $timeout.flush();

        //some JSON objects don't equal eachother because the references change but the data doesn't
        //We stringify the response and comparison to test equality.
        //I typically develop a Jasmine helper called .toEqualJSON() to help (lnked below).
        expect(response).toEqualJSON(shoppingCart);
        expect(ShoppingCart.findAll).toHaveBeenCalledWith(null, { bypassCache : false });
      });
    });
  });

See this post for the matchers, including toEqualJSON

Factory

A factory in the case of this article is a utility that creates a single component. Just as a real factory chugs out parts for cars or equipment, an Angular factory can chug out data objects that you can use in a service. For the example we will be testing a JSData factory which is used to store certain informaation. This particular factory has additional functions that modify the data being retrieved.

  describe('CartItem factory', function () {
    var $httpBackend, $q, $timeout;

    beforeEach(function () {
        module('js-data');
        module('namespace.something');
    });
    
    beforeEach(inject(function(_$httpBackend_, _CartItem_) {
      CartItem = _CartItem_;
      $httpBackend = _$httpBackend_;
    }));

    afterEach(function(){
      $httpBackend.verifyNoOutstandingExpectation();
      $httpBackend.verifyNoOutstandingRequest();
    });

    describe('findAll method', function(){
      it('finds many cartItems', function(){
        $httpBackend.expectGET('cart/1234').respond([{id: 1, price: 2}]);
        CartItem.findAll();

        $httpBackend.flush();
      });
    });
  });

Filter

A filter is something that can be used in the HTML or in the Angular code to process certain information before displaying it. The date filter is a common one, but they are especially useful for customizing arrays or tabular data. The one below adds “…” at the end of a string, limiting it to the length. The 3 characters “…” are included in the total length.

  describe('EllipsisFilter filter', function(){
    var filter;

    beforeEach(function(){
        module('filters.EllipsisFilter');

        inject(function($filter) {
            filter = $filter("ellipsis");
        });
    });

    it('should return empty string when undefined', function(){
        expect(filter(undefined)).toBe('');
    });

    it('should return empty string when null', function(){
        expect(filter(null)).toBe('');
    });

    it('should return ellipsis at the end when text is longer than expected', function(){
        expect(filter('Extra Stuff Mix', '10')).toEqual("Extra Stuf...");
    });

    it('should not return ellipsis at the end when text is smaller than expected', function(){
        expect(filter('hello!', '10')).toEqual("hello!");
    });

    it('should not return ellipsis at the end if expected length is null or undefined', function(){
        expect(filter('Extra Stuff Mix')).toEqual("Extra Stuff Mix");
    });
});

For reference, here’s the code for the Ellipses filter:

/**

 * @name secondaryFlow.filters.EllipsisFilter
 * @description This will take a full string and just give you the string truncated plus ellipsis at the end if needed.
 */
angular.module( 'secondaryFlow.filters.EllipsisFilter', [
    ])

/**
 * @ngdoc filter
 * @name secondaryFlow.filters.EllipsisFilter
 * @description This will take a full string and just give you the string truncated plus ellipsis at the end if needed.
 */
.filter('ellipsis', function() {
  return function (text, maxLength) {
    if ( !text ) {
      return '';
    }     

    maxLength = parseInt(maxLength, 10);
    if ( !maxLength || text.length <= maxLength) {
      return text;
    }

    text = text.substr(0, maxLength);
        
    return text.trim() + '...';
  };  
});

Leave a Reply

Your email address will not be published. Required fields are marked *