Testing AngularJS Directives With Jasmine

When I first started using Angular one area I was particularly confused about was custom directives, and specifically how to test custom directives. I had a vague notion that directives can interact with the DOM (something which is to be avoided in controllers), but I didn’t really know when to use directives or how to test them (something which is pretty straightforward in controllers).

In this post I’m going to build a simple directive to reduce some duplication and show how to test it with Jasmine.

I have created a simple example which uses tab-based navigation.

<div ng-controller="ExampleController as ctrl">
  <ul>
    <li ng-class="{ active: ctrl.isActive('/home') }">
      <a href="#/home">Home</a>
    </li>
    <li ng-class="{ active: ctrl.isActive('/products') }">
      <a href="#/products">Products</a>
    </li>
    <li ng-class="{ active: ctrl.isActive('/orders') }">
      <a href="#/orders">Orders</a>
    </li>
  </ul>
</div>

Note that I am using a controller function to specify when the link should have the active class.

app.controller "ExampleController", ($location) ->
  @isActive = (path) ->
    $location.path() is path

  @

In a previous post I illustrated how we can change this controller function into a directive. I am going to do the same thing here, but I am going to test the directive. A simple way to change this controller function into a directive is to listen for the $locationChangeSuccess event and simply set the active class on the appropriate element in question.

A simple implementation for this directive might look something like this:

app.directive 'exActiveLink', ($location) ->
  restrict: 'A',
  scope:
    path: "@exActiveLink"
  link: (scope, element, attributes) ->
    scope.$on '$locationChangeSuccess', ->
      if $location.path() is scope.path
        element.addClass('active')
      else
        element.removeClass('active')

This works as expected, and we can now change our template to use this directive.

<div class="main" ng-controller="ExampleController as ctrl">
  <ul>
    <li ex-active-link="/home">
      <a href="#/home">Home</a>
    </li>
    <li ex-active-link="/products">
      <a href="#/products">Products</a>
    </li>
    <li ex-active-link="/orders">
      <a href="#/orders">Orders</a>
    </li>
  </ul>
</div>

This is a bit cleaner - and more performant. We can also get rid of the controller function. This is import - not because the controller function was bad, but because if we needed this type of navigation anywhere else we would have needed to duplicate the controller function (or re-use the controller, which can quickly become painful).

So how do we go about testing this directive? This is where I initially struggled when I first started learning angular - should we try and test the link function directly? The link (and compile) functions on directives are internal to angular - they are are tied to the angular page lifecycle. We therefore cannot access these functions directly. The only way to test the link function is to create an actual template that uses the directive.

Let’s see what that looks like.

describe "exActiveLink", ->
  element = null
  $location = null
  scope = null

  beforeEach ->
    module "example"

    inject ($compile, $rootScope, _$location_) ->
      $location = _$location_
      scope = $rootScope.$new()

      element = angular.element "<div ex-active-link='/products' />"
      $compile(element)(scope)
      scope.$digest()

  describe "when the location changes", ->
    describe "when the current path is the link's path", ->
      beforeEach ->
        spyOn($location, "path").and.returnValue("/products")
        scope.$broadcast "$locationChangeSuccess"

      it "adds the active class to the link", ->
        expect(element.hasClass("active")).toBeTruthy()

    describe "when the current path is not the link's path", ->
      beforeEach ->
        spyOn($location, "path").and.returnValue("/home")
        scope.$broadcast "$locationChangeSuccess"

      it "does not add the active class to the link", ->
        expect(element.hasClass("active")).toBeFalsy()

For this test I am creating a real angular element which uses the directive. We have to use the $compile function (to force the element into the ‘angular world’) and specify the scope to be used (which is very normal in angular tests). Finally we have to manually force angular to run a digest cycle.

Once the initial setup is done the rest of the test is more straightforward - we simply spy on the injected $location service and test the different scenarios. We have to go this route because we cannot test the link function any other way.

A more complicated directive

This directive is still rather simple - it’s really only 5 lines of code (plus some configuration). Many custom directives have custom templates and specify their own controllers - how do we go about testing these?

To illustrate how this works I am going to change my directive - currently the directive only takes care of setting the active class on the navigation link, but we could easily change the directive to output the entire navigation (li) element. This would make re-use across the application even easier.

app.directive "exNavigationLink", ->
  restrict: "E",
  replace: true,
  template: '''
    <li ng-class="{ active: ctrl.isActive(path) }">
      <a href="#{{ path }}">{{ name }}</a>
    </li>
  '''
  scope:
    path: "@"
    name: "@"
  controller: "exNavigationLinkController as ctrl"

app.controller "exNavigationLinkController", ($location) ->
  @isActive = (path) ->
    $location.path() is path

  @

This directive generates the entire navigation link and maintains the active link. This simplifies our template even further (and allows for better re-use across the app).

<ul>
  <ex-navigation-link path="/home" name="Home"></ex-navigation-link>
  <ex-navigation-link path="/products" name="Products"></ex-navigation-link>
  <ex-navigation-link path="/orders" name="Orders"></ex-navigation-link>
</ul>

Looking at the directive implementation, it might seem that I am going back on what I said earlier (in terms of not having the isActive function in the controller). However, in this case the controller is tied directly to the directive, so it makes perfect sense to extract the function into the controller. This also makes testing a breeze - I no longer need to test the link function, since we didn’t implement anything in the link function. We now simply need to test a regular controller function.

describe "exNavigationLink", ->
  ctrl = null
  $location = null

  beforeEach ->
    module "example"

    inject ($controller, _$location_) ->
      $location = _$location_
      ctrl = $controller("exNavigationLinkController")

  describe "#isActive", ->
    beforeEach ->
      spyOn($location, "path").and.returnValue("/products")

    describe "when the current path is the specified path", ->
      it "returns true", ->
        expect(ctrl.isActive("/products")).toBeTruthy()

    describe "when the current path is not the specified path", ->
      it "returns true", ->
        expect(ctrl.isActive("/home")).toBeFalsy()

Note that I am deliberately creating the function on the controller, not on the scope. This makes the function even easier to test.

These are the 2 patterns I am familiar with for testing directives. The first pattern (using a real, compiled angular element) doesn’t scale well, especially for more complicated directives. The best approach is therefore to extract logic into controller functions, which can be tested in isolation.

You can find all the code samples (including both directives and tests) on Plunker. Happy coding.