66

In Vojta Jina's excellent repository where he demonstrates testing of directives, he defines the directive controller outside of the module wrapper. See here: https://github.com/vojtajina/ng-directive-testing/blob/master/js/tabs.js

Isn't that bad practice and pollute the global namespace?

If one were to have another place where it might be logical to call something TabsController, wouldn't that break stuff?

The tests for the mentioned directive is to be found here: https://github.com/vojtajina/ng-directive-testing/commit/test-controller

Is it possible to test directive controllers separate from the rest of the directive, without placing the controller in a global namespace?

It would be nice to encapsulate the whole directive within the app.directive(...) definition.

Ondrej Slinták
  • 31,386
  • 20
  • 94
  • 126
Kenneth Lynne
  • 15,461
  • 12
  • 63
  • 79
  • May I ask what's the point testing a directives' controller separately from the directive itself? I suspect that if the controllers' code is not tested along the rest of the directive you are doing something wrong. It's a bit like saying I have this class method but I want to test it separately so I'm going to put this on the global scope. I think the accepted answer (pkozlowski) involves bad practice, while James' one is the correct one. – Izhaki Aug 20 '15 at 14:58

5 Answers5

75

I prefer at times to include my controller along with the directive so I need a way to test that.

First the directive

angular.module('myApp', [])
  .directive('myDirective', function() {
    return {
      restrict: 'EA',
      scope: {},
      controller: function ($scope) {
        $scope.isInitialized = true
      },
      template: '<div>{{isInitialized}}</div>'
    }
})

Then the tests:

describe("myDirective", function() {
  var el, scope, controller;

  beforeEach inject(function($compile, $rootScope) {
    # Instantiate directive.
    # gotacha: Controller and link functions will execute.
    el = angular.element("<my-directive></my-directive>")
    $compile(el)($rootScope.$new())
    $rootScope.$digest()

    # Grab controller instance
    controller = el.controller("myDirective")

    # Grab scope. Depends on type of scope.
    # See angular.element documentation.
    scope = el.isolateScope() || el.scope()
  })

  it("should do something to the scope", function() {
    expect(scope.isInitialized).toBeDefined()
  })
})

See angular.element documentation for more ways to get data from an instantiated directive.

Beware that instantiating the directive implies that the controller and all link functions will already have run, so that might affect your tests.

James van Dyke
  • 4,790
  • 3
  • 28
  • 25
  • 14
    This is a great answer, but there is a small edit needed: you need to pass in the name of the directive for the call to `el.controller()`. So in your example above the call would be `el.controller("myDirective")`. – Tom Spencer Jul 31 '14 at 09:19
  • The controller has no name. It's just part of the directive definition. `el.controller()` works. – James van Dyke Jul 31 '14 at 20:37
  • 4
    @fiznool is right, at least according to the official [angular documentation](https://docs.angularjs.org/api/ng/function/angular.element#methods). Note that this is not the directive's controller name, but the directive name itself! – ArtoAle Sep 16 '14 at 13:38
  • Fair enough. I'll leave it up to the implementor. This method works for me, though. The official documentation mentions that there is a default if no name is given: "_By default retrieves controller associated with the ngController directive. If name is provided as camelCase directive name, then the controller for this directive will be retrieved (e.g. 'ngModel')_" – James van Dyke Sep 16 '14 at 19:42
  • 6
    I had to pass the directive name into controller method in Angular 1.3. Was giving me `undefined` without it. – demisx Sep 18 '14 at 19:12
  • Good to know, @demisx. I'm using 1.2. – James van Dyke Sep 18 '14 at 19:47
  • Really important point is that the directive name has to be the same way you specify in the HTML, i.e. `el.controller('my-directive')` – Gustavo Matias Jun 08 '15 at 01:43
  • This looks like the most elegant solution. It is not working for me with AngularJS 1.3.15. element.controller('directiveName') returns undefined. The documentation for element says this should work with this version. Is anyone else using this method with 1.3.15 or greater? – Martin Jun 17 '15 at 11:20
  • without specified name it doesn't work for me, Angular 1.3.15. I tested 'my-directive' and 'myDirective' cases and work both ways. – Gleb Vinnikov Jul 22 '15 at 09:09
  • Working againt 1.4.7 using the `el.controller('myDirective')` syntax. – Mark Hughes Nov 02 '15 at 11:41
  • what about when the directive is a module via browserify? When I attempt to invoke the controller by the name I've set the module to be when requiring it, my controller is always undefined. – beauXjames Sep 08 '16 at 18:43
  • So what if I want to mock the factory that gets loaded into the controller (inside the directive). – CularBytes Sep 21 '16 at 09:24
58

Excellent question!

So, this is a common concern, not only with controllers but also potentially with services that a directive might need to perform its job but don't necessarily want to expose this controller / service to the "external world".

I strongly believe that global data are evil and should be avoided and this applies to directive controllers as well. If we take this assumption we can take several different approaches to define those controllers "locally". While doing so we need to keep in mind that a controller should be still "easily" accessible to unit tests so we can't simply hide it into directive's closure. IMO possibilities are:

1) Firstly, we could simply define directive's controller on a module level, ex::

angular.module('ui.bootstrap.tabs', [])
  .controller('TabsController', ['$scope', '$element', function($scope, $element) {
    ...
  }])
 .directive('tabs', function() {
  return {
    restrict: 'EA',
    transclude: true,
    scope: {},
    controller: 'TabsController',
    templateUrl: 'template/tabs/tabs.html',
    replace: true
  };
})

This is a simple technique that we are using in https://github.com/angular-ui/bootstrap/blob/master/src/tabs/tabs.js which is based on Vojta's work.

While this is a very simple technique it should be noted that a controller is still exposed to the whole application which means that other module could potentially override it. In this sense it makes a controller local to AngularJS application (so not polluting a global window scope) but it also global to all AngularJS modules.

2) Use a closure scope and special files setup for testing.

If we want to completely hide a controller function we can wrap code in a closure. This is a technique that AngularJS is using. For example, looking at the NgModelController we can see that it is defined as a "global" function in its own files (and thus easily accessible for testing) but the whole file is wrapped in closure during the build time:

To sum up: the option (2) is "safer" but requires a bit of up-front setup for the build.

pkozlowski.opensource
  • 117,202
  • 60
  • 326
  • 286
  • Thanks for your answer! Hadn't thought about that! – Kenneth Lynne Mar 09 '13 at 19:44
  • Good question and good anwser. For me the 1st approach is perfect. – Mark Lagendijk Jul 30 '13 at 14:14
  • 1
    With regard to option 2; How would I access the closured controller definition in the testSpec? – Stevo Oct 17 '13 at 07:20
  • 1
    @Stevo, It only gets closured when built. Before that, it's a global function that's accessible anywhere, you just have to include its file. – M.K. Safi Jan 04 '14 at 14:58
  • Was trying to figure out what you'd call NgModelController in the second scenario. It's a global variable, but only in your local environment. So then it's a local global variable? **mindblown** – Scott Silvi Mar 26 '14 at 20:14
  • @pkozlowski.opensource Can you tell me/us how to setup these closures or do you know a tutorial for it? I already browsed the angular repository but couldn't find where the prefix/suffix'es come into play. – Fortuna Mar 13 '16 at 23:27
  • Starting the answer with a good intro, high expectations, throw an example in there that is not good, expecting an example that is good... and eventually end up with references which I have no idea how to use. You tricked me well :) – CularBytes Sep 21 '16 at 09:52
  • Can anyone give solution of http://stackoverflow.com/questions/42366791/angular-js-load-json-file-directive-test-failing-mocha? – Manwal Feb 22 '17 at 05:37
10

James's method works for me. One small twist is though, when you have an external template, you would have to call $httpBackend.flush() before $rootScope.$digest() in order to let angular execute your controller.

I guess this should not be an issue, if you are using https://github.com/karma-runner/karma-ng-html2js-preprocessor

4

Is there something wrong with doing it this way? Seems preferable since you avoid placing your controller in the global name space and are able to test what you want (i.e. the controller) without unnecessarily $compiling html.

Example directive definition:

 .directive('tabs', function() {
  return {
    restrict: 'EA',
    transclude: true,
    scope: {},
    controller: function($scope, $attrs) {
      this.someExposedMethod = function() {};
    },
    templateUrl: 'template/tabs/tabs.html',
    replace: true
  };

Then in your Jasmine test, ask for the directive you created using "name + Directive" (ex. "tabsDirective"):

var tabsDirective = $injector.get('tabsDirective')[0];
// instantiate and override locals with mocked test data
var tabsDirectiveController = $injector.instantiate(tabsDirective.controller, {
  $scope: {...}
  $attrs: {...}
});

Now you can test controller methods:

expect(typeof tabsDirectiveController.someExposedMethod).toBe('function');
ase
  • 13,231
  • 4
  • 34
  • 46
jbmilgrom
  • 20,608
  • 5
  • 24
  • 22
  • 1
    @pkozlowski.opensource am I missing something with this approach? – jbmilgrom Aug 05 '16 at 11:42
  • I tried this approach to access controller, which is working fine. But I am stuck in this way to how to pass directive attribute. I have a directive like this -> `` I want to access ref-object-uri parameter in unit test. – Anita Oct 06 '17 at 12:12
  • My answer is only about testing the controller function. Your example "html" that corresponds to a directive under the hood will need to get `$compile`ed in order to test the passing of attribute parameters thereby – jbmilgrom Oct 06 '17 at 12:28
0

Use IIFE, which is a common technique to avoid global namespace conflict & it also save tricky inline gymnastics, plus provide freedom in your scope.

 (function(){

  angular.module('app').directive('myDirective', function(){
     return {
       .............
       controller : MyDirectiveController,
       .............
     }
  });

  MyDirectiveController.$inject = ['$scope'];

  function MyDirectiveController ($scope) {

  }

})();
Imdadul Huq Naim
  • 364
  • 5
  • 10