Testing directives that require controllers
Asked Answered
W

3

48

So I did see another question: How to mock required directive controller in directive UT which is basically my problem but it seems the answer to this thread was "change your design." I wanted to make sure there is no way to do this. I have a directive that declares a controller which is used by children directives. I am now trying to write jasmine tests for the children directive but I cant get them to compile in the tests because they are dependent on the controller. Here is what it looks like:

addressModule.directive('address', ['$http', function($http){
        return {
            replace: false,
            restrict: 'A',
            scope: {
                config: '='
            },
            template:   '<div id="addressContainer">' +
                            '<div ng-if="!showAddressSelectionPage" basic-address config="config"/>' +
                            '<div ng-if="showAddressSelectionPage" address-selector addresses="standardizedAddresses"/>' +
                        '</div>',
            controller: function($scope)
            {
                this.showAddressInput = function(){
                    $scope.showAddressSelectionPage = false;
                };

                this.showAddressSelection = function(){
                    $scope.getStandardizedAddresses();
                };

                this.finish = function(){
                    $scope.finishAddress();
                };
            },
            link: function(scope, element, attrs) {
              ...
            }
       }
}])

child directive:

addressModule.directive('basicAddress360', ['translationService', function(translationService){
        return {
            replace: true,
            restrict: 'A',
            scope: {
                config: '='
            },
            template:
                '...',
            require: "^address360",
            link: function(scope, element, attrs, addressController){
            ...
            }
       }
}])

jasmine test:

it("should do something", inject(function($compile, $rootScope){
            parentHtml = '<div address/>';
            subDirectiveHtml = '<div basic-address>';

            parentElement = $compile(parentHtml)(rootScope);
            parentScope = parentElement.scope();
            directiveElement = $compile(subDirectiveHtml)(parentScope);
            directiveScope = directiveElement.scope();
            $rootScope.$digest();
}));

Is there no way for me to test the sub directive with jasmine and if so, what am I missing? Even if I could test the directive itself without the controller functions I would be happy.

Whatley answered 7/10, 2013 at 14:8 Comment(0)
A
76

I can think of two approaches:

1) Use both directives

Let's assume we have the following directives:

app.directive('foo', function() {
  return {
    restrict: 'E',
    controller: function($scope) {
      this.add = function(x, y) {
        return x + y;
      }
    }
  };
});

app.directive('bar', function() {
  return {
    restrict: 'E',
    require: '^foo',
    link: function(scope, element, attrs, foo) {
      scope.callFoo = function(x, y) {
        scope.sum = foo.add(x, y);
      }
    }
  };
});

In order to test the callFoo method, you can simply compile both directives and let bar use foo's implementation:

it('ensures callFoo does whatever it is supposed to', function() {
  // Arrange
  var element = $compile('<foo><bar></bar></foo>')($scope);
  var barScope = element.find('bar').scope();

  // Act
  barScope.callFoo(1, 2);

  // Assert
  expect(barScope.sum).toBe(3);
});    

Working Plunker.

2) Mock foo's controller out

This one is not quite straightforward and a little tricky. You could use element.controller() to get the controller of an element, and mock it out with Jasmine:

it('ensures callFoo does whatever it is supposed to', function() {
    // Arrange
    var element = $compile('<foo><bar></bar></foo>')($scope);
    var fooController = element.controller('foo');
    var barScope = element.find('bar').scope();
    spyOn(fooController, 'add').andReturn(3);

    // Act
    barScope.callFoo(1, 2);

    // Assert
    expect(barScope.sum).toBe(3);
    expect(fooController.add).toHaveBeenCalledWith(1, 2);
  });

Working Plunker.

The tricky part comes up when one directive uses the other's controller right away in its link function:

app.directive('bar', function() {
  return {
    restrict: 'E',
    require: '^foo',
    link: function(scope, element, attrs, foo) {
      scope.sum = foo.add(parseInt(attrs.x), parseInt(attrs.y));
    }
  };
});

In this case you need to compile each directive individually so you can mock the first one out before the second one uses it:

it('ensures callFoo does whatever it is supposed to', function() {
  // Arrange
  var fooElement = $compile('<foo></foo>')($scope);
  var fooController = fooElement.controller('foo');
  spyOn(fooController, 'add').andReturn(3);

  var barElement = angular.element('<bar x="1" y="2"></bar>')
  fooElement.append(barElement);

  // Act
  barElement = $compile(barElement)($scope);
  var barScope = barElement.scope();

  // Assert
  expect(barScope.sum).toBe(3);
  expect(fooController.add).toHaveBeenCalledWith(1, 2);
});

Working Plunker.

The first approach is way easier than the second one, but it relies on the implementation of the first directive, i.e, you're not unit testing things. On the other hand, although mocking the directive's controller isn't so easy, it gives you more control over the test and removes the dependency on the first directive. So, choose wisely. :)

Finally, I'm not aware of an easier way to do all of the above. If anyone knows of a better approach, please improve my answer.

