Directives are a feature of Angular, and thus Ionic, allowing us to make tags powered by JavaScript.

Last time, we talked about using Generator M to quickly build up a mobile app.

We will now use Test Driven Development and add a directive to our app.

What we need

  1. Scaffold an app with Ionic using Generator M
  2. Some knowledge on testing angular directives

Setting up

Make sure you’ve installed the testing pre-reqs:


$ sudo npm install -g generator-karma karma jasmine-node karma-jasmine
$ cd project
$ bower install --save-dev angular-mocks
$ yo karma

Karma generator sets up test/karma.conf.js – in here we’ll want to make the following changes:


config.set({
  // ...
  basePath: '../app',
  // ...
  files: [
      'bower_components/angular/angular.js',
      'bower_components/angular-mocks/angular-mocks.js',
      'bower_components/angular-animate/angular-animate.js',
      'bower_components/angular-sanitize/angular-sanitize.js',
      'bower_components/angular-ui-router/release/angular-ui-router.js',
      'bower_components/ionic/js/angular/main.js',
      'bower_components/ngCordova/dist/ng-cordova.js',
      '*.js',
      'main/*.js',
      'main/**/*.js',
      'main/**/*.html',
      '../test/karma/*.js'
  ],
  // ...
})

Now Karma can find everything it’s looking for

Run the tests.

karma start test/karma.conf.js

Without any tests, you should see a notice about 0 out of 0 tests passing. Good.

Writing our first test


This in a file at project/test/karma/sound-button.js:

'use strict';

describe('Directive: soundButton', function() {
  var element,
    scope;

  var audioMock = {
    play: function () { return true; },
    pause: function () { return true; }
  };

  beforeEach(module('main'));
  beforeEach(inject(function($rootScope, $compile) {
    window.Audio = function(src) {
      audioMock.src = src;
      return audioMock;
    };

    element = angular.element('<div class="well span6">' +
      '<h3>Sounds:</h3>' +
      '<sound-button sound="">' +
      '</sound-button></div>');

    scope = $rootScope;
    scope.sound = 'nobell.mp3';

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

  it("should play and pause on click", function() {
    spyOn(audioMock, 'play');
    spyOn(audioMock, 'pause');

    angular.forEach(element.find('sound-button'), function(e) {
      expect(audioMock.src).toBe(e.getAttribute('sound'));

      var ngElement = angular.element(e);
      ngElement.triggerHandler('click');
      expect(audioMock.play).toHaveBeenCalled();
      ngElement.triggerHandler('click');
      expect(audioMock.pause).toHaveBeenCalled();
      expect(audioMock.currentTime).toBe(0);
    });
  });
});

But what does it do?

The test that we’ve just written should be failing. You can check by re-running the karma start test/karma.conf.js command.

Taking a look inside, we see a mock object:


  var audioMock = {
    play: function () { return true; },
    pause: function () { return true; }
  };

The audioMock object is our gateway into the directive. It allows us to make sure methods are being called. In this case, we want to be certain the audio is started and stopped.

The beforeEach() commands run, well, before each test.


  beforeEach(module('main'));
  beforeEach(inject(function($rootScope, $compile) {
    window.Audio = function(src) {
      audioMock.src = src;
      return audioMock;
    };

    element = angular.element('<div class="well span6">' +
      '<h3>Sounds:</h3>' +
      '<sound-button sound="">' +
      '</sound-button></div>');

    scope = $rootScope;
    scope.sound = 'nobell.mp3';

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

In this case, element and scope get compiled together to render our sound-button directive.

we’re using the module() and inject() helpers that comes with Angular mocks. Additionally, we’re defining the Audio object to create our mocks – primarily because Jasmine doesn’t know what the Audio tag is.

Note that our constructor for window.Audio binds SRC onto the audio mock. This is less than ideal – we can only have one active mock at a time, unless we set up an array which we aren’t going to do.

Then we get to our actual test:


  it("should play and pause on click", function() {
    spyOn(audioMock, 'play');
    spyOn(audioMock, 'pause');

    angular.forEach(element.find('sound-button'), function(e) {
      expect(audioMock.src).toBe(e.getAttribute('sound'));

      var ngElement = angular.element(e);
      ngElement.triggerHandler('click');
      expect(audioMock.play).toHaveBeenCalled();
      ngElement.triggerHandler('click');
      expect(audioMock.pause).toHaveBeenCalled();
      expect(audioMock.currentTime).toBe(0);
    });
  });

This sets up our spies on the mock audio object, and then iterates over each sound-button element. During each iteration, we check to make sure that clicking the element triggers play and pause events respecitvely, and that the second event is actually a stop command (i.e. sets time to 0).

Passing the test

We actually kinda want to write a directive, right? That was the whole idea.

This directive below should make the test pass.


This file belongs in app/main/directives/sound-button.js

'use strict';

angular.module('main')
.directive('soundButton', function () {
  return {
    template: '<div class="sound-button button button-positive"><div class="button-text">Play</div></div>',
    restrict: 'E',
    link: function postLink (scope, element, attrs) {
      var sound = new Audio(attrs.hasOwnProperty('sound') ? attrs.sound : '');
      var buttonTextElement = element[0].firstChild.firstChild;

      sound.loop = attrs.hasOwnProperty('loop');

      var hideStop = function() {
        buttonTextElement.innerHTML = '';
        element.off('click', stopHandler);
      };

      var showStop = function() {
        buttonTextElement.innerHTML = 'Stop';
        element.on('click', stopHandler);
      };

      var hidePlay = function() {
        buttonTextElement.innerHtml = '';
        element.off('click', playHandler);
      };

      var showPlay = function() {
        buttonTextElement.innerHTML = 'Play';
        element.on('click', playHandler);
      };

      var stopHandler = function() {
        sound.pause();
        sound.currentTime = 0;
        hideStop();
        showPlay();
      };

      var playHandler = function() {
        sound.play();
        hidePlay();
        showStop();
      };
      element.on('click', playHandler);
    }
  };
});

Basically, we define a template inside the directive, restrict the directive to element bindings (i.e. <sound-button>), setup the audio event, and attach click handlers.

The click handlers play/pause as well as update the UI. We could have added CSS animations, but that’s a-whole-nother 1am blog post.

Running the tests again should give you an all-green.

Conclusion

Karma is powerful. It wasn’t too hard to attach Karma to our Ionic application (once you get the paths right).

The power to write tests for directives, complete with click handling, is yours!