JavaScript
Article
By Ravi

AngularJS Testing Tips: Testing Directives

By Ravi
Help us help you! You'll get a... FREE 6-Month Subscription to SitePoint Premium Plus you'll go in the draw to WIN a new Macbook SitePoint 2017 Survey Yes, let's Do this It only takes 5 min

Unit tests are an essential part of software development as they help you in releasing less buggy code. Testing is one of the several things that one has to do to improve code quality. AngularJS is created with testing in mind and any code written on top of the framework can be tested easily.

In my last article on testing, I covered Unit testing Controllers, Services and Providers. This article continues the discussion on testing with directives. Directives are different from other components because they aren’t used as objects in the JavaScript code, but in HTML templates of the application. We write directives to perform DOM manipulations and we can’t ignore them in unit tests as they play an important role. Besides, they directly affect the usability of the application.

I encourage you to check out the past article on Mocking dependencies in AngularJS tests, as we will be using some of the techniques from that article here. In case you want to play with the code developed in this tutorial, you can take a look at the GitHub repository I set up for you.

--ADVERTISEMENT--

Testing Directives

Directives are the most important and most complex components in AngularJS. Testing directives is tricky, as they are not called like a function. In applications, the directives are declaratively applied on the HTML template. Their actions are executed when the template is compiled and a user interacts with the directive. When performing unit tests, we need to automate the user actions and manually compile the HTML in order to test the functionality of the directives.

Setting up Objects to Test a Directive

Just like testing any piece of logic in any language or using any framework, we need to get references of the objects needed before starting to test a directive. The key object to be created here is an element containing the directive to be tested. We need to compile a piece of HTML with the directive specified in it to get the directive into action. For instance, consider the following directive:

angular.module('sampleDirectives', []).directive('firstDirective', function() {
  return function(scope, elem){
    elem.append('<span>This span is appended from directive.</span>');
  };
});

Lifecycle of the directive will be kicked in, and the compile and link functions will be executed. We can manually compile any HTML template using the $compile service. The following beforeEach block compiles the above directive:

var compile, scope, directiveElem;

beforeEach(function(){
  module('sampleDirectives');
  
  inject(function($compile, $rootScope){
    compile = $compile;
    scope = $rootScope.$new();
  });
  
  directiveElem = getCompiledElement();
});

function getCompiledElement(){
  var element = angular.element('<div first-directive></div>');
  var compiledElement = compile(element)(scope);
  scope.$digest();
  return compiledElement;
}

On compilation, the lifecycle of the directive is kicked in. After the next digest cycle, the directive object would be in the same state as it appears on a page.

If the directive depends on any service to achieve its functionality, these services have to be mocked before compiling the directive, so that calls to any service methods can be inspected in the tests. We’ll see an example in the next section.

Link function is the most used property of the directive definition object (DDO). It contains most of the core logic of the directive. This logic includes simple DOM manipulations, listening to pub/sub events, watching for change of an object or an attribute, calling services, handling UI events, and so on. We will try to cover most of these scenarios.

DOM Manipulation

Let’s start with case of the directive defined in the previous section. This directive adds a span element to the content of the element on which the directive is applied. It can be tested by finding the span inside the directive. The following test case asserts this behavior:

it('should have span element', function () {
  var spanElement = directiveElem.find('span');
  expect(spanElement).toBeDefined();
  expect(spanElement.text()).toEqual('This span is appended from directive.');
});

Watchers

As directives work on current state of scope, they should have watchers to update the directive when state of the scope changes. Unit test for the watcher has to manipulate data and force the watcher to run by calling $digest and it has to check the state of directive after the digest cycle.

The following code is a slightly modified version of the above directive. It uses a field on scope to bind text inside the span:

angular.module('sampleDirectives').directive('secondDirective', function(){
  return function(scope, elem){
    var spanElement = angular.element('<span>' + scope.text + '</span>');
    elem.append(spanElement);

    scope.$watch('text', function(newVal, oldVal){
      spanElement.text(newVal);
    });
  };
});

Testing this directive is similar to the first directive; except it should be validated against data on scope and should be checked for update. The following test case validates if the state of the directive changes:

it('should have updated text in span', function () 
  scope.text = 'some other text';
  scope.$digest();
  var spanElement = directiveElem.find('span');
  expect(spanElement).toBeDefined();
  expect(spanElement.text()).toEqual(scope.text);
});

The same technique can be followed to test observers on attributes as well.

DOM Events

The importance of events in any UI based application forces us to ensure that they are working correctly. One of the advantages of JavaScript-based applications is that most of the user interaction is testable through APIs. Events can be tested using the APIs. We can trigger events using the jqLite API and test logic inside the event.

Consider the following directive:

angular.module('sampleDirectives').directive('thirdDirective', function () {
  return {
      template: '<button>Increment value!</button>',
      link: function (scope, elem) {
        elem.find('button').on('click', function(){
          scope.value++;
        });
      }
    };
  });

