翻译者:@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进行实例化会导致什么后果?
- 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从哪里来呢?如果:
- 是 new 出来的,那么测试方没有机会重定义这些服务以供测试。
- 来自全局查找,那么所返回的服务也是全局的(但是重定义比较容易,因为需要重定义的只有一个全局变量)
上面的这个类仍然难于测试,因为我们还是不得不修改全局状态:
- 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内建了依赖注入机制,让你可以很容易的“做正确的事”,但是如果你希望在可测试性方面更进一步,你还需要了解更多。
控制器(Controller)
让应用程序与众不同的地方在于它的“逻辑”,而“逻辑”正是我们想要测试的对象。 如果你的应用逻辑中包含了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');
注意,测试代码不仅仅是变短了,也能更简明的体现出发生了什么。我们看到这段代码“描述了一个故事”,而不只是一组看起来互不相关的“点”。
过滤器(Filter)
过滤器
是一个函数,用来把数据转换成用户可读的格式。 它们的重要性在于把数据格式化方面的职责从应用逻辑中移除了,从而简化了应用逻辑。
- myModule.filter('length', function() {
- return function(text){
- return (''+(text||'')).length;
- }
- });
- var length = $filter('length');
- expect(length(null)).toEqual(0);
- expect(length('abc')).toEqual(3);
指令(Directive)
Angular中的指令,用于通过自定义HTML标记(Tag)、属性(Attribute)、类(Class)或注释(Comment)的形式封装复杂的功能。 对于指令来说,单元测试是非常重要的,因为你创建的指令有可能被用于你的整个应用程序中,甚至被用在很多不同的环境中。
简单HTML型元素指令
我们先定义一个不依赖其他模块的angular应用。
- var app = angular.module('myApp', []);
然后在我们的应用中添加一个指令。
- app.directive('aGreatEye', function () {
- return {
- restrict: 'E',
- replace: true,
- template: '<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>'
- };
- });
这个这个指令作为标记(tag)时的用法是。 这个标记会被模板
lidless, wreathed in flame, {{1 + 1}} times
代替。 另外,这里的{{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),以便在作用域中计算表达式{{1 + 1}}
- $rootScope.$digest();
- // 检查编译后的元素中包含了模板中的内容
- expect(element.html()).toContain("lidless, wreathed in flame, 2 times");
- });
- });
我们在每个jasmine测试中注入了$compile服务和$rootScope对象。 $compile服务用于渲染aGreatEye指令。 渲染这个指令后我们确保指令已经把内容替换成了 "lidless, wreathed in flame, 2 times"
范例工程
范例工程参见 Angular种子工程。