Annotate answered 7/10, 2013 at 19:11 Comment(6)
This answer was fantastic thanks for all the time you put into that. I wish I could give you more than +1. I believe the easiest way for my particular situation is probably testing them as a single directive and just searching the parent for the child element/scope. Thanks again!Whatley
genius! element.controller('foo'); is what I've been looking for my entire life - cheers!Preoccupy
I'm afraid solution 2 is not working form me in a real life example. angular version 1.2.18. It returns controller undefined and then the child directive is not satisfying it's dependency on the parent controller. although the barElement returns the right markup. Also I'm using karm, chai and sinon.Cheboksary
You can find a plunker here, problem is that I can't find chai for plunker. plnkr.co/edit/dxSM7Vujv38NQ2SXnLLv?p=previewCheboksary
groups.google.com/d/msg/angular/0t5UxUSoQbw/cyVuEuJNKJwJ is another approach. Inject the directive definition and clobbar the controller with a mock function. Worked for me.Gooey
mind that when u use var barScope = barElement.scope(); the shared scope is returned. If you want the directive's isolated scope use barElement.isolateScope()Hindward
K
57

Forking on the (fantastic) answer of Michael Benford.

If you want to completely isolate your controller/directive in your test, you'll need a slightly different approach.

3) Mocking any required parent controller completely

When you associate a controller with a directive, an instance of the controller gets stored in the data store of the element. The naming convention for the key value is '$' + name of directive + 'Controller'. Whenever Angular tries to resolve a required controller, it traverse the data hierarchy using this convention to locate the required controller. This can easily be manipulated by inserting mocked controller instances into parent elements:

it('ensures callFoo does whatever it is supposed to', function() {

    // Arrange

    var fooCtrl = {
      add: function() { return 123; }
    };

    spyOn(fooCtrl, 'add').andCallThrough();

    var element = angular.element('<div><bar></bar></div>');
    element.data('$fooController', fooCtrl);

    $compile(element)($scope);

    var barScope = element.find('bar').scope();

    // Act

    barScope.callFoo(1, 2);

    // Assert

    expect(barScope.sum).toBe(123);
    expect(fooCtrl.add).toHaveBeenCalled();
});

Working Plunker.

4) Separating link method

The best approach, in my opinion, is by isolating the link method. All the previous approaches actually test too much and, when situations get a little bit more complex than the simple examples provided here, they require too much of a setup.

Angular has the perfect support for this separation of concern:

// Register link function

app.factory('barLinkFn', function() {
  return function(scope, element, attrs, foo) {
    scope.callFoo = function(x, y) {
      scope.sum = foo.add(x, y);
    };
  };
});

// Register directive

app.directive('bar', function(barLinkFn) {
  return {
    restrict: 'E',
    require: '^foo',
    link: barLinkFn
  };
});

And by changing our beforeEach to include our link function ... :

inject(function(_barLinkFn_) {
  barLinkFn = _barLinkFn_;
});

... we can do:

it('ensures callFoo does whatever it is supposed to', function() {

  // Arrange

  var fooCtrl = {
    add: function() { return 321; }
  };

  spyOn(fooCtrl, 'add').andCallThrough();

  barLinkFn($scope, $element, $attrs, fooCtrl);

  // Act

  $scope.callFoo(1, 2);

  // Assert

  expect($scope.sum).toBe(321);
  expect(fooCtrl.add).toHaveBeenCalled();

});

Working Plunker.

This way we're only testing the things that are concerned and the same approach can be used to isolate the compile function if needed.

Knowles answered 13/11, 2013 at 10:20 Comment(5)
This is some excellent information and a great approach for isolating the tests to the linkFn logic. Thanks!Fushih
for #4, it might still need to test DOM related stuff by compiling the directiveBrahman
The game changer here is this line: element.data('$fooController', fooCtrl);. Who would have guessed, right? :) Love it! <3Schumer
With #3, ` element.data('$fooController', fooCtrl);` how do I know what to use as a name for '$fooController'?Madden
my only guess would be "$" + directiveName + "Controller"Madden
B
9

5) Injecting the directive definition and mocking the controller's function

Another approach is to inject the directive's definition and mock whatever we need. The best thing about this is that you can completely write unit tests for your children directive without depending on your parents.

Using inject() you can inject any directives definition providing the name of the directive + 'Directive' and then access to its methods and replace them as you need

it('ensures callFoo does whatever it is supposed to', inject(function(fooDirective) {
  var fooDirectiveDefinition = fooDirective[0];

  // Remove any behavior attached to original link function because unit
  // tests should isolate from other components
  fooDirectiveDefinition.link = angular.noop;

  // Create a spy for foo.add function
  var fooAddMock = jasmine.createSpy('add');

  // And replace the original controller with the new one defining the spy
  fooDirectiveDefinition.controller = function() {
    this.add = fooAddMock;
  };

  // Arrange
  var element = $compile('<foo><bar></bar></foo>')($scope);
  var barScope = element.find('bar').scope();

  // Act
  barScope.callFoo(1, 2);

  // Verify that add mock was called with proper parameters
  expect(fooAddMock).toHaveBeenCalledWith(1, 2);
}));

The idea was proposed by Daniel Tabuenca in the AngularJS Google Group

In this Plunker Daniel mocks the ngModel directive

Basilica answered 14/4, 2014 at 15:39 Comment(2)
This alternative works and since mocking out the parent directives controller completely decouples it from it. And it gives a good insight in the way Angular registers directives. Obviously it's possible to have more than one directive with a certain name!?Henigman
@Henigman It's totally possible, but you cannot have controllers in both directives. Only one controller can be registered for given name. It can be useful for example to extend "input".Telesis

© 2022 - 2024 — McMap. All rights reserved.