如何在AngularJs中编写具有私有方法的可测试控制器?

57

好的,我一直遇到一些问题,希望听听社区的意见。

首先,让我们来看一下某个抽象控制器。

function Ctrl($scope, anyService) {

   $scope.field = "field";
   $scope.whenClicked = function() {
      util();
   };

   function util() {
      anyService.doSmth();
   }

}

很明显,这里有:

  • 一个带有$scope和一些服务注入的控制器常规脚手架
  • 一些字段和函数附加到作用域上
  • 私有方法util()

现在,我想用单元测试(Jasmine)覆盖这个类。然而,问题是我想验证当我点击(调用whenClicked())某个项目时,util()方法将被调用。我不知道如何做到这一点,因为在Jasmine测试中,我总是得到错误提示,要么是未定义或未调用util()的模拟对象。

注意:我不是要解决这个特定例子,我在询问如何在一般情况下测试此类代码。所以请不要告诉我"具体错误是什么"。我在问如何做到这一点,而不是如何修复它。

我尝试了许多方法:

  • 很明显,我不能在我的单元测试中使用$scope,因为我没有将此函数附加到该对象上(通常会出现类似于“期望间谍但得到未定义”等消息)
  • 我尝试通过Ctrl.util = util;将这些函数附加到控制器对象上,然后像Ctrl.util = jasmine.createSpy()这样验证模拟对象,但在这种情况下,Ctrl.util没有被调用,所以测试失败了
  • 我尝试将util()更改为附加到this对象,并再次模拟Ctrl.util,但没有成功

好的,我找不到解决方法,我希望得到一些JS Ninjas(JavaScript高手)的帮助,一个可工作的fiddle(指代码测试网站)会非常完美。

4个回答

41

您提供的控制器函数将被Angular用作构造函数;在某个时刻,它将使用new来调用,以创建实际的控制器实例。如果您确实需要在控制器对象中具有不暴露给$scope但可供间谍/存根/模拟的函数,您可以将它们附加到this上。

function Ctrl($scope, anyService) {

  $scope.field = "field";
  $scope.whenClicked = function() {
    util();
  };

  this.util = function() {
    anyService.doSmth();
  }
}

当您现在调用 var ctrl = new Ctrl(...) 或使用 Angular 的 $controller 服务检索 Ctrl 实例时,返回的对象将包含 util 函数。

您可以在这里查看此方法:http://jsfiddle.net/yianisn/8P9Mv/


+1 我相信这是一种适用于小函数的方式,这些函数仅由单个控制器使用,您不希望将其暴露给作用域。 - Joel
15
直到ControllerAs出现,现在怎么办? - Shawn Erquhart
@ShawnErquhart,controllerAs有什么问题吗?this.util非常适合模拟目的,不是吗?问题出在哪里? - ey dee ey em
@ShawnErquhart 使用 controller as,添加到其中将会添加到作用域中,对吗? - Vamshi
@Ezeewei,将一个函数添加到this中会使其在视图中可用,这违背了将其设置为私有的目的。 - d512

31

在作用域上进行命名会污染环境。你需要的是将那个逻辑提取到一个单独的函数中,然后将其注入到您的控制器中。例如:

function Ctrl($scope, util) {

   $scope.field = "field";
   $scope.whenClicked = function() {
      util();
   };
}

angular.module("foo", [])
       .service("anyService", function(...){...})
       .factory("util", function(anyService) {
              return function() {
                     anyService.doSmth();
              };
       });

现在您可以使用模拟对象对您的Ctrl以及"util"进行单元测试。


