单元测试

供排序方法测试时使用,用于选择排序要求

图片 1

 javascript是一门动态类型语言,这给她带来了很强的表现能力,但同时也使编译器几乎不能给开发者提供任何帮助。因为这个原因,我们感受到编写任何javascript代码都必须有一套强大完整的测试。angular拥有许多功能,让我们更加容易地测试我们的应用。我们应该没有借口不去写测试(这个嘛……)。

导言


最近在学AngularJS的实例教程PhoneCat Tutorial
App,发现网上的中文教程都比较久远,与英文版对应不上,而且缺少组件和文件重构两节。所以决定自己整理一个中文简明教程。

此篇为6-7节。
上一篇:AngularJS
Phonecat(步骤0-步骤5)

一、 It is all about NOT mixing
concerns(全部都关于避免代码关系变得复杂……)

6 下拉选择框–双向绑定


我们要增加一个下拉选择框,用于选择排序要求,让手机列表按要求排序。
为了实现自动排序,我们要在数据模型中添加排序的依据,并与迭代器联系起来。

  单元测试,正如名称那样,是关于测试单个“单元”的代码。单元测试努力解答这些问题:我对逻辑的考虑是否已经正确?排序方法得出的结果是否正确?为了解答这些问题,将这些问题独立出来显得尤其重要。这是因为当我们在测试排序方法的时候,我们不想关心其他相关的片段,例如DOM元素或者发起XHR请求获取数据等。明显地,通常比较难做到在典型的项目中单独调用一个函数。导致这个问题的原因是,开发者通常把关系弄得很复杂,最终让一个代码片段看起来可以做所有事情。它通过XHR获取数据,对数据进行排序,然后操纵DOM。与angular一起,我们可以更加容易地写出较好的代码,所以angular为我们提供XHR(我们可以模拟它)的依赖注入,angular还创建允许我们对model进行排序而不需要操作DOM的抽象。所以,到最后,我们可以简单地写一个排序方法,然后通过测试用例创建数据集合,供排序方法测试时使用,然后判断结果model是否符合预期。测试无须等待XHR、者创建对应的DOM和判断函数是否正确操作DOM。angular的核心思想包含代码的可测试性,但同时也要求我们去做正确的事情。angular致力于简化做正确事情的方法,但angular不是魔法,这意味着我们如果不遵循以下的几点,我们最终可能会得出一个不可测试的应用。

组件模板

app/phone-list/phone-list.template.html:

<div class="container-fluid">
  <div class="row">
    <div class="col-md-2">
      <!--Sidebar content-->

      <p>
        Search:
        <input ng-model="$ctrl.query">
      </p>

      <!--新增选择框,绑定$ctrl.orderProp-->
      <p>
        Sort by:        
        <select ng-model="$ctrl.orderProp">
          <option value="name">Alphabetical</option>
          <option value="age">Newest</option>
        </select>
      </p>

    </div>
    <div class="col-md-10">
      <!--Body content-->
      <!--新增orderBy,以$ctrl.orderProp为依据-->
      <ul class="phones">
        <li ng-repeat="phone in $ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp">                
          {{phone.name}}
          <p>{{phone.snippet}}</p>
        </li>
      </ul>

    </div>
  </div>
</div>
  • 增加<select>元素,并绑定到
    $ctrl.orderProp(数据模型),用户可以从两个选项中进行选择。而orderBy又以$ctrl.orderProp为依据,这样选定的选项就会成为排序依据。
    这种双向数据绑定,可以让数据变化及时反映到视图中,且不需要指定DOM操作。
  • orderBy连接了filter,过滤后的数据才会输入orderBy进行排序。orderBy的工作原理是,复制输入的数组、进行排序、输出。

1. Dependency Inject

组件控制器

app/phone-list/phone-list.component.js:

