Writing Testable Application in AngularJS – Part 2


In the last post, we’ve setup yeoman and karma, and then scaffold our application. If you look at app/app.js which currently contains the routing information. Yeoman generator has automagically mapped #/main route and created its associated files mainly main.js and main.html – which we do not need for this application.

We’ll do some cleanup first.

Cleanup

In index.html, remove the unnecessary Google Analytics code and main.js included. Delete following files:

$ cd eShell
$ rm app/scripts/controllers/main.js app/views/main.html test/spec/controllers/main.js

And finally replace

<div class="container"></div>

with

<div class="navbar navbar-inverse navbar-fixed-top">
  <div class="navbar-inner">
    <div class="container"><a class="brand" href="#">eShell - A player for eLearning Courses</a></div>
  </div>
</div>
<div style="margin-top: 40px;"><div ng-view></div></div>
<div class="navbar navbar-inverse navbar-fixed-bottom ng-cloak">
  <div class="navbar-inner">
    <div class="container">
      <div class="btn-group pull-left"><button class="btn btn-inverse input-small" type="text" disabled="disabled" value="Attempt 1 of 3"></button></div>
      <div class="btn-group pull-left">
        <button class="btn btn-primary" type="button">     Submit      </button>
        <button class="btn btn-warning" type="button">   Try Again   </button>
        <button class="btn btn-success" type="button">Show Answer</button>
      </div>
      <div class="btn-group pull-right">
        <button class="btn">«</button>
        <button class="btn">1 of 4</button>
        <button class="btn">»</button>
      </div>
    </div>
  </div>
</div>

First Route

Using yeoman, it takes a split second to create a new route. Just run the following command in a Terminal:

$ yo angular:route fib
create app/scripts/controllers/fib.js
create test/spec/controllers/fib.js
create app/views/fib.html

As you could see, this has created 3 files, a controller, a view and a test. If you look at app.js now, you will see a new route information has automagically been inserted without breaking a thing. Love it!

Lets now update our app.js from:

angular.module('eShellApp', []).config(function ($routeProvider) {
  $routeProvider
    .when('/', {
    templateUrl: 'views/main.html',
    controller: 'MainCtrl'
  })
  .when('/fib', {
    templateUrl: 'views/fib.html',
    controller: 'FibCtrl'
  })
  .otherwise({
    redirectTo: '/'
  });
});

to:

angular.module('eShellApp', []).config(function ($routeProvider) {
  $routeProvider
  .when('/1', {
    templateUrl: 'views/fib.html',
    controller: 'FibCtrl'
  })
  .otherwise({
    redirectTo: '/1'
  });
});

Go and check out http://localhost:9000/

Creating our First Template – Fill in the blanks

This template creates a fill in the blank question backed by JSON data including valid answers. To read the JSON file, angular gives a nifty little service, $http that facilitates communication with the remote HTTP servers via the browser’s XMLHttpRequest object or via JSONP. The reason why I prefer to use $http service because it takes single argument (object) and returns a promise with two specific methods, success and error as it is based on Deferred/Promise APIs.

so, lets create fib.json and put data:

$ touch app/data/fib.json
{
  "text":"AngularJS is originally created in [[1]] by [[2]] and [[3]].",
  "choices":[
    {
      "val1":"[[1]]",
      "val2":"2009,09"
    },
    {
      "val1":"[[2]]",
      "val2":"misko,Misko Hevery"
    },
    {
      "val1":"[[3]]",
      "val2":"adam,adam abrons"
    }
  ]
}

Now, lets modify our fib.js controller to read the above JSON data. We’ll first expose $http object to our controller and $compile service to process the data before injecting into a view.

