JavaScript是一个动态类型的语言,具有强大的表达能力,但同时,你几乎没法从编译器获得任何帮助。 因此,我们深切体会到:任何JavaScript程序都需要伴随着一组强大的测试。 我们在angular中引入了很多特性来让你更轻松的测试应用程序。 所以,想不写测试?不,没有任何借口!
单元测试,顾名思义就是用于测试代码中不可分割的单元。单元测试试图回答下列问题:“我所认为的逻辑正确吗?”或者“我的sort函数是否按照正确的顺序排序了这个列表?”
为了回答上述问题,最重要的事情就是:我们要能把这个单元的代码隔离到一个独立的test模块中。 这是因为我们测试sort函数的时候,绝不会希望也被迫创建相关的部分 —— 比如DOM元素,或者先发起一个XHR调用来获取完数据才能测试sort函数。
虽然这看起来无所谓,但在一个典型的项目中,想要调用一个独立函数确实是非常困难的。 原因在于,开发人员经常会把不同的任务揉成一团,导致一部分代码中往往会做很多很多事:发起XHR请求,对返回的数据进行排序,然后更新到DOM。
在Angular中,我们尝试进行简化,来确保你总能“做正确的事”,所以,我们提供了依赖注入(DI),来让你自由使用XHR(这样你就能mock它了); 我们创建了抽象层,让你可以排序你的模型(Model),而不用去管DOM。 最终的结果就是:很容易写出这样一个对一些数据进行排序的sort函数,你的测试程序可以创建一组数据、调用sort函数,然后验证这些数据是否被按照正确的顺序排列了。 这个测试不用等XHR返回结果,不用想方设法创建正确类型的测试用DOM,也不用验证DOM节点是否根据排好的顺序发生了变化。
写Angular的核心思想之一就是“可测试性”,但是它仍然需要你按照正确的方式使用它。 你当然希望轻而易举的把事情做对,但Angular不是魔术。如果你不遵循下列指导原则,你仍然很可能得到一个不可测试的程序。
你可以有很多种方式获得所依赖的对象。比如:
new
运算符创建一个。在这四种方案中,只有最后一种是可测试的。让我们分析一下这是为什么:
new
运算符本质上,使用new
运算符没有错,问题出在从构造函数中调用new
运算符的时候。
这种情况下,调用者被永久性的和它要new
的这个类型绑定在一起。比如,如果为了从服务器获得数据而对XHR进行实例化会导致什么后果?
function MyClass() { this.doWork = function() { var xhr = new XHR(); xhr.open(method, url, true); xhr.onreadystatechange = function() {...} xhr.send(); } }
在测试时,问题表现在:当我们想要实例化一个MockXHR
—— 我们需要它来返回模拟数据,并且模拟网络异常。
如果我们调用new XHR()
来获得实例,我们就永久性的和实际的XHR(而不是Mock的!)绑定在一起,并且没有任何办法替换它。
固然,我们可以使用猴子补丁(译注:monkey patch —— 见下面例子),但这绝对是个坏注意,理由很多,不过本文档中不展开论述。
下面是一个例子,可以看出为何即使借助于猴子补丁仍然不是个好办法。
var oldXHR = XHR; XHR = function MockXHR() {}; var myClass = new MyClass(); myClass.doWork(); // 确保MockXHR按照正确的参数进行了调用 XHR = oldXHR; // 如果你忘了写这句,就糟了
另一个方法是从一个众所周知的地方查找此服务。
function MyClass() { this.doWork = function() { global.xhr({ method:'...', url:'...', complete:function(response){ ... } }) } }
虽然这次没有直接创建新的依赖对象,问题和new
方案仍是一样的:测试方无法拦截对global.xhr
的调用 —— 除非通过猴子补丁。
对测试来说,根本问题在于全局变量应该允许被测试方修改,以便能替换它,并且调用一个mock函数。
关于“这种方式为什么不好”的详细论述请参见:
孤僻的全局状态和单例对象
上面这个类之所以难于测试,原因就在于我们不得不修改全局状态:
var oldXHR = global.xhr; global.xhr = function mockXHR() {}; var myClass = new MyClass(); myClass.doWork(); // 确保mockXHR使用正确的参数调用了 global.xhr = oldXHR; // 如果你忘了写这句,就糟了
粗看起来似乎有一个好办法解决这个问题:创建一个注册表,它保存着所有服务,那么测试方就可以替换这些服务了。
function MyClass() { var serviceRegistry = ????; this.doWork = function() { var xhr = serviceRegistry.get('xhr'); xhr({ method:'...', url:'...', complete:function(response){ ... } }) }
问题在于,serviceRegistry从哪里来呢?如果:
上面的这个类仍然难于测试,因为我们还是不得不修改全局状态:
var oldServiceLocator = global.serviceLocator; global.serviceLocator.set('xhr', function mockXHR() {}); var myClass = new MyClass(); myClass.doWork(); // 确保mockXHR被使用正确的参数调用 global.serviceLocator = oldServiceLocator; // 如果你忘了写这句,就糟了
最后,可以被动接收所依赖的对象。
function MyClass(xhr) { this.doWork = function() { xhr({ method:'...', url:'...', complete:function(response){ ... } }) }
这是首选方案!因为这段代码让我们不用对xhr
从哪里来作出任何假设,而只要知道谁负责创建这个类并且传给我们就够了。
因为类的创建者和类的使用者一般不是同一段代码,这里把创建类的职责从应用逻辑里分离出去。这就是依赖注入的简易原理。
上面这个类是可测试的,在测试代码中我们可以这样写:
function xhrMock(args) {...} var myClass = new MyClass(xhrMock); myClass.doWork(); // 确保xhrMock使用正确的参数调用
注意,这个测试中我们不用写任何全局变量。
Angular内建了依赖注入机制,让你可以很容易的“做正确的事”,但是如果你希望在可测试性方面更进一步,你还需要了解更多。
让应用程序与众不同的地方在于它的“逻辑”,而“逻辑”正是我们想要测试的对象。 如果你的应用逻辑中包含了DOM操作,它就很难被测试了。参见下面的例子:
function PasswordCtrl() { // 获得DOM元素的引用 var msg = $('.ex1 span'); var input = $('.ex1 input'); var strength; this.grade = function() { msg.removeClass(strength); var pwd = input.val(); password.text(pwd); if (pwd.length > 8) { strength = 'strong'; } else if (pwd.length > 3) { strength = 'medium'; } else { strength = 'weak'; } msg .addClass(strength) .text(strength); } }
上述代码在可测试性方面的问题在于,它需要你的测试代码在执行被测代码时提供正确类型的DOM。测试代码看起来将是这样的:
var input = $('<input type="text"/>'); var span = $('<span>'); $('body').html('<div class="ex1">') .find('div') .append(input) .append(span); var pc = new PasswordCtrl(); input.val('abc'); pc.grade(); expect(span.text()).toEqual('weak'); $('body').html('');
在angular的设计中,控制器和DOM操作被严密的隔离开,其效果就是可以更轻易的提供可测试性,如下所示:
function PasswordCtrl($scope) { $scope.password = ''; $scope.grade = function() { var size = $scope.password.length; if (size > 8) { $scope.strength = 'strong'; } else if (size > 3) { $scope.strength = 'medium'; } else { $scope.strength = 'weak'; } }; }
测试代码也立即变得整洁了:
var $scope = {}; var pc = $controller('PasswordCtrl', { $scope: $scope }); $scope.password = 'abc'; $scope.grade(); expect($scope.strength).toEqual('weak');
注意,测试代码不仅仅是变短了,也能更简明的体现出发生了什么。我们看到这段代码“描述了一个故事”,而不只是一组看起来互不相关的“点”。
过滤器
是一个函数,用来把数据转换成用户可读的格式。
它们的重要性在于把数据格式化方面的职责从应用逻辑中移除了,从而简化了应用逻辑。
myModule.filter('length', function() { return function(text){ return (''+(text||'')).length; } }); var length = $filter('length'); expect(length(null)).toEqual(0); expect(length('abc')).toEqual(3);
Angular中的指令,用于通过自定义HTML标记(Tag)、属性(Attribute)、类(Class)或注释(Comment)的形式封装复杂的功能。 对于指令来说,单元测试是非常重要的,因为你创建的指令有可能被用于你的整个应用程序中,甚至被用在很多不同的环境中。
我们先定义一个不依赖其他模块的angular应用。
var app = angular.module('myApp', []);
然后在我们的应用中添加一个指令。
app.directive('aGreatEye', function () { return { restrict: 'E', replace: true, template: '<h1>lidless, wreathed in flame, 2 times</h1>' }; });
这个这个指令作为标记(tag)时的用法是<a-great-eye></a-great-eye>
。
这个标记会被模板<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>
代替。
另外,这里的{{1 + 1}}
表达式在渲染内容时也将被计算。
接下来,我们将写一个jasmine(一种单元测试框架)单元测试,来验证这个功能。
describe('单元测试集', function() { var $compile; var $rootScope; // 加载myApp模块,它包含着指令 beforeEach(module('myApp')); // 保存$rootScope和$compile的引用,以便它们能被这里的所有测试使用 beforeEach(inject(function(_$compile_, _$rootScope_){ // 注射器匹配的时候会去掉参数名两端的下划线再匹配 $compile = _$compile_; $rootScope = _$rootScope_; })); it('用适当的内容替换元素', function() { // 编译一块包含指令的HTML var element = $compile("<a-great-eye></a-great-eye>")($rootScope); // 触发所有的监听(watch),以便在作用域中计算表达式2 $rootScope.$digest(); // 检查编译后的元素中包含了模板中的内容 expect(element.html()).toContain("lidless, wreathed in flame, 2 times"); }); });
我们在每个jasmine测试中注入了$compile服务和$rootScope对象。 $compile服务用于渲染aGreatEye指令。 渲染这个指令后我们确保指令已经把内容替换成了 "lidless, wreathed in flame, 2 times"
范例工程参见 Angular种子工程。