在AngularJS中进行单元测试和端到端测试
发布在Node.js测试研究2014年4月24日view:14905
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

enter image description here

在AngularJS中进行单元测试和端到端测试

单元测试是一种能够帮助开发者验证代码中某一部分有效性的技术。端到端测试(E2E)测试则是在当你想要确保一堆组件能够按照预先设想的方式整合起来运行的时候使用。作为一个现代的JavaScript MVW 框架对于单元测试和端到端测试都提供了充分的支持。在编写AngularJS应用的同时编写测试能够为你省下很多在未来需要修改bug的时间。本文将介绍如何在AngularJS中编写单元测试和端到端测试。

在本文中我们将会使用Jasmine作为测试框架并使用karma作为测试运行期。你也可以使用Yeoman为你轻松地创建一个项目脚手架,或者从Github直接找到angular-seed项目。

如果你现在还没有一个测试环境,你应该首先完成以下的步骤:

  1. 下载并安装Node.js
  2. 使用npm安装karma(npm install -g karma)
  3. 从Github上下载本文的demo并完成解压

在解压后的文件中,你可以的test/unit和test/e2e目录下找到测试文件。为了查看测试结果,你可以运行 script/test.bat 文件,它将会开启Karma服务器。我们的主要HTML文件时app/notes.html,你可以通过 https://localhost/angular-seed/app/notes.html 访问。

开始单元测试

首先,我们会创建一个简单的Angular应用,来看看如何将单元测试加入开发过程中。因此,我们创建一个应用并将单元测试运用到各个组件中。在这一部分我们将学习如何编写单元测试:

  • Controller
  • Directives
  • Filters
  • Factories

我们将会创建一个非常简单的todo应用。我们标记将会包含一个文本输入框,用户可以在这里编写一些简单的笔记。当用户按下一个按钮时,这个笔记就会被添加到一个笔记列表中。我们将使用HTML的LocalStorage来储存笔记信息。初始的HTML代码如下所示。我们同事会使用Bootstrap来创建布局。

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html ng-app="todoApp">
  <head>
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1"/>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.2/angular.min.js" type="text/javascript"></script>
<script src="//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js" type="text/javascript"></script>
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css" type="text/css"/>
<script type="text/javascript" src="js/app.js"></script>
<style>
  .center-grey{
 background:#f2f2f2;
 margin-top:20;
  }
  .top-buffer {
  margin-top:20px; 
  }
  button{
  display: block; 
  width: 100%;
  }
</style>
<title>Angular Todo Note App</title>
  </head>
  <body>
<div class="container center-grey" ng-controller="TodoController">
  <div class="row top-buffer" >
<span class="col-md-3"></span>
<span class="col-md-5">
  <input class="form-control" type="text" ng-model="note" placeholder="Add a note here"/> 
</span>
<span class="col-md-1">
  <button ng-click="createNote()" class="btn btn-success">Add</button>
</span>
<span class="col-md-3"></span>
  </div>
  <div class="row top-buffer" >
<span class="col-md-3"></span>
<span class="col-md-6">
  <ul class="list-group">
<li ng-repeat="note in notes track by $index" class="list-group-item">
  <span>{{note}}</span>
</li>
  </ul>
</span>
<span class="col-md-3"></span>
  </div>
</div>
  </body>
</html>

在上面的代码中,正如你所见,我们的Angular 模块是todoApp,控制器时TodoController。输入框被绑定到了note模型。同时也有一个列表展示已经被添加的笔记系那个吗。另外,当按钮被点击时,TodoController的createNote()函数会被调用,现在我们打开app.js文件,来创建模型和控制器。将夏敏的代码添加到app.js文件中:

var todoApp = angular.module('todoApp',[]);

todoApp.controller('TodoController',function($scope,notesFactory){
    $scope.notes = notesFactory.get();
    $scope.createNote = function(){
    notesFactory.put($scope.note);
    $scope.note='';
    $scope.notes = notesFactory.get();
    }
});

todoApp.factory('notesFactory',function(){
    return {
    put: function(note){    
        localStorage.setItem('todo' + (Object.keys(localStorage).length + 1), note);
    },
    get: function(){
        var notes = [];
        var keys = Object.keys(localStorage);
        for(var i = 0; i < keys.length; i++){
            notes.push(localStorage.getItem(keys[i]));
        }
        return notes;
    }       
    };
});

我们的TodoController使用了一个叫做notesFactory的factory服务来储存和提取笔记信息。当createNote()函数被调用时,它会使用这个factory服务来将一条信息存进localStorage中,然后清空note模型。因此,如果我们进行单元测试时,我们应该确保当控制器初始化时,scope包含了一定数量的笔记。在调用createNote()函数之后,笔记的数量应该比之前的数量加一。我们进行单元测试的代码如下所示:

