单元测试是一种技术,可帮助开发人员验证隔离的代码段。 当您要确定一组组件集成在一起时,可以按预期工作时,就要进行端到端测试(E2E)。 AngularJS是现代JavaScript MVC框架,为单元测试和E2E测试提供全面支持。 在开发Angular应用程序时编写测试可以为您节省大量时间,否则您将浪费很多时间来修复意外的错误。 本教程将说明如何在Angular应用程序中合并单元测试和E2E测试。 本教程假定您熟悉AngularJS开发。 您还应该对构成Angular应用程序的不同组件感到满意。
我们将使用Jasmine作为测试框架,并使用Karma作为测试运行程序。 您可以使用Yeoman轻松地为您搭建项目,也可以从GitHub快速获取有角的种子应用程序 。
如果您没有测试环境,请按照以下步骤操作:
- 下载并安装Node.js (如果尚未安装)。
- 使用npm(
npm install -g karma
)npm install -g karma
。 - 从GitHub下载本教程的演示应用程序并将其解压缩。
在解压缩的应用程序内,您可以在test/unit
和test/e2e
目录中找到测试。 要查看单元测试的结果,只需运行scripts/test.bat
启动Karma服务器。 我们的主要HTML文件是app/notes.html
,可以从http://localhost/angular-seed/app/notes.html进行访问。
单元测试入门
让我们构建一个简单的Angular应用程序,看看单元测试如何适合开发过程,而不是仅仅看单元测试的编写方式。 因此,让我们从一个应用程序开始,然后将单元测试同时应用于各个组件。 在本节中,您将学习如何进行单元测试:
- 控制器
- 指令
- 筛选器
- 工厂工厂
我们将构建一个非常简单的待办事项笔记应用程序。 我们的标记将包含一个文本字段,用户可以在其中编写简单的注释。 当按下按钮时,便笺将添加到便笺列表中。 我们将使用HTML5 本地存储来存储注释。 初始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
的工厂来存储和检索注释。 运行createNote()
函数时,它将使用工厂将注释放入localStorage
,然后清除note
模型。 因此,如果要对TodoController
进行单元测试, TodoController
需要确保在初始化控制器时, scope
包含一定数量的注释。 运行范围的createNote()
函数后,注释数应比以前的计数多一。 我们的单元测试的代码如下所示。
describe('TodoController Test', function() {
beforeEach(module('todoApp')); // will be run before each it() function
// we don't need the real factory here. so, we will use a fake one.
var mockService = {
notes: ['note1', 'note2'], //just two elements initially
get: function() {
return this.notes;
},
put: function(content) {
this.notes.push(content);
}
};
// now the real thing: test spec
it('should return notes array with two elements initially and then add one',
inject(function($rootScope, $controller) { //injects the dependencies
var scope = $rootScope.$new();
// while creating the controller we have to inject the dependencies too.
var ctrl = $controller('TodoController', {$scope: scope, notesFactory:mockService});
// the initial count should be two
expect(scope.notes.length).toBe(2);
// enter a new note (Just like typing something into text box)
scope.note = 'test3';
// now run the function that adds a new note (the result of hitting the button in HTML)
scope.createNote();
// expect the count of notes to have been increased by one!
expect(scope.notes.length).toBe(3);
})
);
});
说明
describe()
方法定义测试套件。 它只是说明套件中包含哪些测试。 里面有一个beforeEach()
函数, it()
在每个it()
函数运行之前执行。 it()
函数是我们的测试规格,并具有要进行的实际测试。 因此,在执行每个测试之前,我们需要加载模块。
由于这是一个单元测试,因此我们不需要外部依赖项。 您已经知道我们的控制器依靠notesFactory
处理注释。 因此,要对控制器进行单元测试,我们需要使用模拟工厂或服务。 这就是为什么我们创建了mockService
, mockService
模拟了真正的notesFactory
并具有相同的功能get()
和put()
。 虽然我们的实际工厂使用localStorage
来存储笔记,但假工厂使用的是基础数组。
现在让我们检查用于执行测试的it()
函数。 您会看到它声明了两个依赖项$rootScope
和$controller
,它们由Angular自动注入。 这两个服务分别是获取应用程序的根范围和创建控制器所必需的。
$controller
服务需要两个参数。 第一个是要创建的控制器的名称。 第二个对象代表控制器的依赖关系。 $rootScope.$new()
返回控制器所需要的新子范围。 注意,我们还将伪造的工厂实现传递给了控制器。
现在, expect(scope.notes.length).toBe(2)
断言,控制器初始化时scope.notes
包含两个注释。 如果它的注释超过或少于两个,则此测试将失败。 同样,我们用新项目填充note
模型,然后运行应该添加新便笺的createNote()
函数。 现在expect(scope.notes.length).toBe(3)
对此进行检查。 因为从一开始我们就用两个项目初始化了数组,所以在运行createNote()
它应该再有一个(三个项目)。 您可以查看在Karma中哪些测试失败/成功。
测试工厂
现在我们要对工厂进行单元测试,以确保它能按预期工作。 notesFactory
如下所示。
describe('notesFactory tests', function() {
var factory;
// excuted before each "it()" is run.
beforeEach(function() {
// load the module
module('todoApp');
// inject your factory for testing
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;
});
});
// check to see if it has the expected function
it('should have a get function', function() {
expect(angular.isFunction(factory.get)).toBe(true);
expect(angular.isFunction(factory.put)).toBe(true);
});
//check to see if it returns three notes initially
it('should return three todo notes initially', function() {
var result = factory.get();
expect(result.length).toBe(3);
});
//check if it successfully adds a new item
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
相同。 请记住,实际工厂使用localStorage
来存储和检索注释项。 但是,由于我们正在进行单元测试,因此我们不想依赖外部服务。 因此,我们需要将诸如localStorage.getItem()
和localStorage.setItem()
类的函数调用转换为伪函数,以使用我们自己的存储,而不是使用localStorage
的基础数据存储。 spyOn(localStorage, 'setItem').andCallFake()
执行此操作。 spyOn()
的第一个参数指定感兴趣的对象,第二个参数表示我们要监视的功能。 andCallFake()
提供了一种编写我们自己的函数实现的方法。 因此,在此测试中,我们将localStorage
函数配置为使用我们的自定义实现。 在我们的工厂中,我们还使用Object.keys()
函数进行迭代并获取注释的总数。 因此,在这种简单情况下,我们还可以监视Object.keys(localStorage)
以从我们自己的存储而不是本地存储返回键。
接下来,我们检查工厂是否包含必需的函数( get()
和put()
)。 这是通过angular.isFunction()
。 然后,我们检查工厂最初是否有三张纸条。 在上一个测试中,我们添加了一个新音符并断言它使音符数增加了一个。
测试过滤器
现在,假设我们需要修改注释在页面上的显示方式。 如果注释的文本超过20个字符,我们应该仅显示前10个字符。让我们为此编写一个简单的过滤器,并将其命名为truncate
,如下所示。
todoApp.filter('truncate', function() {
return function(input,length) {
return (input.length > length ? input.substring(0, length) : input );
};
});
在标记中,可以这样使用:
{{note | truncate:20}}
要对其进行单元测试,可以使用以下代码。
describe('filter tests', function() {
beforeEach(module('todoApp'));
it('should truncate the input to 10 characters',
//this is how we inject a filter by appending Filter to the end of the filter name
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();
// get an element representation
elem = angular.element("<span custom-color=\"rgb(128, 128, 128)\">sample</span>");
// create a new child scope
scope = $rootScope.$new();
// finally compile the HTML
$compile(elem)(scope);
// expect the background-color css property to be desirabe one
expect(elem.css("background-color")).toEqual('rgb(128, 128, 128)');
})
);
});
我们需要一个名为$compile
(由Angular注入)的服务来实际编译和测试在其上应用了指令的元素。 angular.element()
创建一个jqLite或jQuery(如果可用)元素供我们使用。 然后,我们用一个作用域对其进行编译,并准备对其进行测试。 在这种情况下,我们希望background-color
CSS属性为rgb(128, 128, 128)
。 请参阅此文档 ,了解可以在element
调用哪些方法。
端到端的E2E测试
在端到端测试中,我们将一组组件组合在一起,并检查整个过程是否按预期工作。 在我们的案例中,我们需要确保当用户在文本字段中输入内容并单击按钮时,它将添加到localStorage
并出现在文本字段下方的列表中。
此E2E测试使用Angular场景运行器。 如果您下载了演示应用程序并将其解压缩,则可以看到test/e2e
里面有一个runner.html
。 这是我们的方案运行器文件。 该scenarios.js
文件包含端到端测试(你将在这里写的测试)。 编写测试后,您可以运行http://localhost/angular-seed/test/e2e/runner.html查看结果。 该E2E测试要添加到scenarios.js
如下所示。
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('note').enter()
。 请注意,您需要将模型名称传递给input()
函数。 在我们的HTML页面中,输入绑定到ng-model
note
。 因此,应将其用于标识我们的输入字段。 然后,我们单击按钮,并检查是否在列表中添加了新注释( li
元素)。 我们通过将新计数(由repeater('ul li').count()
)与旧计数进行比较来实现。
结论
AngularJS在设计时就考虑到了可靠的JavaScript测试,并且偏向于测试驱动开发。 因此,在开发过程中请始终测试代码。 这似乎很耗时,但实际上它消除了以后会出现的大多数错误,从而节省了您的时间。
其他资源
- 如果您的服务/工厂使用
http
服务来调用远程API,则可以从中返回假数据以进行单元测试。 这是一个指南 。 - 这个来自Angular网站的文档提供了一些有关单元测试的良好信息。
- 如果要开始新的Angular项目,请考虑使用Protractor进行E2E测试。
From: https://www.sitepoint.com/unit-and-e2e-testing-in-angularjs/