angular.module('eShellApp').controller('FibCtrl', function ($scope, $http, $compile) {
  $http.get('data/fib.json').then(function(data) {
    $scope.data = data.data;
    var text = '<div>' + data.data.text + '</div>';

    // A bit complex logic to replace every instance of [] with input boxes
    data.data.choices.forEach(function(choice, index) {
      text = text.replace(
        new RegExp(choice.val1.replace(/\[/g, '\\[').replace(/\]/g, '\\]'), 'g'),
        '<span class="control-group">' +
        ' <input type="text" class="input-medium" ' +
        '   autocorrect="off" ' +
        '   autocapitalize="off" ' +
        '   autocomplete="off" ' +
        '   ng-change="validate(frmFIB.$invalid)" ' +
        '   required ' +
        '   data-index="' + index + '" ' +
        '   ng-model="input_' + index + '" />' +
        '</span>'
      );
    });
    $scope.text = $compile(text)($scope).html();
  });
});

We’re just looping through choices we’ve in JSON and replacing the occurrences of [] with input boxes. Notice that I’ve used ng-change directive that will call validate() method on every keypress to toggle submit button. You may be wondering why have I wrapped data.data.text in a div? That’s because $compile service requires it, otherwise you will get some nasty error. Finally I’ve bound compiled text to $scope in order to use it in a view below.

<div class="contentWrapper">
  <div class="content">
    <h3 class="pagination-centered">Fill in the Blanks</h3>
    <form class="form-inline" name="frmFIB" novalidate="">
      <div class="pagination-centered" ng-bind-html-unsafe="text"></div>
    </form>
  </div>
</div>

Again, ng-bind-html-unsafe evaluates the expression (text, in this case) and put it inside the element it bound to (div, in this case). In addition, it is also helpful to get rid of FOUC or precompiled values issue with angular (where you see {{expr}} before the evaluation, mostly in IE).

Enter the Dragon: Directive

Its very bad to do the DOM manipulation inside controller and directive is the best option Angular provides. Lets create our first directive.

$ yo angular:directive parseBracket
create app/scripts/directives/parseBracket.js
create test/spec/directives/parseBracket.js

Now we’ll move the entire parsing logic into above directive. But before that lets replace ng-bind-html-unsafe we’d used earlier with parse-bracket.

<div class="contentWrapper">
  <div class="content">
    <h3 class="pagination-centered">Fill in the Blanks</h3>
    <form class="form-inline" name="frmFIB" novalidate="">
      <div class="pagination-centered" parse-bracket="{{data}}"></div>
    </form>
  </div>
</div>

We’re sending JSON object (data) being parsed to the directive.

According to the author, Directive is a way to teach HTML a new trick. In this case, I’m restricting the directive to be an attribute only and inside link function, $observe observes the changes of parseBracket attribute which contains interpolation. Rest is same borrowed from the controller ;-). You can learn more about directive on adobe or onehungrymind.

angular.module('eShellApp').directive('parseBracket', function ($compile) {
  return {
    restrict: 'A',
    link: function postLink(scope, element, attrs) {
      attrs.$observe('parseBracket', function(val) {
        var params = angular.fromJson(val),
            text = '<div>' + params.text + '</div>';

        if (angular.isDefined(params.choices)) {
          params.choices.forEach(function(choice, index) {
            text = text.replace(
              new RegExp(choice.val1.replace(/\[/g, '\\[').replace(/\]/g, '\\]'), 'g'),
              '<span class="control-group">' +
                '<input type="text" ' +
                  'class="input-medium" ' +
                  'autocorrect="off" ' +
                  'autocapitalize="off" ' +
                  'autocomplete="off" ' +
                  'ng-change="validate(frmFIB.$valid)" ' +
                  'ng-model="input_' + index + '"' +
                  'required />' +
              '</span>'
            );
          });
        }

        element.html(text);
        $compile(element.contents())(scope);
      });
    }
  };
});

Lets Test ’em

You have noticed that when we created the directive using yeoman generator, it also created the test file for the same. We’re going to modify it a bit to pass through the test.