describe('TodoController Test', function() {
  beforeEach(module('todoApp')); // 将会在所有的it()之前运行

  // 我们在这里不需要真正的factory。因此我们使用一个假的factory。
  var mockService = {
    notes: ['note1', 'note2'], //仅仅初始化两个项目
    get: function() {
      return this.notes;
    },
    put: function(content) {
      this.notes.push(content);
    }
  };

  // 现在是真正的东西,测试spec
  it('should return notes array with two elements initially and then add one',
    inject(function($rootScope, $controller) { //注入依赖项目
      var scope = $rootScope.$new();

      // 在创建控制器的时候,我们也要注入依赖项目
      var ctrl = $controller('TodoController', {$scope: scope, notesFactory:mockService});

      // 初始化的技术应该是2
      expect(scope.notes.length).toBe(2);

      // 输入一个新项目
      scope.note = 'test3';

      // now run the function that adds a new note (the result of hitting the button in HTML)
      // 现在运行这个函数,它将会增加一个新的笔记项目
      scope.createNote();

      // 期待现在的笔记数目是3
      expect(scope.notes.length).toBe(3);
    })
  );
});

解释

describe()方法定义了一个测试套件。它的作用是将所有测试包含在这个套件中。其中我们在所有的it()之前执行了一个beforeEach()函数。每一个it()函数是我们的测试spec,其中进行的是真正的测试。因此,在所有的测试被执行之前,我们需要载入我们的模块。

由于这是一个单元测试,我们不需要外部依赖。你已经知道了我们的控制器依赖于noteFactory处理笔记。因此,为了对控制器进行单云测试我们需要使用一个假的factory或service。这就是我们为什么要创造mokeService的原因,它用来模拟真是的noteFactory,其中包含相同的函数,get()和put()。虽然我们真正的factory使用localStorage来存储笔记,这个假的factory使用一个内嵌的数组。

现在,我们来看看it()函数究竟是怎么被用来进行测试的。你会看到它声明了两个依赖项目$rootScope和$controller,它们都可以由Angular自动注入。两个服务被用来获取根作用域和创建新控制器。

$controller服务需要两个参数。第一个参数是将要创建的控制器的名称。第二个参数是一个代表控制器依赖项目的对象,$rootScope.$new()方法将会返回一个新的自作用于,它用来注入控制器。注意到我们同事也为控制器传递了一个我们定义的假factory。

现在,expect(scope.notes.length).toBe(2)会在控制器初始化scope.notes的时候进行断言。如果笔记的数目多于或者少于两个,测试将会失败。类似的,我们产生了一个新的note模型,然后运行了createNote()函数,我们预期它将会增加一个新的笔记项目。现在expect(scope.notes.length).toBe(3)会检查结果。由于我们在初始化的时候添加了两个项目,现在应该有3个项目。你可以在karma中查看测试成功还是失败。

测试Factory

现在我们想要对factory进行单元测试来确保它能够像预想中一样运行。测试的代码如下所示:

describe('notesFactory tests', function() {
  var factory;

  // 在所有it()函数之前运行
  beforeEach(function() {
    // 载入模块
    module('todoApp');

    // 注入你的factory服务
    inject(function(notesFactory) {
      factory = notesFactory;
    });

    var store = {
      todo1: 'test1',
      todo2: 'test2',
      todo3: 'test3'
    };

    spyOn(localStorage, 'getItem').andCallFake(function(key) {
      return store[key];
    });

    spyOn(localStorage, 'setItem').andCallFake(function(key, value) {
      return store[key] = value + '';
    });

    spyOn(localStorage, 'clear').andCallFake(function() {
      store = {};
    });

    spyOn(Object, 'keys').andCallFake(function(value) {
      var keys=[];

      for(var key in store) {
        keys.push(key);
      }

      return keys;
    });
  });

  // 检查是否有我们想要的函数
  it('should have a get function', function() {
    expect(angular.isFunction(factory.get)).toBe(true);
    expect(angular.isFunction(factory.put)).toBe(true);
  });

  // 检查是否返回3条记录
  it('should return three todo notes initially', function() {
    var result = factory.get();

    expect(result.length).toBe(3);
  });

  // 检查是否添加了一条新纪录
  it('should return four todo notes after adding one more', function() {
    factory.put('Angular is awesome');

    var result = factory.get();
    expect(result.length).toBe(4);
  });
});

测试的过程和前面提到的TodoController的测试很类似。记住,实际上的factory使用localStorage来存储和提取笔记项目。但是,由于我们在进行单元测试于是我们并不想要依赖外部服务。因此,我们需要转换localStorage.getItem()以及localStorage.serItem()为假函数,而不是使用真正的localStorage函数。spuOn(localStorage,’setItem’).andCallFake()就是用来做这件事的。spyOn函数的第一个参数指明了我们感兴趣的对象,第二个参数指明了我们需要去监视的函数。andCallfake()让欧文们可以编写自己的函数。因此,在这个测试中我们已经完成了对于localStorage中使用的函数的配置。在我们的factory中我们也要使用Object.keys()函数来进行迭代以获取笔记的总数目。因此,在这个简单的例子中我们也能对Object.keys(localStorage)进行监视并返回我们的数组中的值,而不是localStorage。

接下来,我们检查了这个factory中是否包含我们需要的函数(get()和put())。这可以通过angular.isFunction()函数来完成。然后我们检查了这个factory中时候初始化了3个笔记。最后我们添加了一个新笔记然后断言笔记的数目添加了一个。

测试一个过滤器

现在,假设我们需要修改笔记在页面上展示的方式。如果一个笔记的字数超过了20个字符我们只应该展示前10个。我们现在就来编写一个简单的过滤器truncate来做这件事:

todoApp.filter('truncate',function(){
    return function(input,length){
        return (input.length > length ? input.substring(0,length) : input);
    }
});

在页面标记中,它应该这样被使用:

{{note } truncate:10 }}  

为了对这个过滤器进行单元测试,我们应该编写下面的代码:

describe('filter test',function(){
    beforeEach(module('todoApp'));
    it('should truncate the input to 1o characters',inject(function(truncateFilter){
        expect(truncateFilter('abcdefghijkl',10).length).toBe(10);
    });
  );
});  

前面的代码非常直观。需要的注意的一点事我们需要在过滤器的名称后面加上Filter。然后就像定义时候那样调用即可。

测试一个指令

我们来创建一个简单的指令,它可以给绑定这个指令的元素添加一个背景色。这可以通过CSS简单的完成。但是,为了来学习如何测试一个指令,我们还是编写了下面的代码:

    todoApp.directive('customColor', function() {
  return {
    restrict: 'A',
    link: function(scope, elem, attrs) {
      elem.css({'background-color': attrs.customColor});
    }
  };
});  

这个指令可以运用于任何元素,例如<ul custom-color="rgb(128,128,128)"></ul>。测试代码如下所示:

describe('directive tests',function(){
    beforeEach(module('todoApp'));
    it('should set background to rgb(128, 128, 128)',
    inject(function($compile,$rootScope) {
      scope = $rootScope.$new();

      // 获得一个元素
      elem = angular.element("<span custom-color=\"rgb(128, 128, 128)\">sample</span>");

      // 创建一个新的自作用域
      scope = $rootScope.$new();

      // 最后编译HTML
      $compile(elem)(scope);

      // 希望元素的背景色和我们所想的一样
      expect(elem.css("background-color")).toEqual('rgb(128, 128, 128)');
     })
  );
});

我们需要一个叫做$compile的服务来完成实际的编译,然后测试我们想要进行测试的元素。angular.element()会创建一个jqLite或者jQuery(如果可用)元素为我们所用。接着,我们将这个元素编译到一个作用域,它现在就可以被测试了。在上面的例子中我们希望background-color属性等于rgb(128,128,128)。

在Angular中使用E2E测试

在E2E测试中我们将一堆组件合起来然后检查全过程是否如我们预想的那样运运作。在我们的例子中我们想要确保当一个用户在文本输入框中输入文本然后点击按钮的时候,信息能够被添加到localStorage中然后出现在文本框下面的列表中。

E2E测试会使用一个Angular的场景运行器。如果你已经下载了demo应用并且解压完成,你可以看到test/e2e中有一个runner.html文件。这就是我们的场景运行文件。scenario.js文件包含了e2e测试(你应该在这里编写测试)。在编写完测试之后,你可以运行 http://localhost/angular-seed/test/e2e/runner.html 来查看结果。E2E测试的代码如下所示:

describe('my app', function() {
  beforeEach(function() {
    browser().navigateTo('../../app/notes.html');
  });

  var oldCount = -1;

  it("entering note and performing click", function() {
    element('ul').query(function($el, done) {
      oldCount = $el.children().length;
      done();
    });

    input('note').enter('test data');

    element('button').query(function($el, done) {
      $el.click();
      done();
    });
  });

  it('should add one more element now', function() {
    expect(repeater('ul li').count()).toBe(oldCount + 1);
  });        
});  

解释

当我们要进行一次测试的时候我们应该首先导航到我们的主要HTML页面,app/notes.html。这可以通过browser.navigateTo()来完成。element.query()函数选择了ul元素并且记录了其中有哦多少个初始化项目。这个制备存储在oldCount变量中。接着,我们模拟在文本框中输入一个笔记,通过input(’note’)enter()完成。需要注意的是你需要将模型的名称传递给input函数。在我们的HTML页面中input被绑定到了ng-model=note。因此,模型名称应该被用来识别我们的输入字段。然后我们对按钮进行了一次点击然后检查列表中是否增加了一个新的笔记(li元素)。我们可以通过将新的笔记数目和旧的笔记数目进行对比得出结论。

总结

AngularJS应用的开发过程和测试似乎分不开的,尤其是TDD。一开始看似花费时间的测试一定会在最后帮你省下很多修复bug的时间。


本文译自Unit and End to End Testing in AngularJS,原文地址 http://www.sitepoint.com/unit-and-e2e-testing-in-angularjs/

如果你觉得本文对你有帮助,请为我提供赞助 https://me.alipay.com/jabez128

评论
发表评论
3年前
赞了此文章!
3年前
赞了此文章!
WRITTEN BY
张小俊128
Intern in Baidu mobile search department。认真工作,努力钻研,期待未来更多可能。
TA的新浪微博
PUBLISHED IN
Node.js测试研究

介绍Node.js的相关知识

我的收藏