1/12/2015

ngTestHarness: Strap in with this new testing helper for Angular.js

This is the first time I can point to open source code that I took a large part in authoring. I am proud of the project that the very talented Team Titan at Gaikai, a Sony Entertainment Company, has created. While updating/adding unit tests for development and build purposes we became very disenchanted with the complexity required for creating unit tests in Angular. We are working with a multi-tier testing system so we weren't interested in an E2E system like Protractor, and do not want to use a server side component. We need something to test our code as indpendent modular pieces. Of course this is possible with Angular.

describe('Test the note-editor directive', function () {
  var $compile,
      $rootScope;
       
  beforeEach(function () {
      module('noteEditor');
      inject(function ($injector) {
        $compile = $injector.get('$compile');
        $rootScope = $injector.get('$rootScope');
      });
  });

  describe('Creates the editor div', function () {
    it('Adds the container div', function () {
      var elm = $compile('<my-note></my-note>')($rootScope);
      expect(elm.html()).toBe('<div class="editor-container"></div>');
    });
  });
});

Previously, I had discussed how to use Angular dependency injection with Jasmine to make successful Jasmine unit tests, which you can see above. I felt there was still too much boilerplate. Our tests were become way too bloated, and some suites began requiring scrolling until you even found the first test. I feel tests should be as simple to read as possible. An excellent test suite can double as developer documentation. All the injection boilerplate and angular requirements were taking up too much space. So, Team Titan went back to the drawing board. The result is a singleton which can be evoked at the beginning of a suite and reduces our Angular-Jasmine tests of directives down to as little as one line. Our tests can now be as simple as:

  describe('Test the note-editor directive', function() {
    var harness = new ngHarness(['noteEditor']);

    it('Adds the container div', function() {
      expect(harness.compileElement('<my-note></my-note>').html()).toBe('<div class="editor-container"></div>');
    });
  });

The ngHarness object manages all the necessary injection operations for us. The context is maintained as well since the object is available at the suite level. When it is time to change the context, you just create a new object. Let's take a look at a simplified version of the harness code to make elaboration easier:


"Let's take a look at the harness code"

The first, and most important, part of the harness is it's instantiation:

  function DirectiveTestHarness (modules){
    modules.unshift ('ng');
    
    this.angularFactory = angular.injector(modules);
    this.rootScope = this.angularFactory.get ('$rootScope');
    this.compile = this.angularFactory.get('$compile');
  }

The incoming parameter ("modules") should be an array and should contain all the modules you want in the testing context. In order for any of this to work we need to include Angular's context as well. The order can important here. We found fewer complications when Angular came first. Therefore, we add the 'ng' module into the first position in the modules array. Once we have all the modules identified it's time to inject them into the environment.

    modules.unshift ('ng');

We will be using the context that the injector creates. So to keep it around, we assign it as a property of our harness object.

  this.angularFactory = angular.injector(newArr);

The harness will need to maintain pointers to the Angular services we will need in the future, so we also attach them to the object.

  this.rootScope = this.angularFactory.get ('$rootScope');
  this.compile = this.angularFactory.get('$compile');

The instantiation is the bulk of the magic, but let's look at how to use it. The next step is to update the function prototype to make an object. The constructor is set to the constructor function.

  DirectiveTestHarness.prototype =  {
    constructor: DirectiveTestHarness,
}


"Now the harness is ready."

Now the harness is ready. What is left is to add the functions needed to compile and return our Angular objects.

  compileElement: function (html){
    var elm,
    compile = this.compile;
  
    scope = this.rootScope.$new();

    this.angularFactory.invoke(function() {
        elm = compile(html)(scope);
    });

    scope.$digest();
    return elm;
  }

The function takes the html for the directive and then goes through the compile process. Since we already have pointers to the services we need on our object we can refer to them here. Getting everything to compile correctly will require invoking those pointers. The invocation process requires a function to run which means we will lose the current context. In other words, "this" will no longer mean the same thing inside the invocation as outside. Luckily, closures come to our rescue. We maintain a local pointer to the compile service so that the "this" identifier isn't required inside the invoke function. We also prepare a new clean scope for our new directive.

 var elm,
 compile = this.compile,
 scope = this.rootScope.$new();

 this.angularFactory.invoke(function() {
     elm = compile(html)(scope);
 });

Finally we force a digest cycle to sync up data. The directive won't properly be generated without forcing the digest cycle. After the cycle runs, the directive is complete and we return the compiled element.

  scope.$digest();
  return elm;


"Let's revisit our example from above."

Let's revisit our example from above. Below is a very simplified directive we want to test:

  angular.module('noteEditor', [])
  .directive('myNote', function () {
    return {
      restrict: 'E',
      template: '<div class="editor-container"></div>'
    }
  });

The test is very easy to set up. We create a new harness, and pass in the module being tested:

  describe('Test the note-editor directive', function() {
    var harness = new ngHarness(['noteEditor']);

    it('Adds the container div', function() {
      expect(harness.compileElement('<my-note></my-note>').html()).toBe('<div class="editor-container"></div>');
    });
  });

The test closely resembles a non-angular test and is very easy to read. We load the modules we need to test into the harness at the suite level. Since, the harness doesn't store the scope or html of an element internally, we can use it for each test without re-initializing it. In the previous article we were using suites inside suites to maintain object context. This is no longer necessary since we have a singleton to perform the same operation. We only need to reset the harness when the modules change.

The actual ngHarness implementation is much more complicated than what is shown here. However, what this article does show is how it works, how it's simple structure means we can expand it easily. If this article proves helpful, then I'll write up more about how to use the ngHarness. Please go take a look at it, and help us make it better with pull requests and issue submission.

Happy Testing!

Github Project Page: http://github.com/Gaikai/ngTestHarness

Update 7/22/15: ngTestHarness is now available via npm ngtestharness