1
作为澄清,我认为您拥有的任何封装逻辑都应该被注入,而您的控制器应该负责将这些注入的逻辑角色与范围变量组装起来,以便交付给模板。 - MikeMac
1
听起来不错,但有时我们想要测试的东西就像这样简单:if($scope.flag) service.a(); else service.b();。为了这一个测试而提取某个组件并不是很自然。不过我理解你的观点,我认为这仍然是一种有效的方法。 - ŁukaszBachman
5
如果您提取到私有方法的代码太简单,无法将其提取为单独的可模拟服务,为什么不忽略“Ctrl”的内部代码分区,并直接验证此逻辑呢?即,在您的示例中,与其验证对util()方法的调用,您可以验证对anyService.doSmth()(以及在util()中可能存在的任何其他服务调用)的调用。 - Good Night Nerd Pride
@Abbodanza是正确的。如果应该由于逻辑问题而将util分开,则应该将其拆分为单独的工厂。但不应该仅出于简化测试而将其分离。请参阅我的答案以了解如何实现此目的。 - Yehosef

7

我会提供另一种方法。您不应该测试私有方法。这就是它们为什么是私有的原因 - 这是一个与使用无关的实现细节。

例如,如果您意识到util在多个地方都被使用,但现在基于其他代码重构,它只在这个位置调用。为什么要有额外的函数调用?只需在$scope.whenClicked()中包含anyService.doSmith()即可。假设您正在测试是否调用了util(),根据上述建议,即使您没有更改程序的功能,您的测试也将出现错误。单元测试的主要价值之一是简化重构而不破坏事物,因此如果您没有破坏事物,则测试不应该失败。

您需要做的是确保调用$scope.whenClicked时也调用了anyService.doSmth()。您只需要:

spyOn(anyService,'doSmith')
scope.whenClicked();
expect(anyService.doSmith).toHaveBeenCalled();

1
请解释一下为什么要给我点踩。如果理由是我没有回答问题,那是不正确的。我解释了测试私有方法的方法,你应该测试所需的效果,而不是测试特定的方法是否被调用。 - Yehosef
1
讨厌没有解释就进行踩票的行为 :) 所有踩票都应该强制附带解释 :) - ey dee ey em
我不明白测试 anyService.doSmith() 是否被调用与测试 util() 是否被调用有什么不同。它们都是你的类的实现细节。唯一的区别在于一个是外部依赖,另一个不是。是的,创建依赖于实现细节的单元测试是脆弱的,但这就是单元测试。 - d512
区别在于anyService.doSmth()是一个重要的函数,而util()不是——它只是一个包装/帮助函数。我需要确保doSmth函数被调用,所以我测试了一下。这个例子没有说明为什么有差别,但是这个想法是doSmth是实际做某事的函数,util是帮助我组织代码的函数。如果我有多个事件有不同的操作和帮助函数来组织哪些事件有哪些操作——那么帮助函数就无关紧要了,只关注实际发生了什么。 - Yehosef

2
我正在添加一个答案,包含我的当前方法,希望得到一些评论和讨论,关于这是否是一个好的解决方案。
我们将私有函数附加到控制器函数上(从而使它们变为公共函数,可以进行模拟)。为了避免重复写控制器名称并使语法更加美观,我们创建了一个self对象,其中包含对控制器函数的引用。因此,它变成了:
function Ctrl($scope, anyService) {

   $scope.field = "field";
   $scope.whenClicked = function() {
      self.util();
   };

   var self = Ctrl; // For the sake of syntax simplicity only

   self.util = function() {
      anyService.doSmth();
   };

}

现在我们可以在单元测试中使用以下代码:

Ctrl.util = jasmine.createSpy("util()");
expect(Ctrl.util).toHaveBeenCalled();

我仍然不太喜欢这种方式,但我认为这是最简单的方法。我希望有人能找到更好的方法。


(有偏见的)但我认为在“私有命名空间”下添加嵌套层次可以更清晰地表达意图 - 无论是在$scope上还是在控制器类本身上。 - Mark Nadig
1
是的,我同意。但我不想将任何东西附加到$scope上,因为我们已经实施了项目范围规则,只有需要在Angular的digest周期中进行评估的对象才能附加到它上面。我同意,你的解决方案更容易管理。 - ŁukaszBachman

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接