describe('Directive: parseBracket', function () {
  beforeEach(module('eShellApp'));

  var element, $scope;

  it('should replace [] with input boxes', inject(function ($rootScope, $compile) {
    $scope = $rootScope.$new();
    $scope.data = {
      text    : 'AngularJS is [[1]]',
      choices : [{
        "val1": "[[1]]",
        "val2": "awesome,powerful"
      }]
    };
    element = angular.element('<div parse-bracket="{{data}}"></div>');
    element = $compile(element)($scope);
    $rootScope.$digest();

    expect(element.html()).toBe('<div class="ng-scope">AngularJS is <span class="control-group" ng-class="setClass(input_0_valid)"><input type="text" class="input-medium ng-pristine ng-invalid ng-invalid-required" autocorrect="off" autocapitalize="off" autocomplete="off" ng-change="validate(frmFIB.$valid)" ng-readonly="input_0_readonly" ng-model="input_0" required=""></span></div>');
  }));
});

Wrap up

In a next post, We’ll clean up our controller and write some tests for it too. Checkout Part 3.

If you found this article useful in anyway, feel free to donate me and receive my dilettante painting as a token of appreciation for your donation.
Advertisement

6 thoughts on “Writing Testable Application in AngularJS – Part 2

  1. Hi!

    First – thanks for posting.

    I am trying to follow and after smooth part 1, I have problems in part 2.

    1. In part 1 you named the app folder ‘testable-angular-app’, so yeoman sees this as an App name and scaffolding uses it: angular.module(‘testableAngularApp’). In part 2 in the snippets you use ‘eShellApp’ as the App name, so each time I scaffold new entity – I have to manually change the module name.

    To solve this – I have edited component.json file and replaced
    “name”: “testableAngularApp”
    to
    “name”: “eShell”
    (without the ‘App’, it is added by the scaffolding…).

    2. In the main snippet you do not have “ng-view” at all. This is causing Angular not to handle routing at all. It just stays at “localhost:9000”, not getting redirected to “localhost:9000#/1”. There is also no container for the view.

    To solve this I have added the “” under the “” (line 7 in your snippet)

    3. The $scope.text was rendered as “[[object HTMLDivElement]]”. Looking at what $compile(text)($scope) produced showed that it is JQLite Object.

    To solve this, I added “.html()” to the command:
    $scope.text = $compile(text)($scope).html();

    And now it works. I got as far as “Enter the Dragon: Directive”.

    Despite those small issues, I am enjoying myself 🙂

  2. Hi, again.

    Found 1 issue in “Enter the Dragon: Directive” also:

    The test has wrong result string (“toBe(…)”). It should be:
    ‘AngularJS is ‘

    And one more thing:
    You are using “attrs.$observe()” to catch up with controller’s “data” value, but there is “Isolated Scope” thingy. I personally feel it is more “angularly” to use it here(?) This does not seem to make less code, but nevertheless.
    I thought of 2 examples. In both we have to use scope.$watch(“parseBracket”), similar to “attrs.$observe()”:

    1
    .directive(‘parseBracket’, function ($compile) {
    return {
    scope: {
    parseBracket: ‘@’
    },
    link: function postLink(scope, element, attrs) {
    scope.$watch(‘parseBracket’, function () {
    var params = angular.fromJson(scope.parseBracket);


    Here the controller template is unchanged:

    In the directive we get “scope.parseBracket” set to JSON string of “data”. We still need to parse it into object.

    2.
    directive(‘parseBracket’, function ($compile) {
    return {
    scope: {
    parseBracket: ‘=’
    },
    link: function postLink(scope, element, attrs) {
    scope.$watch(‘parseBracket’, function () {
    var params = scope.parseBracket;


    No JSON string parsing needed, as we get the “data” as is from the controller ( = object).
    The controller’s template should be updated (“{{“, “}}” need to be removed from the “parse-bracket” attribute’s value):

    The one thing that might not so good about #2 is that we have here two-way binding, meaning, that changing the “scope.parseBracket” in the directive will change “data” on the controller.

    Thanks again!

  3. In your testing code, which test framework do you use? Jasmine or ng-scenario? And what karma really do in the testing? Which role does it play?

    • I’ve used both, Jasmine for unit testing the code and Angular Scenario Runner for end to end testing of the application similar to Selenium. Karma Test Runner is sort of manager that takes care of both testing frameworks and makes it easy to run tests. Karma basically automates the testing where tests are written in Jasmine and ng-scenario or any other testing framework.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.