Angular Directive Testing Best Practices

Getting in the habit of testing is hard, but I’ve found that a big turning point for me when I start testing in new languages and tools is to identify patterns in how tests are written. Once I know a pattern for testing something, it’s easier to walk through each step to write my tests. Just like anything else, it’s more approachable when you break it apart into smaller pieces.

When testing directives, this is the main pattern I see:

  1. Mock
  2. Compile
  3. Interact
  4. Assert

An Example Directive

I’m going to walk through each step in the directive testing pattern for a directive I wrote recently. Here is the source of the radioButton directive, which provides a way to use custom style and markup for a radio form control.

angular
  .module('kit.forms', [])
  .directive('radioButton', radioButtonDirective);

function radioButtonDirective() {
  return {
    require: '?ngModel',
    link: function(scope, element, attributes, ngModelCtrl) {
      element.addClass('button');

      ngModelCtrl.$render = function() {
        element.toggleClass('primary', angular.equals(ngModelCtrl.$modelValue, scope.$eval(attributes.radioButton)));
      };

      element.bind('click', function() {
        if (!element.hasClass('active')) {
          scope.$apply(function () {
            ngModelCtrl.$setViewValue(scope.$eval(attributes.radioButton));
            ngModelCtrl.$render();
          });
        }
      });
    }
  };
}

Note: This example shows some of the power of ngModelController, which I think is pretty cool. If you’re interested in learning more about what you can do with ngModel, send me an email or let me know in the comments.

Mock

The first thing we need to do is mock out our dependencies. In this example, we are mocking out the kit.forms module and injecting a new instance of $rootScope to use later in our tests.

describe('Forms module', function() {
  var element, scope;

  beforeEach(module('kit.forms'));

  beforeEach(inject(function($rootScope) {
    scope = $rootScope.$new();
  }));

  // ...
});

Compile

Directives need to be compiled in order to make assertions on the HTML they produce. This can be done by injecting the $compile service and running a string of HTML through it. After it is compiled, the digest cycle needs to be kicked off with a call to $digest()from the $scope instance created earlier.

describe('[required]', function() {
  beforeEach(inject(function($compile) {
    element = angular.element(
      '<div class="button-group">' +
        '<label ng-model="model" radio-button="'yes'">Yes</label>' +
        '<label ng-model="model" radio-button="'no'">No</label>' +
        '<label ng-model="model" radio-button="'maybe'">Maybe</label>' +
      '</div>'
    );

    $compile(element)(scope);
    scope.$digest();
  }));

  // ...
});

Interact

Now you can script some interactions on the compiled HTML before you make assertions. Storing a reference to the element(s) you want to manipulate and make assertions against at the beginning of your specs is usually helpful.

it('applies the "primary" class when clicked', function() {
  var radios = element.find('.button');

  radios.eq(1).click();
  // ...

  radios.eq(2).click();
  // ...
});

Assert

Finally, we can make assertions that the markup has changed the way it’s expected to.

it('applies the "primary" class when clicked', function() {
  var radios = element.find('.button');

  radios.eq(1).click();
  expect(radios.eq(1)).toHaveClass('primary');
  expect(radios.eq(0)).not.toHaveClass('primary');
  expect(radios.eq(2)).not.toHaveClass('primary');

  radios.eq(2).click();
  expect(radios.eq(2)).toHaveClass('primary');
  expect(radios.eq(1)).not.toHaveClass('primary');
});

Sometimes the interaction step isn’t necessary for making assertions. For example, it’s usually best to assert that a directive initially compiles the way you expect it to.

it('adds the "button" class', function() {
  expect(element.find('.button').length).toBe(3);
});

E2E Testing Directives

When I first started testing directives I thought I was doing it wrong because I was testing the DOM but I wasn’t using a true integration testing tool, like Protractor. However, I realized a lot of times it’s hard to know the context a directive is going to be used in so it’s difficult to end-to-end test effectively. In most cases it’s easier to avoid an integration testing tool for an individual directive test and just rely on that for flexing the main flows of your SPAs.

Other Patterns

Directives are easily the most difficult thing to test in Angular, but I’ll be sharing some other Angular testing patterns and best practices I use over the next few weeks. Make sure to subscribe to email updates if you want to keep up with this series of articles on Angular testing and please share any other patterns you’ve seen in the comments.

Leave a Reply

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