angular.
  module('phoneList').
  component('phoneList', {
    templateUrl: 'phone-list/phone-list.template.html',
    controller: function PhoneListController() {
      this.phones = [
        {
          name: 'Nexus S',
          snippet: 'Fast just got faster with Nexus S.',
          age: 1            //新增了age属性
        }, {
          name: 'Motorola XOOM™ with Wi-Fi',
          snippet: 'The Next, Next Generation tablet.',
          age: 2
        }, {
          name: 'MOTOROLA XOOM™',
          snippet: 'The Next, Next Generation tablet.',
          age: 3
        }
      ];

      this.orderProp = 'age';       //默认以“age”为排序依据
    }
  });
  • 修改数据模型:每个phone对象都新增了age属性,用于按手机推出时间排序。
  • 设置默认以“age”为排序依据。如果不设置默认值,即手机列表不会进行排序,直到用户从下拉菜单中选择选项。
  • 双向绑定:页面加载后,我们会看到“Newest”被选中,这是因为控制器设置了默认排序依据“age”,实现了数据模型–>UI的绑定。如果我们在下拉菜单中选择“Alphabetically”,数据模型也会更新,进而手机列表重新进行排序,这就是UI–>数据模型的绑定。
    总结起来就是,改变数据模型会改变视图,改变视图也会改变数据模型。

  有许多办法可以获得依赖的资源:1)我们可以使用new操作符;2)我们使用一个众所周知的方式,被称为”
全局单例”;3)我们可以向registry
service请求(但我们如何取得一个registry?可以查看后面的章节);4)我们可以期待它会被传递过来。

测试

组件的单元测试,用于验证默认排序
app/phone-list/phone-list.component.spec.js:

describe('phoneList', function() {

  // 测试前需加载`phoneList` 模块
  beforeEach(module('phoneList'));

  // 测试控制器
  describe('PhoneListController', function() {
    var ctrl;

    beforeEach(inject(function($componentController) {
      ctrl = $componentController('phoneList');
    }));

    it('should create a `phones` model with 3 phones', function() {
      expect(ctrl.phones.length).toBe(3);
    });

    it('should set a default value for the `orderProp` model', function() {
      expect(ctrl.orderProp).toBe('age');
    });

  });

});

测试成功,则命令行输出:

Chrome 22.0: Executed 2 of 2 SUCCESS (0.021 secs / 0.001 secs)

端到端测试:用于验证下拉菜单改变时的排序
e2e-tests/scenarios.js:

describe('PhoneCat Application', function() {

  describe('phoneList', function() {

    ...

    it('should be possible to control phone order via the drop-down menu', function() {
      var queryField = element(by.model('$ctrl.query'));
      var orderSelect = element(by.model('$ctrl.orderProp'));
      var nameOption = orderSelect.element(by.css('option[value="name"]'));
      var phoneNameColumn = element.all(by.repeater('phone in $ctrl.phones').column('phone.name'));

      function getNames() {
        return phoneNameColumn.map(function(elem) {
          return elem.getText();
        });
      }

      queryField.sendKeys('tablet');   // Let's narrow the dataset to make the assertions shorter
//测试默认状态下的排序
      expect(getNames()).toEqual([
        'Motorola XOOM\u2122 with Wi-Fi',
        'MOTOROLA XOOM\u2122'
      ]);
//选择按“name”排序
      nameOption.click();
//测试排序
      expect(getNames()).toEqual([
        'MOTOROLA XOOM\u2122',
        'Motorola XOOM\u2122 with Wi-Fi'
      ]);
    });

    ...

运行测试,在命令行中输入:

npm run protractor

  上面列出的方法中,只有最后一个是可测试的,让我们看看为什么:

尝试

  • phoneList组件控制器中,删除orderProp的默认值,下拉菜单会多一个空白栏(”unknown”)。
  • 在phone-list.template.html模板添加一个{{$ctrl.orderProp}}的绑定来显示当前的排序依据。
  • 反转排序只需要在排序值前面添加一个-号:

<option value="-age">Oldest</option>

1) Using the new operator

7 XHR和依赖注入


一个真实的应用程序不会只有三条数据,也不会直接写到前端代码中。所以这一节,我们会使用AngularJS的内置服务$http从服务器中获取更多的数据,并使用依赖注入(dependency
injection)为phonelist组件控制器提供服务。

  使用new操作符时基本上没有错误,但问题是通过new调用构造函数将会永久地将调用方与type绑定起来。举个例子,我们尝试实例化一个XHR对象,以让我们可以从服务器获得一些数据。

数据

app/phones/phones.json用于保存手机数据,保存格式为json,文件中的一组手机数据如下所示:

[
  {
    "age": 13,
    "id": "motorola-defy-with-motoblur",
    "name": "Motorola DEFY\u2122 with MOTOBLUR\u2122",
    "snippet": "Are you ready for everything life throws your way?"
    ...
  },
  ...
]
function MyClass() {
   this.doWork = function() {
     var xhr = new XRH();
     xhr.open(method,url,true);
     xhr.onreadystatechange = function() {…};
     xhr.send();
  }
}

组件控制器

使用$http向web服务器发送HTTP请求,并从文件app/phones/phones.json
中获取手机数据。$http是Angular的内置服务(built-in
Services)之一,内置服务用于处理常见操作,Angular会在程序需要时自动注入这些服务。
服务由Angular的依赖注入子系统管理。依赖注入能让你的web
app实现更好的结构(如独立的展示结构、数据结构和控制器)和组件松耦合(组件不用自己实现依赖关系,而是通过依赖注入子系统来实现),这样app也会更容易测试。
app/phone-list/phone-list.component.js:

angular.
  module('phoneList').
  component('phoneList', {
    templateUrl: 'phone-list/phone-list.template.html',
//向控制器函数注入$http
    controller: function PhoneListController($http) {
      var self = this;
      self.orderProp = 'age';
//$http获取json文件中的数据
      $http.get('phones/phones.json').then(function(response) {
        self.phones = response.data;
      });
    }
  });
  • $http发起GET请求,服务器响应请求,返回json文件中的手机数据
  • $http服务返回一个promise对象,该对象有then()函数,我们在then()函数中将服务器返回的数据放入控制器。
    注意:Angular会自动检测和解析json数据。
  • 由于回调函数中的this不会指向控制器,需要引用变量self来指向控制器实例。

使用Angular的某个服务,只需要将该服务名称放入控制器构造函数的参数中,比如:

function PhoneListController($http) {...}

Angular的依赖注入会在控制器初始化时提供服务,并自动处理相应的层次依赖关系(通常一个服务还依赖于其他服务)。
注意:参数的名字很重要(不能随意变动),依赖注入会用这些名字查找服务功能并进行注入。

  问题来了,在测试时,我们通常需要实例化一个可以返回测试数据或者网络错误的虚拟的XHR。通过调用new
XHR(),我们永久地绑定了真实的XHR,并且没有一个很好的方法去替代它。当然,有一个糟糕的补救办法,有很多理由可以证明那是一个糟糕的想法:

$前缀命名规则

Angula的内置服务service、作用域scope和一些其它的API都有一个$前缀。该前缀用于Angular提供的服务,所以我们自定义服务和数据模型的时候最好别用$前缀,以免发生命名冲突。另外,$$前缀用于私有属性,无法访问或修改。

var oldXHR = XHR;
XHR = new MockXHR() {};
myClass.doWork();
//判断MockXHR是否通过正常的参数进行调用
XHR = oldXHR;//如果忘了这一步,很容易会发生悲催的事情。

压缩代码时的注意事项

因为依赖注入是通过参数名来实现的,所以压缩代码的时候,要注意参数名不能被压缩,否则会导致依赖注入出错。
对于该问题,我们需要提供一个禁止压缩的依赖名称列表,具体有两个方法:

  • 在控制器构造函数中创建一个$inject字符串数组,每个字符串表示一个需要注入的服务名。例如:

function PhoneListController($http) {...}
//$inject字符串数组
PhoneListController.$inject = ['$http'];
...
.component('phoneList', {..., controller: PhoneListController});
  • 在组件注册控制器的代码中使用内联注解数组,例如:

function PhoneListController($http) {...}
...
.component('phoneList', {..., controller: ['$http', PhoneListController]});

这两种方法可任选其一。如果使用第二种方法,通常会用内联构造函数来注册控制器:

.component('phoneList', {..., controller: ['$http', function PhoneListController($http) {...}]});

现在开始,我们会在教程中使用内联注解的方式进行处理。

app/phone-list/phone-list.component.js(为PhoneListController添加内联注解):

angular.
  module('phoneList').
  component('phoneList', {
    templateUrl: 'phone-list/phone-list.template.html',
    controller: ['$http',   //内联注解
      function PhoneListController($http) {
        var self = this;
        self.orderProp = 'age';

        $http.get('phones/phones.json').then(function(response) {
          self.phones = response.data;
        });
      }
    ]
  });

2) Global look-up

测试

应用依赖注入使得控制器有了依赖关系,所以构建控制器的单元测试变复杂了。可以使用new运算符在构造函数中提供$http的模拟实现,不过现在不用那么麻烦。
Angular提供了用于单元测试的模拟$http服务,我们可以直接调用服务中的$httpBackend方法实现服务器的模拟响应。

app/phone-list/phone-list.component.spec.js:

describe('phoneList', function() {

  beforeEach(module('phoneList'));

  describe('controller', function() {
    var $httpBackend, ctrl;

    // 这里的依赖注入会忽略头部和尾部的下划线(如 _$httpBackend_).
    // 这样变量就可以用和服务同样的名字,而不会有命名冲突
    beforeEach(inject(function($componentController, _$httpBackend_) {
      $httpBackend = _$httpBackend_;
      //模拟发送http请求
      $httpBackend.expectGET('phones/phones.json')
                  //模拟响应
                  .respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);

      ctrl = $componentController('phoneList');
    }));

    ...

  });

});

注意:因为需要加载Jasmine和angular-mocks.js到测试环境,所以我们有两个辅助方法module和inject用于访问和配置注入器。

  • 使用inject()向Jasmin的beforeEach()函数注入$componentController和
    $httpBackend服务的实例,用于创建每个测试。这样保证每个测试有共同的起点,又各自独立。
  • 调用$componentController(),并传入参数’phoneList’(组件的名字)。

代码使用$http获取手机列表到控制器中,所以在创建控制器(PhoneListController)前,我们要告诉测试程序预期的输入请求:

  • 向beforEach()函数注入$httpBackend的请求服务,它会模拟生产环境中的XHR和JSONP请求。这样我们在编写测试时,就不用处理原生API和与它们相关的全局状态(服务器响应结果)。这样也避免了异步请求(XHR和JSONP是异步请求),可以加快单元测试。
  • $httpBackend.expectGET()方法可以模拟预期的HTTP请求和响应。注意:在调用$httpBackend.flush()之后,才会返回响应。

接着,我们来验证在收到响应之前,控制器中是否有phones属性:

it('should create a `phones` property with 2 phones fetched with `$http`', function() {
//收到响应前,phones未定义
  expect(ctrl.phones).toBeUndefined();
//收到响应后,phones变成一个对象数组
  $httpBackend.flush();
  expect(ctrl.phones).toEqual([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
});
  • $httpBackend.flush()激活浏览器请求序列,这会让$http服务返回预期的响应。

最后,们验证默认的orderProp值是否设置正确:

it('should set a default value for the `orderProp` property', function() {
  expect(ctrl.orderProp).toBe('age');
});

命令行应该输出:

Chrome 49.0: Executed 2 of 2 SUCCESS (0.133 secs / 0.097 secs)

  解决问题的另外一个方法是在一个众所周知的地方获取依赖的资源。

尝试

  • 在phone-list.template.html底部,加入代码:

<pre>{{$ctrl.phones | filter:$ctrl.query | orderBy:$ctrl.orderProp | json}}</pre>

可以看到json格式的手机列表。

  • 在控制器PhoneListController的$http回调函数中输入:

self.phones = response.data.slice(0, 5);

可以预处理HTTP响应,将返回的手机序号限制为0-5。

下一篇:AngularJS Phonecat
(步骤8-步骤9)

图片 1

function MyClass() {
   this.doWork = function() {
      global.xhr({…});
  };
}

  没有创建新依赖对象的实例的情况下,问题基本上与new一致,除了那个悲催的补丁以外,没有一个很好的方法可以再测试时拦截global.xhr的调用。测试的最基本的问题是global变量需要改为调用虚拟的方法而被修改。想进一步了解它的坏处,可以参观这里:

  上面的代码比较难去测试,所以我们必须修改global state:

var oldXHR = global.xhr;
global.xhr = function mockXHR(){…};
var myClass = new MyClass();
//判断MockXHR是否通过正常的参数进行调用
global.xhr = oldXHR;//如果忘了这一步,很容易会发生悲催的事情。

3) Service Registry

  拥有一个包含所有service的registry的话,似乎可以解决问题,然后,在测试代码中替换所需要的service。

function MyClass() {
   var serviceRegistry = ???;
   this.doWork = function() {
     var xhr = serviceRegistry.get(“xhr”);
    …
  };
}

  但是,serviceRegistry来自哪里?if it is: * new-ed up, the the test
has no chance to reset the services for testing * global look-up, then
the service returned is global as well (but resetting is easier, since
there is only one global variable to be
reset)(这里后面的文字跟乱码一样……没看懂)

  根据这个方法,将上面的Class修改为如下的方式:

var oldServiceLocator = global.serviceLocator;
global.serviceLocator.set('xhr', function mockXHR() {});
var myClass = new MyClass();
myClass.doWork();
//判断MockXHR是否通过正常的参数进行调用
global.serviceLocator = oldServiceLocator; //如果忘了这一步,很容易会发生悲催的事情。

4) Passing in Dependencies

  最后,依赖资源可以被传入。

function MyClass(xhr) {
   this.doWork = function() {
     xhr({…});
  };
}

  这个是首选的方式,因为代码无须理会xhr是从哪来的,也不关心谁创建了传进来的xhr。因此,类的创建者与类的使用者可以分开编码,这将创建的责任从逻辑中分离出来,这就是依赖注入的概述。

  这个class很容易测试,在测试中我们可以这样写:

function xhrMock(args) {…}
var myClass = new MyClass(xhrMock);
myClass.doWrok();
//做一些判断……
通过这个测试代码,我们可以意识到没有任何全局变量被破坏。

  angular附带的dependency-injection(//www.jb51.net/article/91775.htm),通过这种方式编写的代码,更加容易编写测试代码,如果我们想编写可测试性强的代码,我们最好使用它。

2. Controllers

  逻辑使每一个应用都是唯一的,这就是我们想去测试的。如果我们的逻辑里面混杂着DOM的操作,这将会跟下面的例子一样难测试:

function PasswordController() {
  // 获取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 = $('');
$('body').html('<div class="ex1">').find('div').append(input).append(span);
var pc = new PasswordController();
input.val('abc');
pc.grade();
expect(span.text()).toEqual('weak');
$('body').html('');

  在angular中,controller严格地将DOM操作逻辑分离出来,将大大降低编写测试用例的难度,看看下面的例子:

function PasswordCntrl($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 pc = new PasswordController($scope);
pc.password('abc');
pc.grade();
expect($scope.strength).toEqual('weak');

  值得注意的是,测试代码不仅仅更加间断,而且更加容易追踪。我们一直说测试用例是在讲故事,而不是判断其他不相关的东西。

3. Filters

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

4. Directives

5. Mocks

6. Global State Isolation

7. Preferred way of Testing

8. JavascriptTestDriver

9. Jasmine

10.   Sample project

 后续继续更新相关文章,谢谢大家对本站的支持!

您可能感兴趣的文章:

  • angularjs中的单元测试实例
  • 在JavaScript的AngularJS库中进行单元测试的方法
  • AngularJS
    单元测试(二)详解
  • AngularJS
    单元测试(一)详解
  • AngularJS使用指令增强标准表单元素功能
  • Angular.js与Bootstrap相结合实现表格分页代码
  • 浅谈angular.js中实现双向绑定的方法$watch $digest
    $apply
  • Angular.js如何从PHP读取后台数据
  • 总结十个Angular.js由浅入深的面试问题
  • 对Angular.js
    Controller如何进行单元测试