The directive increments the value of the value property by one on every click of the button element. The test case for this directive has to trigger the click event using jqLite’s triggerHandler and then check if the value is incremented. This is how you test the previous code:

it('should increment value on click of button', function () {
  scope.value=10;
  var button = directiveElem.find('button');

  button.triggerHandler('click');
  scope.$digest();

  expect(scope.value).toEqual(11);
});

In addition to the cases mentioned here, the link function contains logic involving the interaction with services or publishing /subscribing scope events. To test these cases, you can follow the techniques discussed in my previous post. The same techniques can be applied here too.

The compile block has responsibilities similar to link. The only difference is that the compile block can’t use or manipulate scope, as the scope is not available by the time compile runs. DOM updates applied by the compile block can be tested by inspecting HTML of the rendered element.

Testing Directive’s Template

A template can be applied to a directive in two ways: using an inline template or using a file. We can verify if the template is applied on a directive and also if the template has certain elements or directives in it.

A directive with inline template is easier to test as it’s available in the same file. Testing a directive with template referred from a file is tricky, as the directive makes an $httpBackend request to the templateUrl. Adding this template to $templateCache makes the task of testing easier and the template will be easy to share. This can be done using the grunt-html2js grunt task.

grunt-html2js is very easy to configure and to use. It needs the source path(s) of the html file(s) and a destination path where the resultant script has to be written. The following is the configuration used in the sample code:

html2js:{
  main: {
    src: ['src/directives/*.html'],
    dest: 'src/directives/templates.js'
  }
}

Now, all we need to do is to refer the module generated by this task in our code. By default, name of the module generated by grunt-html2js is templates-main but you can modify it.

Consider the following directive:

angular.module('sampleDirectives', ['templates-main'])
.directive('fourthDirective', function () {
  return {
    templateUrl: 'directives/sampleTemplate.html'
  };
});

And the content of template:

<h3>Details of person {{person.name}}<h3>
<another-directive></another-directive>

The template has another-directive element, which is another directive and it’s an important part of the template. Without anotherDirective directive, fourthDirective won’t work as expected. So, we have to validate the followings after the directive is compiled:

  1. If the template is applied inside the directive element
  2. If the template contains another-directive element

These are the tests to demonstrate these cases:

it('should applied template', function () {
  expect(directiveElem.html()).not.toEqual('');
});

it('should have another-person element', function () {
  expect(directiveElem.find('another-directive').length).toEqual(1);
});

You don’t need to write test for every single element in the directive’s template. If you feel that a certain element or directive is mandatory in the template, and without that the directive would not be complete, add a test to check for the existence of such component. Doing so, your test will complain if someone accidentally removes it.

Testing Directive’s Scope

A directive’s scope can be one of the following:

  1. Same as scope of surrounding element
  2. Inherited from scope of surrounding element
  3. Isolated scope

In the first case, you may not want to test the scope as the directive is not supposed to modify state of the scope when it uses the same scope. But in other cases, the directive may add some fields to the scope that drive behavior of the directive. We need to test these cases.

Let’s take an example of a directive using isolated scope. Following is the directive that we have to test:

angular.module('sampleDirectives').directive('fifthDirective', function () {
  return {
    scope:{
      config: '=',
      notify: '@',
      onChange:'&'
    }
  }
};
})

In the tests of this directive, we need to check if the isolated scope has all three properties defined and if they are assigned with the right values. In this case, we need to test the following cases:

  1. config property on isolated scope should be same as the one on scope and is two-way bound
  2. notify property on isolated scope should be one-way bound
  3. onChange property on isolated scope should be a function and the method on scope should be called when it is invoked

The directive expects something on the surrounding scope, so it needs a slightly different set up and we also need to get a reference of the isolated scope.

The snippet below prepares the scope for the directive and compiles it:

beforeEach(function() {
  module('sampleDirectives');
  inject(function ($compile, $rootScope) {
    compile=$compile;
    scope=$rootScope.$new();
    scope.config = {
      prop: 'value'
    };
    scope.notify = true;
    scope.onChange = jasmine.createSpy('onChange');
  });
  directiveElem = getCompiledElement();
});

