Improve this doc

翻译者:@asnowwolf

JavaScript是一个动态类型的语言,具有强大的表达能力,但同时,你几乎没法从编译器获得任何帮助。 因此,我们深切体会到:任何JavaScript程序都需要伴随着一组强大的测试。 我们在angular中引入了很多特性来让你更轻松的测试应用程序。 所以,想不写测试?不,没有任何借口!

一切的关键是:不要把不同的任务揉成一团

单元测试,顾名思义就是用于测试代码中不可分割的单元。单元测试试图回答下列问题:“我所认为的逻辑正确吗?”或者“我的sort函数是否按照正确的顺序排序了这个列表?”

为了回答上述问题,最重要的事情就是:我们要能把这个单元的代码隔离到一个独立的test模块中。 这是因为我们测试sort函数的时候,绝不会希望也被迫创建相关的部分 —— 比如DOM元素,或者先发起一个XHR调用来获取完数据才能测试sort函数。

虽然这看起来无所谓,但在一个典型的项目中,想要调用一个独立函数确实是非常困难的。 原因在于,开发人员经常会把不同的任务揉成一团,导致一部分代码中往往会做很多很多事:发起XHR请求,对返回的数据进行排序,然后更新到DOM。

在Angular中,我们尝试进行简化,来确保你总能“做正确的事”,所以,我们提供了依赖注入(DI),来让你自由使用XHR(这样你就能mock它了); 我们创建了抽象层,让你可以排序你的模型(Model),而不用去管DOM。 最终的结果就是:很容易写出这样一个对一些数据进行排序的sort函数,你的测试程序可以创建一组数据、调用sort函数,然后验证这些数据是否被按照正确的顺序排列了。 这个测试不用等XHR返回结果,不用想方设法创建正确类型的测试用DOM,也不用验证DOM节点是否根据排好的顺序发生了变化。

Angular确实做了很多,但你也不能偷懒

写Angular的核心思想之一就是“可测试性”,但是它仍然需要你按照正确的方式使用它。 你当然希望轻而易举的把事情做对,但Angular不是魔术。如果你不遵循下列指导原则,你仍然很可能得到一个不可测试的程序。

依赖注入(DI)

