How do you mock directives to enable unit testing of higher level directive?

23,945

Solution 1

Due to the implementation of the directive registration, it does not seem possible to replace an existing directive by a mocked one.

However, you have several ways to unit test your higher level directive without interference from lower level directives :

1) Do not use lower level directive in your unit test template :

If your lower level directive is not added by your higher level directive, in your unit test use a template with only you higer-level-directive :

var html = "<div my-higher-level-directive></div>";
$compile(html)(scope);

So, lower level directive will not interfere.

2) Use a service in your directive implementation :

You can provide the lower level directive linking function by a service :

angular.module("myModule").directive("myLowerLevelDirective", function(myService) {
    return {
        link: myService.lowerLevelDirectiveLinkingFunction
    }
});

Then, you can mock this service in your unit test to avoid interference with your higher level directive. This service can even provide the whole directive object if needed.

3) You can overwrite your lower level directive with a terminal directive :

angular.module("myModule").directive("myLowerLevelDirective", function(myService) {
    return {
        priority: 100000,
        terminal: true,
        link: function() {
            // do nothing
        }
    }
});

With the terminal option and a higher priority, your real lower level directive will not be executed. More infos in the directive doc.

See how it works in this Plunker.

Solution 2

Directives are just factories, so the best way to do this is to mock the factory of the directive in using the module function, typically in the beforeEach block. Assuming you have a directive named do-something used by a directive called do-something-else you'd mock it as such:

beforeEach(module('yourapp/test', function($provide){
  $provide.factory('doSomethingDirective', function(){ return {}; });
}));

