Skip to content

Instantly share code, notes, and snippets.

@ianmcnally
Last active August 29, 2015 14:18
Show Gist options
  • Save ianmcnally/c986117b81f60bc107d3 to your computer and use it in GitHub Desktop.
Save ianmcnally/c986117b81f60bc107d3 to your computer and use it in GitHub Desktop.
My week in angular, js, testing

A week's small victories: Angular, Equality and Testing

Our work weeks are made up of victories both big and small. What really gets me writing is the small. The a-ha! moments. The high fives (internal ones count too). The green builds. The git pushes.

I spent my week creating dynamic forms and lists in Angular. I made them, fought with them, tested them, and came out the other side in one piece. Here's my story:

Writing and testing custom form validators

Custom form validators are a powerful feature in Angular.

Say I've got a form, and an input for a name. I want the name to be unique from a list of pre-existing names (shout out to Beatles fans):

form.html

<form name="nameform">
  <input name="nameinput" ng-model="fifthMember" unique-name-validator current-names="theBeatles" />
</form>

controller.js

$scope.theBeatles = ['John', 'George', 'Paul', 'Ringo'];

Creating a validator

Take a look at the attribute unique-name-validator and current-names. That's a reference to the custom validator, which wraps itself in a directive:

angular.module('addABeatle').directive('uniqueNameValidator', function(){
    return {
        require: 'ngModel',
        scope: {
            currentNames: '='
        },
        link: function($scope, _e, _a, modelController){
            modelController.$validators.uniqueName = function(newName){
                return !_.contains($scope.currentNames, newName);
            };
        }
    };
});

Requiring ngModel gives you access to the model controller that handles the ng-model="fifthBeatles". On that controller, a validator is added. It is simply a function that returns true if the input is valid, false if it's invalid.

From there, I use the current-names attribute to test if the name is unique (lodash used here). It gets attached to $scope when I isolate it on the directive.

Testing

Testing, after some setup work, is pretty straight forward.

You create an element and $compile it. To get a reference to the input, I leveraged Angular's behavior of attach forms onto the current $scope.

describe('directive: uniqueNameValidator', function(){
    var $scope, input;

    beforeEach(inject(function($compile, $rootScope){
        $scope = $rootScope.$new();
        $scope.theBeatles = ['John', 'George', 'Paul', 'Ringo'];
        var element = '<form name="nameform"><input name="nameinput" ng-model="fifthMember" unique-name-validator current-names="theBeatles" /></form>';
        $compile(element)($scope);
        input = $scope.nameform.nameinput;
    }));

Then the tests become a matter of setting the view's value, and testing for $valid or $invalid:

it('marks a unique name $valid', function(){
    input.$setViewValue('Pete Best'); // Beatles dropout!
    $scope.$digest();
    expect(input.$valid).toBe(true);
});

it('marks a not unique name $invalid', function(){
    input.$setViewValue('Paul');
    $scope.$digest();
    expect(input.$invalid).toBe(true);
});

Equality of shallow javascript arrays

I was working on a UI bit where you click on a modal to add or remove some items from a list. I wanted the modal to have a shallow copy of the items list, so that a cancel wouldn't update the original items list; only save would. Since a line of code is worth a thousand explanations:

controller.js

var items = ['Clean room', 'Do laundry'];

openModal = function() {
  modal.open(items.slice()); // passing in items to modal
};

modal.js

remove = function(item) {
  _.pull(items, item);
}

cancel = function() {
   this.close();
}

So when modal.cancel is called, controller.items should be unmodified.

In my unit test (jasmine, below), I wanted to test that modal.items was a shallow copy.

test.js

expect(modal.items).not.toEqual(controller.items);
// Fail

But that doesn't work. slice returns a new array, but maintains the references inside the array. This subtlety is lost on toEqual, and lodash/underscore's _.isEqual. Plain javascript to the rescue:

test.js

expect(modal.items !== controller.items).toBe(true);
// Pass

Oh yeah.

To cover my bases, I still used toEqual to test that controller.items was passed to modal.items so my test looked like:

expect(modal.items).toEqual(controller.items);
expect(modal.items !== controller.items).toBe(true);
// Pass

Testing Angular directive's bindToController

In an Angular directive, you can use the bindToController property to set your $scope injections on the controller instance. It all works great, but things get murky when it comes to testing.

If you want to pass in mock data in a directive controller during a test, you have to modify how you instantiate the controller. It turns out a third argument to $controller is boolean called later that will return a function.

On that function's instance property, you can add your mock injections. Then when you invoke the function, you'll get the controller instance with your mocks attached.

A quick example:

controller

angular.module('todoapp', [])
.directive('todoList', function(){
  scope: {
    items: '='
  },
  bindToController: true
  controller: 'Todo'
})
.controller('Todo', function(){
  var controller = this;
  
  controller.amountOfTodos = function(){
    return controller.items.length;
  };
});

test

describe('getAmountOfTodos', function(){
    var controller = $controller('Todo', {}, true);
    angular.extend(controller.instance, {
      items: [
          'Take out laundry'
      ]
    });
    controller = controller();
    
    it('returns the count of todo items', function(){
      expect(controller.getAmountOfTodos()).toEqual(1);
    });
});

Props. and open issue.

Making the most of ng-repeat and filters

Not too long ago, I wrote about storing the value of a filtered ng-repeat. But what if I wanted the display to show the filtered results, and I wanted to use the un-filtered results somewhere else in the template?

Consider this example:

<input ng-model="searchText" />
<ul>
  <li ng-repeat="item in getItems() | filter:searchText as items"></li>
</ul>
<span>{{items.length}} items!</span>

If getItems() returns two items, the span will show 2 items!. Once we start entering search text, getItems() will still return two items, but the repeat will be filtered. Then, the span would show 1 items! or 0 items! depending on what our searchText was.

To make sure the item count persists even when a user is filtering, you can modify how you assign the output of getItems():

<li ng-repeat="item in (items = getItems()) | filter:searchText"></li>

In that example, items is being assigned before filtering. So our items will remain the same, even when a user is searching. And when they search, the display will only show the filtered results.

Putting it all together:

<input ng-model="searchText" />
<ul>
  <li ng-repeat="item in (items = getItems()) | filter:searchText"></li>
</ul>
<span>{{items.length}} items!</span>

These may be small things. But this week, they represented huge victories.

About the author

Ian McNally is a Front End Specialist and Senior Software Consultant for Stride. He writes at ia-n.com and tweets @_ianmcnally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment