mindparse
mindparse

Reputation: 7265

Mocking out required controllers in directive tests

I am having a hard time trying to figure out how I mock out a required controller for a directive I have written that's the child of another.

First let me share the directives I have:

PARENT

angular
    .module('app.components')
    .directive('myTable', myTable);

function myTable() {
    var myTable = {
        restrict: 'E',
        transclude: {
            actions: 'actionsContainer',
            table: 'tableContainer'
        },
        scope: {
            selected: '='
        },
        templateUrl: 'app/components/table/myTable.html',
        controller: controller,
        controllerAs: 'vm',
        bindToController: true
    };

    return myTable;

    function controller($attrs, $scope, $element) {
        var vm = this;
        vm.enableMultiSelect = $attrs.multiple === '';
    }
}

CHILD

angular
    .module('app.components')
    .directive('myTableRow', myTableRow);

myTableRow.$inject = ['$compile'];

function myTableRow($compile) {
    var myTableRow = {
        restrict: 'A',
        require: ['myTableRow', '^^myTable'],
        scope: {
            model: '=myTableRow'
        },
        controller: controller,
        controllerAs: 'vm',
        bindToController: true,
        link: link
    };

    return myTableRow;

    function link(scope, element, attrs, ctrls) {

        var self = ctrls.shift(),
            tableCtrl = ctrls.shift();

        if(tableCtrl.enableMultiSelect){
            element.prepend(createCheckbox());
        }

        self.isSelected = function () {
            if(!tableCtrl.enableMultiSelect) {
                return false;
            }
            return tableCtrl.selected.indexOf(self.model) !== -1;
        };

        self.select = function () {
            tableCtrl.selected.push(self.model);
        };

        self.deselect = function () {
            tableCtrl.selected.splice(tableCtrl.selected.indexOf(self.model), 1);
        };

        self.toggle = function (event) {
            if(event && event.stopPropagation) {
                event.stopPropagation();
            }

            return self.isSelected() ? self.deselect() : self.select();
        };

        function createCheckbox() {
            var checkbox = angular.element('<md-checkbox>').attr({
                'aria-label': 'Select Row',
                'ng-click': 'vm.toggle($event)',
                'ng-checked': 'vm.isSelected()'
            });

            return angular.element('<td class="md-cell md-checkbox-cell">').append($compile(checkbox)(scope));
        }
    }

    function controller() {

    }
}

So as you can probably see, its a table row directive that prepends checkbox cells and when toggled are used for populating an array of selected items bound to the scope of the parent table directive.

When it comes to unit testing the table row directive I have come across solutions where can mock required controllers using the data property on the element.

I have attempted this and am now trying to test the toggle function in my table row directive to check it adds an item to the parent table directive's scope selected property:

describe('myTableRow Directive', function() {
  var $compile,
    scope,
    compiledElement,
    tableCtrl = {
      enableMultiSelect: true,
      selected: []
    },
    controller;

  beforeEach(function() {
    module('app.components');
    inject(function(_$rootScope_, _$compile_) {
      scope = _$rootScope_.$new();
      $compile = _$compile_;
    });

    var element = angular.element('<table><tbody><tr my-table-row="data"><td></td></tr></tbody></table>');

    element.data('$myTableController', tableCtrl);
    scope.data = {foo: 'bar'};
    compiledElement = $compile(element)(scope);
        scope.$digest();
    controller = compiledElement.controller('myTableRow');

  });

  describe('select', function(){
    it('should work', function(){
      controller.toggle();
      expect(tableCtrl.selected.length).toEqual(1);
    });
  });
});

But I'm getting an error:

undefined is not an object (evaluating 'controller.toggle')

If I console log out the value of controller in my test it shows as undefined.

I am no doubt doing something wrong here in my approach, can someone please enlighten me?

Thanks

UPDATE

I have come across these posts already:

Unit testing a directive that defines a controller in AngularJS

How to access controllerAs namespace in unit test with compiled element?

I have tried the following, given I'm using controllerAs syntax:

var element = angular.element('<table><tr act-table-row="data"><td></td></tr></table>');
  element.data('$actTableController', tableCtrl);
  $scope.data = {foo: 'bar'};
  $compile(element)($scope);
  $scope.$digest();
  console.log(element.controller('vm'));

But the controller is still coming up as undefined in the console log.

UPDATE 2

I have come across this post - isolateScope() returning undefined when testing angular directive

Thought it could help me, so I tried the following instead

console.log(compiledElement.children().scope().vm);

But still it returns as undefined. compiledElement.children().scope() does return a large object with lots of angular $$ prefixed scope related properties and I can see my vm controller I'm trying to get at is buried deep within, but not sure this is the right approach

UPDATE 3

I have come across this article which covers exactly the kind of thing I'm trying to achieve.

When I try to implement this approach in my test, I can get to the element of the child directive, but still I am unable to retrieve it's scope:

beforeEach(function(){
    var element = angular.element('<table><tr act-table-row="data"><td></td></tr></table>');
    element.data('$actTableController', tableCtrl);
    $scope.data = {foo: 'bar'};
    compiledElement = $compile(element)($scope);
    $scope.$digest();
    element = element.find('act-table-row');
    console.log(element);
    console.log(element.scope()); //returns undefined
});

I just wonder if this is down to me using both a link function and controllerAs syntax?