// Or using the shorthand sytax
beforeEach(module('yourapp/test', { doSomethingDirective: {} ));

Then the directive will be overridden when the template is compiled in your test

inject(function($compile, $rootScope){
  $compile('<do-something-else></do-something-else>', $rootScope.$new());
});

Note that you need to add the 'Directive' suffix to the name because the compiler does this internally: https://github.com/angular/angular.js/blob/821ed310a75719765448e8b15e3a56f0389107a5/src/ng/compile.js#L530

Solution 3

The clean way of mocking a directive is with $compileProvider

beforeEach(module('plunker', function($compileProvider){
  $compileProvider.directive('d1', function(){ 
    var def = {
      priority: 100,
      terminal: true,
      restrict:'EAC',
      template:'<div class="mock">this is a mock</div>',
    };
    return def;
  });
}));

You have to make sure the mock gets a higher priority then the directive you are mocking and that the mock is terminal so that the original directive will not be compiled.

priority: 100,
terminal: true,

The result would look like the following:

Given this directive:

var app = angular.module('plunker', []);
app.directive('d1', function(){
  var def =  {
    restrict: 'E',
    template:'<div class="d1"> d1 </div>'
  }
  return def;
});

You can mock it like this:

describe('testing with a mock', function() {
var $scope = null;
var el = null;

beforeEach(module('plunker', function($compileProvider){
  $compileProvider.directive('d1', function(){ 
    var def = {
      priority: 9999,
      terminal: true,
      restrict:'EAC',
      template:'<div class="mock">this is a mock</div>',
    };
    return def;
  });
}));

beforeEach(inject(function($rootScope, $compile) {
  $scope = $rootScope.$new();
  el = $compile('<div><d1></div>')($scope);
}));

it('should contain mocked element', function() {
  expect(el.find('.mock').length).toBe(1);
});
});

A few more things:

  • When you create your mock, you have to consider whether or not you need replace:true and/or a template. For instance if you mock ng-src to prevent calls to the backend, then you don't want replace:true and you don't want to specify a template. But if you mock something visual, you might want to.

  • If you set priority above 100, your mocks's attributes will not be interpolated. See $compile source code. For instance if you mock ng-src and set priority:101, then you'll end-up with ng-src="{{variable}}" not ng-src="interpolated-value" on your mock.

Here is a plunker with everything. Thanks to @trodrigues for pointing me in the right direction.

Here is some doc that explains more, check the "Configuration Blocks" section. Thanks to @ebelanger!

Solution 4

You can modify your templates inside $templateCache to remove any lower level directives:

beforeEach(angular.mock.inject(function ($templateCache) {
  $templateCache.put('path/to/template.html', '<div></div>');
}));

Solution 5

Loved Sylvain's answer so much I had to turn it into a helper function. Most often, what I need is to kill off a child directive so that I can compile and test the parent container directive without its dependencies. So, this helper lets us do that:

function killDirective(directiveName) {
  angular.mock.module(function($compileProvider) {
    $compileProvider.directive(directiveName, function() {
      return {
        priority: 9999999,
        terminal: true
      }
    });
  });
}

With that, you can completely disable a directive by running this before the injector gets created:

killDirective('myLowerLevelDirective');
Share:
23,945
dnc253
Author by

dnc253

Updated on July 27, 2022

Comments

  • dnc253
    dnc253 almost 2 years

    In our app we have several layers of nested directives. I'm trying to write some unit tests for the top level directives. I've mocked in stuff that the directive itself needs, but now I'm running into errors from the lower level directives. In my unit tests for the top level directive, I don't want to have to worry about what is going on in the lower level directives. I just want to mock the lower level directive and basically have it do nothing so I can be testing the top level directive in isolation.

    I tried overwriting the directive definition by doing something like this:

    angular.module("myModule").directive("myLowerLevelDirective", function() {
        return {
            link: function(scope, element, attrs) {
                //do nothing
            }
        }
    });
    

    However, this does not overwrite it, it just runs this in addition to the real directive. How can I stop these lower level directives from doing anything in my unit test for the top level directive?

  • dnc253
    dnc253 almost 11 years
    For #1, the lower level directive is in the template string of the higher level directive. I'm not including it in my test, but it gets brought in through the compilation process. For #2, it is an interesting an idea that seems like it would work, but I'm not sure if I like the idea of my directive definition off in some service somewhere.
  • Bastien Caudan
    Bastien Caudan almost 11 years
    I have just added a #3
  • dnc253
    dnc253 almost 11 years
    #3 appears to do the same thing as what I did in my original question. It runs both directives.
  • Bastien Caudan
    Bastien Caudan almost 11 years
    I have unit test it in a plunker, I add the link at the end of my answer.
  • dnc253
    dnc253 almost 11 years
    I don't have time right now to see what the plunker is doing, but I appreciate your efforts, and this answer certainly gives me some options. As the bounty is expiring, I want to award that before it does expire.
  • Bastien Caudan
    Bastien Caudan almost 11 years
    Thanks, don't hesitate if you have questions about the plunker
  • dnc253
    dnc253 almost 11 years
    I looked at the plunker and do indeed both run. With what you did, I can ensure that the one in the test runs last, but I don't want the other one running at all. See plnkr.co/edit/S1BgF97kRrAKAQs5Y9vk?p=preview
  • Bastien Caudan
    Bastien Caudan almost 11 years
    In the plunker, there is two tests : one without the terminal directive and one with it. If you deactivate the without terminal directive test, the real lower directive never runs. See plnkr.co/edit/5RJeV8T7u2uT3ETWJjXJ?p=preview
  • dnc253
    dnc253 almost 11 years
    Having had some time to look at this closer, I see what's going on now. This does effectively stop the lower directive from running, but the problem is that it prevents ALL directives from running. The specific directive I'm trying to test has an ng-switch in it, and I'm running into problems with stuff being run that should have been ng-switched out. See plnkr.co/edit/yKMWVxbcV0s7m3ufAcbo?p=preview
  • dnc253
    dnc253 almost 11 years
    Right after posting that previous comment, I realized what the solution is. All my directives currently don't have a priority set (meaning it is 0), so the directive in the test just needs a priority higher than that in order to stop the real directive from running. I can just set the priority to 1, and thus the higher priority angular directives can still run. See plnkr.co/edit/bmoxPDpKtzJhc73yq1nP?p=preview Thanks for the help.
  • demisx
    demisx about 10 years
    If you have to tweak your code just to make your tests work, then there is something wrong with the tests.
  • Elliot Winkler
    Elliot Winkler almost 10 years
    This works better than @Sylvain's solution for me b/c it overrides any dependencies that the directive may have -- otherwise you have to mock those out too.
  • Sylvain
    Sylvain almost 10 years
    @ElliotWinkler IMO, having to mock the dependencies is a good thing. It forces you to tests the directive as a unit.
  • Elliot Winkler
    Elliot Winkler almost 10 years
    I realize this, but my point is that I shouldn't have to mock secondary dependencies of the thing I'm testing, only primary dependencies. In other words, I shouldn't have to care what the directive depends upon in my test.
  • Piioo
    Piioo over 9 years
    If someone have this problem when using coffescript: [ng:areq] Argument 'fn' is not a function, got Object -> return null on the end of beforeEach : gist.github.com/jbrowning/9527280
  • PSWai
    PSWai over 9 years
    If you want to mock with custom behavior, you should return an array of directive definition objects: gist.github.com/pswai/ee51b0567f51c39d81c2
  • arieljake
    arieljake about 9 years
    how would one restore the mock after testing so that future usage of the directive will employ the original factory?
  • Maciej Gurban
    Maciej Gurban over 8 years
    providing within a module() applies the mock only to current unit test, so whatever changes you make to the directive, they will not be visible to other tests (unless you use window object, but you probably shouldn't)
  • SimplGy
    SimplGy over 8 years
    $compileProvider works great. If you're using coffeescript and getting error 'fn' is not a function, got $CompileProvider, make sure to return undefined from the module function.
  • ethanfar
    ethanfar over 8 years
    One small comment though: If the directive you're mocking is doing transclusion, the high priority will prevent ng-transclude from working. In this case, my solution is to simply transclude manually inside the link function: link: function(scope, elem, attrs, ctrls, transFn) { transFn(scope, function(clone) { element.append(clone); }); }
  • Matthias
    Matthias about 7 years
    @PSWai that's right, and you also need to use the compile function pattern in the Directive Definition Object (if you only have a link, it won't get called).
  • Matthias
    Matthias about 7 years
    @PSWai also, if you want to use scope bindings or bindToController, you need to manually do everything that $compileProvider.directive() does behind the scenes: github.com/angular/angular.js/blob/v1.4.5/src/ng/…
  • René Olivo
    René Olivo almost 6 years
    Sir, please go up! This solution works for me, $provide.factory didn't work at all.