【问题标题】:Testing directives that require controllers需要控制器的测试指令
【发布时间】:2013-10-14 03:44:51
【问题描述】:

所以我确实看到了另一个问题:How to mock required directive controller in directive UT 这基本上是我的问题,但似乎这个线程的答案是“改变你的设计”。我想确保没有办法做到这一点。我有一个指令,它声明了一个由子指令使用的控制器。我现在正在尝试为 children 指令编写 jasmine 测试,但我无法让它们在测试中编译,因为它们依赖于控制器。这是它的样子:

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) {
              ...
            }
       }
}])

子指令:

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

茉莉花测试:

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();
}));

我有没有办法用茉莉花测试子指令,如果是这样,我错过了什么?即使我可以在没有控制器功能的情况下测试指令本身,我也会很高兴。

【问题讨论】:

    标签: angularjs jasmine


    【解决方案1】:

    我可以想到两种方法:

    1) 使用两个指令

    假设我们有以下指令:

    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);
          }
        }
      };
    });
    

    为了测试callFoo方法,你可以简单地编译两个指令,让bar使用foo的实现:

    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) 模拟 foo 的控制器

    这个不是很简单而且有点棘手。您可以使用element.controller() 获取元素的控制器,并使用 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.

    当一个指令在其link 函数中立即使用另一个控制器时,就会出现棘手的部分:

    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));
        }
      };
    });
    

    在这种情况下,您需要单独编译每个指令,以便在第二个使用它之前模拟第一个:

    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.

    第一种方法比第二种方法容易得多,但它依赖于第一个指令的实现,也就是说,你不是在对事物进行单元测试。另一方面,虽然模拟指令的控制器并不容易,但它可以让您更好地控制测试并消除对第一个指令的依赖。所以,明智地选择。 :)

    最后,我不知道有一种更简单的方法可以完成上述所有操作。如果有人知道更好的方法,请改进我的答案。

    【讨论】:

    • 这个答案非常感谢您为此付出的所有时间。我希望我能给你超过+1。我相信对于我的特殊情况,最简单的方法可能是将它们作为单个指令进行测试,并且只是在父元素/范围内搜索子元素。再次感谢!
    • 天才! element.controller('foo'); 是我一生都在寻找的东西 - 干杯!
    • 恐怕解决方案 2 在现实生活中对我不起作用。角度版本 1.2.18。它返回未定义的控制器,然后子指令不满足它对父控制器的依赖。尽管 barElement 返回正确的标记。我也在使用 karm、chai 和 sinon。
    • 你可以在这里找到一个 plunker,问题是我找不到 chai for plunker。 plnkr.co/edit/dxSM7Vujv38NQ2SXnLLv?p=preview
    • groups.google.com/d/msg/angular/0t5UxUSoQbw/cyVuEuJNKJwJ 是另一种方法。注入指令定义并使用模拟函数对控制器进行clobbar。为我工作。
    【解决方案2】:

    根据 Michael Benford 的 (fantastic) 答案分叉。

    如果您想在测试中完全隔离您的控制器/指令,您需要一种稍微不同的方法。

    3) 完全模拟任何所需的父控制器

    当您将控制器与指令相关联时,控制器的一个实例会存储在元素的 数据存储 中。键值的命名约定是'$' + 指令名称 + 'Controller'。每当 Angular 尝试解析 required 控制器时,它都会使用此约定遍历数据层次结构来定位所需的控制器。 这可以通过将模拟控制器实例插入到父元素中来轻松操作:

    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) 分离链接法

    在我看来,最好的方法是隔离链接方法。之前的所有方法实际上都进行了太多的测试,当情况变得比这里提供的简单示例稍微复杂一点时,它们需要太多的设置。

    Angular 完美支持这种关注点分离:

    // 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
      };
    });
    

    并且通过更改我们的 beforeEach 以包含我们的链接功能...:

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

    ...我们可以做到:

    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.

    这样我们只测试相关的东西,如果需要,可以使用相同的方法来隔离编译功能。

    【讨论】:

    • 这是一些极好的信息,也是将测试隔离到 linkFn 逻辑的好方法。谢谢!
    • 对于#4,它可能仍然需要通过编译指令来测试DOM相关的东西
    • 改变游戏规则的是这一行:element.data('$fooController', fooCtrl);。谁会猜到,对吧? :) 爱它!
    • 有了#3,`element.data('$fooController', fooCtrl);`我怎么知道用什么作为'$fooController'的名字?
    • 我唯一的猜测是"$" + directiveName + "Controller"
    【解决方案3】:

    5) 注入指令定义并模拟控制器的功能

    另一种方法是注入指令的定义并模拟我们需要的任何内容。最好的一点是,您可以完全为您的 children 指令编写单元测试,而无需依赖您的父母。

    使用 inject(),您可以注入任何提供指令名称 + 'Directive' 的指令定义,然后访问其方法并根据需要替换它们

    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);
    }));
    

    这个想法是由Daniel TabuencaAngularJS Google Group提出的

    在这个Plunker Daniel 嘲笑 ngModel 指令

    【讨论】:

    • 这种替代方法有效,因为模拟父指令控制器完全将其与它分离。它提供了一个很好的洞察 Angular 注册指令的方式。显然可以有多个具有特定名称的指令!?
    • @hgoebl 这完全有可能,但你不能在两个指令中都有控制器。给定名称只能注册一个控制器。例如,扩展“输入”可能很有用。
    猜你喜欢
    • 2013-03-15
    • 2016-06-14
    • 2015-05-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-12-30
    相关资源
    最近更新 更多