Upvotes: 5

Views: 1261

Answers (2)

JcT
JcT

Reputation: 3569

You were very close with the original code you'd posted. I think you were just using .controller('myTableRow') on the wrong element, as your compiledElement at this point was the whole table element. You needed to get a hold of the actual tr child element in order to get the myTableRow controller out of it.

See below, specifically:

controller = compiledElement.find('tr').controller('myTableRow');

/* Angular App */
(function() {
  "use strict";

  angular
    .module('app.components', [])
    .directive('myTableRow', myTableRow);

  function myTableRow() {
    return {
      restrict: 'A',
      require: ['myTableRow', '^^myTable'],
      scope: {
        model: '=myTableRow'
      },
      controller: controller,
      controllerAs: 'vm',
      bindToController: true,
      link: link
    };

    function link($scope, $element, $attrs, $ctrls) {
      var self = $ctrls.shift(),
        tableCtrl = $ctrls.shift();

      self.toggle = function() {
        // keeping it simple for the unit test...
        tableCtrl.selected[0] = self.model;
      };
    }

    function controller() {}
  }

})();

/* Unit Test */
(function() {
  "use strict";

  describe('myTableRow Directive', function() {
    var $compile,
      $scope,
      compiledElement,
      tableCtrl = {},
      controller;

    beforeEach(function() {
      module('app.components');
      inject(function(_$rootScope_, _$compile_) {
        $scope = _$rootScope_.$new();
        $compile = _$compile_;
      });

      tableCtrl.enableMultiSelect = true;
      tableCtrl.selected = [];

      var element = angular.element('<table><tbody><tr my-table-row="data"><td></td></tr></tbody></table>');

      element.data('$myTableController', tableCtrl);
      $scope.data = {
        foo: 'bar'
      };
      compiledElement = $compile(element)($scope);
      $scope.$digest();
      controller = compiledElement.find('tr').controller('myTableRow');
      //console.log(controller); // without the above .find('tr'), this is undefined
    });

    describe('select', function() {
      it('should work', function() {
        controller.toggle();
        expect(tableCtrl.selected.length).toEqual(1);
      });
    });

  });

})();
<link rel="stylesheet" href="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.css" />
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.js"></script>
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine-html.js"></script>
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/boot.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.8/angular.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.8/angular-mocks.js"></script>

Upvotes: 3

Pritish Vaidya
Pritish Vaidya

Reputation: 22209

Here is an example to quote the use of angular directives using the parent child relationship.

The definition of annotated-image looks like this:(which is the parent)

angular.module('annotatedimage').directive('annotatedImage', function() {
  function AnnotatedImageController(scope) {}

  return {
    {
      restrict: 'E',
      template: [
        '<annotated-image-controls annotations="configuration.annotations"></annotated-image-controls>',
        '<annotated-image-viewer src="configuration.image" annotations="configuration.annotations"></annotated-image-viewer>',
        '<annotated-image-current></annotated-image-current>'
      ].join('\n'),
      controller: ['$scope', AnnotatedImageController],
      scope: {
        configuration: '='
      }
    }
  };
});

Now for the annotatedImageController , annotatedImageViewer and the annotatedImageCurrent which are the children.

 angular.module('annotated-image').directive('annotatedImageControls', function() {
   function link(scope, el, attrs, controller) {
     scope.showAnnotations = function() {
       controller.showAnnotations();
     };

     controller.onShowAnnotations(function() {
       scope.viewing = true;
     });
   }

   return {
     restrict: 'E',
     require: '^annotatedImage',
     template: [
       '<div>',
       '<span span[data-role="show annotations"]     ng-click="showAnnotations()" ng-hide="viewing">Show</span>',
       '<span span[data-role="hide annotations"] ng-click="hideAnnotations()" ng-show="viewing">Hide</span>',
       '<span ng-click="showAnnotations()">{{ annotations.length }} Annotations</span>',
       '</div>'
     ].join('\n'),
     link: link,
     scope: {
       annotations: '='
     }
   };
 });
 angular.module('annotated-image').directive('annotatedImageViewer', function() {
   function link(scope, el, attrs, controller) {
     var canvas = el.find('canvas');
     var viewManager = new AnnotatedImage.ViewManager(canvas[0], scope.src);

     controller.onShowAnnotations(function() {
       viewManager.showAnnotations(scope.annotations);
     });
   }

   return {
     restrict: 'E',
     require: '^annotatedImage',
     template: '<canvas></canvas>',
     link: link,
     scope: {
       src: '=',
       annotations: '='
     }
   };
 });

The same can be done for the annotatedImageCurrent

Summary

<parent-component>
  <child-component></child-component>
  <another-child-component></another-child-component>
</parent-component>

Parent Component

 module.directive('parentComponent', function() {
   function ParentComponentController(scope) {
     // initialize scope
   }

   ParentComponentController.prototype.doSomething = function() {
     // does nothing here
   }

   return {
     restrict: 'E',
     controller: ['$scope', ParentComponentController],
     scope: {}
   };
 });

Child Component

module.directive('childComponent', function() {
  function link(scope, element, attrs, controller) {
    controller.doSomething();
  }

  return {
    restrict: 'E',
    require: '^parentComponent',
    link: link,
    scope: {}
  }
});

Upvotes: 0

Related Questions