你可以有很多种方式获得所依赖的对象。比如:

  • 通过new运算符创建一个。
  • 在一个众所周知的地方找一个现成的,比如全局性的单例(singleton)对象。
  • 从一个注册表(registry)(比如服务注册表)中找一个现成的。(但是你如何找到一个注册表的引用呢?通常要从一个众所周知的地方寻找。参见#2。)
  • 等别人把它“递”到你手里。

在这四种方案中,只有最后一种是可测试的。让我们分析一下这是为什么:

使用new运算符

本质上,使用new运算符没有错,问题出在从构造函数中调用new运算符的时候。 这种情况下,调用者被永久性的和它要new的这个类型绑定在一起。比如,如果为了从服务器获得数据而对XHR进行实例化会导致什么后果?

  1. function MyClass() {
  2. this.doWork = function() {
  3. var xhr = new XHR();
  4. xhr.open(method, url, true);
  5. xhr.onreadystatechange = function() {...}
  6. xhr.send();
  7. }
  8. }

在测试时,问题表现在:当我们想要实例化一个MockXHR —— 我们需要它来返回模拟数据,并且模拟网络异常。 如果我们调用new XHR()来获得实例,我们就永久性的和实际的XHR(而不是Mock的!)绑定在一起,并且没有任何办法替换它。 固然,我们可以使用猴子补丁(译注:monkey patch —— 见下面例子),但这绝对是个坏注意,理由很多,不过本文档中不展开论述。

下面是一个例子,可以看出为何即使借助于猴子补丁仍然不是个好办法。

  1. var oldXHR = XHR;
  2. XHR = function MockXHR() {};
  3. var myClass = new MyClass();
  4. myClass.doWork();
  5. // 确保MockXHR按照正确的参数进行了调用
  6. XHR = oldXHR; // 如果你忘了写这句,就糟了

全局查找:

另一个方法是从一个众所周知的地方查找此服务。

  1. function MyClass() {
  2. this.doWork = function() {
  3. global.xhr({
  4. method:'...',
  5. url:'...',
  6. complete:function(response){ ... }
  7. })
  8. }
  9. }

虽然这次没有直接创建新的依赖对象,问题和new方案仍是一样的:测试方无法拦截对global.xhr的调用 —— 除非通过猴子补丁。 对测试来说,根本问题在于全局变量应该允许被测试方修改,以便能替换它,并且调用一个mock函数。 关于“这种方式为什么不好”的详细论述请参见: 孤僻的全局状态和单例对象

上面这个类之所以难于测试,原因就在于我们不得不修改全局状态:

  1. var oldXHR = global.xhr;
  2. global.xhr = function mockXHR() {};
  3. var myClass = new MyClass();
  4. myClass.doWork();
  5. // 确保mockXHR使用正确的参数调用了
  6. global.xhr = oldXHR; // 如果你忘了写这句,就糟了

服务注册表

粗看起来似乎有一个好办法解决这个问题:创建一个注册表,它保存着所有服务,那么测试方就可以替换这些服务了。

  1. function MyClass() {
  2. var serviceRegistry = ????;
  3. this.doWork = function() {
  4. var xhr = serviceRegistry.get('xhr');
  5. xhr({
  6. method:'...',
  7. url:'...',
  8. complete:function(response){ ... }
  9. })
  10. }

问题在于,serviceRegistry从哪里来呢?如果:

  • 是 new 出来的,那么测试方没有机会重定义这些服务以供测试。
  • 来自全局查找,那么所返回的服务也是全局的(但是重定义比较容易,因为需要重定义的只有一个全局变量)

上面的这个类仍然难于测试,因为我们还是不得不修改全局状态:

  1. var oldServiceLocator = global.serviceLocator;
  2. global.serviceLocator.set('xhr', function mockXHR() {});
  3. var myClass = new MyClass();
  4. myClass.doWork();
  5. // 确保mockXHR被使用正确的参数调用
  6. global.serviceLocator = oldServiceLocator; // 如果你忘了写这句,就糟了

传入依赖对象

最后,可以被动接收所依赖的对象。

  1. function MyClass(xhr) {
  2. this.doWork = function() {
  3. xhr({
  4. method:'...',
  5. url:'...',
  6. complete:function(response){ ... }
  7. })
  8. }

这是首选方案!因为这段代码让我们不用对xhr从哪里来作出任何假设,而只要知道谁负责创建这个类并且传给我们就够了。 因为类的创建者和类的使用者一般不是同一段代码,这里把创建类的职责从应用逻辑里分离出去。这就是依赖注入的简易原理。

上面这个类是可测试的,在测试代码中我们可以这样写:

  1. function xhrMock(args) {...}
  2. var myClass = new MyClass(xhrMock);
  3. myClass.doWork();
  4. // 确保xhrMock使用正确的参数调用

注意,这个测试中我们不用写任何全局变量。

Angular内建了依赖注入机制,让你可以很容易的“做正确的事”,但是如果你希望在可测试性方面更进一步,你还需要了解更多。

控制器(Controller)

让应用程序与众不同的地方在于它的“逻辑”,而“逻辑”正是我们想要测试的对象。 如果你的应用逻辑中包含了DOM操作,它就很难被测试了。参见下面的例子:

  1. function PasswordCtrl() {
  2. // 获得DOM元素的引用
  3. var msg = $('.ex1 span');
  4. var input = $('.ex1 input');
  5. var strength;
  6.  
  7. this.grade = function() {
  8. msg.removeClass(strength);
  9. var pwd = input.val();
  10. password.text(pwd);
  11. if (pwd.length > 8) {
  12. strength = 'strong';
  13. } else if (pwd.length > 3) {
  14. strength = 'medium';
  15. } else {
  16. strength = 'weak';
  17. }
  18. msg
  19. .addClass(strength)
  20. .text(strength);
  21. }
  22. }

上述代码在可测试性方面的问题在于,它需要你的测试代码在执行被测代码时提供正确类型的DOM。测试代码看起来将是这样的:

  1. var input = $('<input type="text"/>');
  2. var span = $('<span>');
  3. $('body').html('<div class="ex1">')
  4. .find('div')
  5. .append(input)
  6. .append(span);
  7. var pc = new PasswordCtrl();
  8. input.val('abc');
  9. pc.grade();
  10. expect(span.text()).toEqual('weak');
  11. $('body').html('');

在angular的设计中,控制器和DOM操作被严密的隔离开,其效果就是可以更轻易的提供可测试性,如下所示:

  1. function PasswordCtrl($scope) {
  2. $scope.password = '';
  3. $scope.grade = function() {
  4. var size = $scope.password.length;
  5. if (size > 8) {
  6. $scope.strength = 'strong';
  7. } else if (size > 3) {
  8. $scope.strength = 'medium';
  9. } else {
  10. $scope.strength = 'weak';
  11. }
  12. };
  13. }

测试代码也立即变得整洁了:

  1. var $scope = {};
  2. var pc = $controller('PasswordCtrl', { $scope: $scope });
  3. $scope.password = 'abc';
  4. $scope.grade();
  5. expect($scope.strength).toEqual('weak');

注意,测试代码不仅仅是变短了,也能更简明的体现出发生了什么。我们看到这段代码“描述了一个故事”,而不只是一组看起来互不相关的“点”。

过滤器(Filter)

过滤器是一个函数,用来把数据转换成用户可读的格式。 它们的重要性在于把数据格式化方面的职责从应用逻辑中移除了,从而简化了应用逻辑。

  1. myModule.filter('length', function() {
  2. return function(text){
  3. return (''+(text||'')).length;
  4. }
  5. });
  6.  
  7. var length = $filter('length');
  8. expect(length(null)).toEqual(0);
  9. expect(length('abc')).toEqual(3);

指令(Directive)

Angular中的指令,用于通过自定义HTML标记(Tag)、属性(Attribute)、类(Class)或注释(Comment)的形式封装复杂的功能。 对于指令来说,单元测试是非常重要的,因为你创建的指令有可能被用于你的整个应用程序中,甚至被用在很多不同的环境中。

简单HTML型元素指令

我们先定义一个不依赖其他模块的angular应用。

  1. var app = angular.module('myApp', []);

然后在我们的应用中添加一个指令。

  1. app.directive('aGreatEye', function () {
  2. return {
  3. restrict: 'E',
  4. replace: true,
  5. template: '<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>'
  6. };
  7. });

这个这个指令作为标记(tag)时的用法是。 这个标记会被模板

lidless, wreathed in flame, {{1 + 1}} times

代替。 另外,这里的{{1 + 1}}表达式在渲染内容时也将被计算。 接下来,我们将写一个jasmine(一种单元测试框架)单元测试,来验证这个功能。

  1. describe('单元测试集', function() {
  2. var $compile;
  3. var $rootScope;
  4.  
  5. // 加载myApp模块,它包含着指令
  6. beforeEach(module('myApp'));
  7.  
  8. // 保存$rootScope和$compile的引用,以便它们能被这里的所有测试使用
  9. beforeEach(inject(function(_$compile_, _$rootScope_){
  10. // 注射器匹配的时候会去掉参数名两端的下划线再匹配
  11. $compile = _$compile_;
  12. $rootScope = _$rootScope_;
  13. }));
  14.  
  15. it('用适当的内容替换元素', function() {
  16. // 编译一块包含指令的HTML
  17. var element = $compile("<a-great-eye></a-great-eye>")($rootScope);
  18. // 触发所有的监听(watch),以便在作用域中计算表达式{{1 + 1}}
  19. $rootScope.$digest();
  20. // 检查编译后的元素中包含了模板中的内容
  21. expect(element.html()).toContain("lidless, wreathed in flame, 2 times");
  22. });
  23. });

我们在每个jasmine测试中注入了$compile服务和$rootScope对象。 $compile服务用于渲染aGreatEye指令。 渲染这个指令后我们确保指令已经把内容替换成了 "lidless, wreathed in flame, 2 times"

范例工程

范例工程参见 Angular种子工程