function getCompiledElement(){
  var compiledDirective = compile(angular.element('<fifth-directive config="config" notify="notify" on-change="onChange()"></fifth-directive>'))(scope);
  scope.$digest();
  return compiledDirective;

Now that we have the directive ready, let’s test if the isolated scope is assigned with the right set of properties.

it('config on isolated scope should be two-way bound', function(){
  var isolatedScope = directiveElem.isolateScope();

  isolatedScope.config.prop = "value2";

  expect(scope.config.prop).toEqual('value2');
});

it('notify on isolated scope should be one-way bound', function(){
  var isolatedScope = directiveElem.isolateScope();

  isolatedScope.notify = false;

  expect(scope.notify).toEqual(true);
});

it('onChange should be a function', function(){
    var isolatedScope = directiveElem.isolateScope();

    expect(typeof(isolatedScope.onChange)).toEqual('function');
});

it('should call onChange method of scope when invoked from isolated scope', function () {
    var isolatedScope = directiveElem.isolateScope();
    isolatedScope.onChange();

    expect(scope.onChange).toHaveBeenCalled();
});

Testing Require

A directive may strictly or optionally depend on one or a set of other directives. For this reason, we have some interesting cases to test:

  1. Should throw error if a strictly required directive is not specified
  2. Should work if a strictly required directive is specified
  3. Should not throw error if an optionally required directive is not specified
  4. Should interact with controller of optional directive if it is found

The directive below requires ngModel and optionally requires form in a parent element:

angular.module('sampleDirectives').directive('sixthDirective', function () {
    return {
      require: ['ngModel', '^?form'],
      link: function(scope, elem, attrs, ctrls){
        if(ctrls[1]){
          ctrls[1].$setDirty();
      }
    }
  };
});

As you can see, the directive interacts with the form controller only if it is found. Though the example doesn’t make much sense, it gives the idea of the behavior. The tests for this directive, covering the cases listed above, are shown below:

function getCompiledElement(template){
  var compiledDirective = compile(angular.element(template))(scope);
  scope.$digest();
  return compiledDirective;
}

it('should fail if ngModel is not specified', function () {
  expect(function(){
    getCompiledElement('<input type="text" sixth-directive />');
  }).toThrow();
});

it('should work if ng-model is specified and not wrapped in form', function () {
  expect(function(){
    getCompiledElement('<div><input type="text" ng-model="name" sixth-directive /></div>');
  }).not.toThrow();
});

it('should set form dirty', function () {
  var directiveElem = getCompiledElement('<form name="sampleForm"><input type="text" ng-model="name" sixth-directive /></form>');

  expect(scope.sampleForm.$dirty).toEqual(true);
});

Testing Replace

Testing replace is very simple. We just have to check if the directive element exists in the compiled template. This is how you do that:

//directive
angular.module('sampleDirectives').directive('seventhDirective', function () {
  return {
    replace: true,
    template: '<div>Content in the directive</div>'
  };
});

//test
it('should have replaced directive element', function () {
  var compiledDirective = compile(angular.element('<div><seventh-directive></seventh-directive></div>'))(scope);
  scope.$digest();

  expect(compiledDirective.find('seventh-directive').length).toEqual(0);
});

Testing Transclude

Transclusion has two cases: transclude set to true and transclude set to an element. I haven’t seen many use cases of transclude set to element, so we will only discuss the case of transclude set to true.

We got to test the following to check if the directive supports transcluded content:

  1. If the template has an element with ng-transclude directive on it
  2. If the content is preserved

To test the directive, we need to pass some HTML content inside the directive to be compiled and then check for the above cases. This is a directive using transclude and its test:

//directive
angular.module('sampleDirectives').directive('eighthDirective', function(){
  return{
    transclude: true,
    template:'<div>Text in the directive.<div ng-transclude></div></div>'
  };
});

//test
it('should have an ng-transclude directive in it', function () {
    var transcludeElem = directiveElem.find('div[ng-transclude]');
    expect(transcludeElem.length).toBe(1);
});

it('should have transclude content', function () {
    expect(directiveElem.find('p').length).toEqual(1);
});

Conclusion

As you’ve seen in this article, directives are harder to test when compared with other concepts in AngularJS. At the same time, they can’t be ignored as they control some of the important parts of the application. AngularJS’s testing ecosystem makes it easier for us to test any piece of a project. I hope that thanks to this tutorial you are more confident to test your directives now. Let me know your thoughts in comment section.

In case you want to play with the code developed in this tutorial, you can take a look at the GitHub repository I set up for you.

Comments
Kogratte

Using html2js is an ugly way to test directive smile

First of all, the template markup isn't frozen.

It can, and would probably change when the project grows. You still can mock $templateCache and fill it, breaking dependency.

Consider this directive:

angular.module('sampleDirectives', ['templates-main'])
.directive('fourthDirective', function () {
return {
templateUrl: 'directives/sampleTemplate.html'
};
});

In your test, you just need to do this:

$templateCache.put("directives/sampleTemplate.html", "myFakeContent");

You still can test the updated scope, but right now you're not depending of an evolving file.

sravikiran

Kogratte,

Sure, $templateCache is a good alternative to test the directive without loading the template. But, at times the template would have certain important elements and you need to make sure that these elements are not modified or, removed from the template so that the directive works as expected. To assert such cases, you need to have tests verifying them against the actual template rather than a mocked response.

Recommended
Sponsors
Get the latest in JavaScript, once a week, for free.