Angular-测试驱动开发第二版-全-
Angular 测试驱动开发第二版(全)
原文:
zh.annas-archive.org/md5/2587abd8d5ac1ecccf1401601540e791译者:飞龙
前言
本书将为读者提供关于以 JavaScript 为重点的测试驱动开发(TDD)的完整指南,然后深入 Angular 方法。它将提供清晰、逐步的示例,以不断强化 TDD 中的最佳实践。本书将探讨使用 Karma 进行单元测试和使用 Protractor 进行端到端测试,不仅关注如何使用这些工具,还理解它们被构建的原因以及为什么应该使用它们。在整个过程中,将重点关注何时、何地以及如何使用这些工具,不断强化测试驱动开发生命周期(测试、执行和重构)的原则。
本书中的所有示例均基于 Angular v2,且与 Angular v4 兼容。
本书涵盖的内容
第一章测试驱动开发简介通过解释为什么以及如何 TDD 可以帮助开发过程,向我们介绍了测试驱动开发的基本原理。
第二章JavaScript 测试的细节在 JavaScript 环境中涵盖了 TDD。本章探讨了 JavaScript 应用程序中需要的测试类型,如单元测试、行为测试、集成测试和端到端测试。这也解释了不同类型的 JavaScript 测试工具、框架及其在 Angular 应用程序中的用途。
第三章Karma 之道探讨了 Karma 的起源以及为什么它必须在任何 Angular 项目中使用。到本章结束时,读者不仅将理解 Karma 解决的问题,还将通过 Karma 的完整示例进行实践。
第四章使用 Protractor 进行端到端测试查看端到端测试应用程序,贯穿应用程序的所有层。本章向读者介绍了来自 Angular 团队的端到端测试工具 Protractor。然后解释了为什么创建 Protractor 以及它是如何解决问题的。最后,逐步指导读者如何在现有的 Angular 项目中使用 TDD 安装、配置和使用 Protractor。
第五章量角器,更进一步对 Protractor 进行了更深入的探讨,并探索了一些高级配置。然后,它通过示例解释了测试套件的调试过程。这也探讨了 Protractor 的一些常用 API 及其相关示例。
第六章第一步通过介绍如何使用 TDD 用类、组件和服务构建 Angular 应用程序,提供了一个入门级教程。本章帮助读者开始 TDD 之旅,并看到基础原理的实际应用。到目前为止,本书一直专注于 TDD 和工具的基础。然后,通过更进一步,它深入探讨了 Angular 中的 TDD。
第七章开关继续扩展我们应用 TDD(测试驱动开发)的知识,使用 Angular 的路由和导航功能通过我们的示例 Angular 应用获取结果集。除了 Angular 功能外,本章还指导读者如何借助 Protractor 对这些特定功能进行端到端测试。
第八章告诉世界,涵盖了更多针对示例 Angular 应用的单元测试,包括路由和导航。除此之外,本章还对现有代码进行了重构,使其更易于测试。然后,在重构代码的同时介绍了 Angular 服务和事件广播,并引入 MockBackend 来测试服务中的 HTTP 请求。
你需要这本书的内容
在这本书中,我们使用了Node 包管理器(npm)作为我们的命令工具来运行应用程序和各种测试工具。因此,在您的操作系统上全局安装 npm 是先决条件。要安装它,您必须在操作系统上安装 Node.js。
我们不会探讨如何安装 Node.js 和 npm。关于在任意操作系统上安装它们的资源已经很多了。
这本书面向的对象
这本书是为那些对 Angular 有基本经验但想了解何时、为什么以及如何应用测试技术和最佳实践以创建高质量代码的开发的读者而写的。为了最大限度地利用这本书,你应该对 HTML、CSS 和 JavaScript 有良好的理解,并对 Angular 和 TypeScript 有基本的了解。
惯例
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:"接下来的代码读取链接并将其分配给Calculator函数。"
代码块设置如下:
var calculator = {
multiply : function(amount1, amount2) {
return amount1 * amount2;
}
};
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将被设置为粗体:
<!DOCTYPE html>
<html>
<head>
<title>Test Runner</title>
</head>
<body>
// ...
<script src="img/calculator.js"></script>
</body>
</html>
任何命令行输入或输出都应如下所示:
$ npm install protractor
$ npm protractor --version
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"为了下载新模块,我们将前往文件 | 设置 | 项目名称 | 项目解释器"。
注意事项
警告或重要注意事项以如下框的形式出现。
小贴士
小技巧和技巧看起来像这样。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您在某个主题领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从您的账户www.packtpub.com下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击顶部的代码下载与勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
下载文件后,请确保您使用最新版本解压缩或提取文件夹。
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Angular-Test-Driven-Development-Second-Edition。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。请查看它们!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/AngularTestDrivenDevelopment_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这个问题,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
在互联网上对版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过发送邮件至 copyright@packtpub.com 并附上涉嫌侵权材料的链接与我们联系。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以给我们发送邮件至 questions@packtpub.com,我们将尽力解决问题。
第一章. 测试驱动开发简介
Angular 在客户端 JavaScript 测试方面处于前沿。每个 Angular 教程都包括一个相应的测试,事件测试模块是 Angular 核心包的一部分。Angular 团队专注于使测试成为网络开发的基础。
本章将向您介绍使用 Angular 的测试驱动开发(TDD)的基础,包括以下主题:
-
TDD 概述
-
TDD 生命周期:先测试,然后运行,最后改进
-
常见测试技术
TDD 概述
TDD 是一种进化式开发方法,在你编写足够的生产代码以满足测试及其重构之前,你先编写一个测试。
本节将探讨 TDD 的基础。让我们以裁缝为例,看看他是如何将 TDD 应用到自己的过程中的。
TDD 基础
在开始编写代码之前,先了解一下要写什么。这听起来可能有些陈词滥调,但这正是 TDD 给你的。TDD 从定义期望开始,然后让你满足期望,最后在期望满足后迫使你细化更改。
实践 TDD(测试驱动开发)的一些明显好处如下:
-
没有变化是小的:小的更改可能会在整个项目中引起很多破坏性问题。实践 TDD 是唯一可以帮助的方法,因为测试套件会捕捉到破坏点,并在任何更改后拯救项目,从而拯救开发者的生命。
-
具体识别任务:测试套件专门提供对任务和逐步工作流程的清晰视野,以便成功。首先设置测试可以让你只关注在测试中定义的组件。
-
重构的信心:重构涉及移动、修复和更改项目。测试通过确保逻辑独立于代码结构来保护核心逻辑不受重构的影响。
-
前期投资,未来收益:最初,测试看起来会花费额外的时间,但实际上,当项目变得更大时,它会带来回报,因为它让我们有信心扩展功能,因为只需运行测试就能识别出任何破坏性问题。
-
QA 资源可能有限:在大多数情况下,QA 资源有一些限制,因为 QA 团队总是需要额外的时间来手动检查所有内容,但编写一些测试用例并成功运行它们肯定会节省一些 QA 时间。
-
文档:测试定义了特定对象或函数必须满足的期望。期望充当合同,可以用来查看方法应该如何或可以如何使用。这使得代码可读性更强,更容易理解。
用不同的眼光衡量成功
TDD 不仅仅是一种软件开发实践——其基本原理也与其他工匠共享。其中一位工匠是裁缝,他的成功依赖于精确的测量和周密的计划。
分解步骤
这里是裁缝制作西装的高级别步骤:
-
先测试:
-
确定西装的尺寸
-
让客户确定他们想要的西装风格和材料
-
测量客户的胳膊、肩膀、躯干、腰部和腿部
-
-
裁剪:
-
根据期望的风格选择布料
-
根据客户的身体形状测量布料
-
根据测量结果裁剪布料
-
-
重构:
-
将裁剪和外观与客户期望的风格进行比较
-
进行调整以达到期望的风格
-
-
重复:
-
先测试:确定西装的尺寸
-
裁剪:测量布料并进行裁剪
-
重构:根据评审进行更改
-
前面的步骤是 TDD 方法的一个例子。在裁缝开始切割原材料之前,必须先进行测量。想象一下,如果裁缝没有使用测试驱动的方法,也没有使用卷尺(测试工具),那么在测量之前就开始切割将是荒谬的。
作为一名开发者,你是否“先裁后量”?你会信任没有卷尺的裁缝吗?你会对不进行测试的开发者有何感想?
量两次,裁一次
裁缝总是从测量开始。如果裁缝在测量之前就开始裁剪会发生什么?如果布料裁剪得太短会发生什么?裁剪会花费多少额外的时间?因此,量两次,裁一次。
软件开发者在开始开发之前可以选择无数的方法。一种常见的方法是按照规范工作。记录的方法可能有助于定义需要构建的内容;然而,如果没有满足规范的具体标准,实际开发的应用程序可能与规范完全不同。使用 TDD 方法,过程的每个阶段都会验证结果是否符合规范。想想裁缝如何在整个过程中继续使用卷尺来验证西装。
TDD 体现了一种先测试的方法。TDD 为开发者提供了从明确目标开始,编写直接满足规范代码的能力,因此你可以像专业人士一样开发,并遵循有助于你编写高质量软件的实践。
使用 JavaScript 的实用 TDD
让我们深入探讨 JavaScript 环境下的实用 TDD。这次讲解将带我们通过向计算器添加乘法功能的过程。
只需记住以下 TDD 生命周期:
-
先测试
-
让它运行
-
让它变得更好
指出开发待办事项
开发待办事项列表有助于组织和专注于单个任务。它还帮助在开发过程中列出想法,这些想法可能最终成为单个功能。
让我们在开发待办事项列表中添加第一个功能--添加乘法功能:
3 * 3 = 9
上述列表描述了需要完成的工作。它还提供了一个如何验证乘法 3 * 3 = 9 的清晰示例。
设置测试套件
要设置测试,让我们在名为 calculator.js 的文件中创建初始计算器。它被初始化为一个对象,如下所示:
var calculator = {};
测试将通过一个简单的 HTML 页面作为网页浏览器运行。因此,让我们创建一个 HTML 页面,并将 calculator.js 导入以测试它,并将页面保存为 testRunner.html。
要运行测试,让我们在您的网页浏览器中打开 testRunner.html 文件。
testRunner.html 文件将看起来像这样:
<!DOCTYPE html>
<html>
<head>
<title>Test Runner</title>
</head>
<body>
<script src="img/calculator.js"></script>
</body>
</html>
测试套件已准备好用于项目,并且功能开发待办事项列表也已准备好。下一步是根据功能列表逐个进入 TDD 生命周期。
先测试
虽然编写乘法函数很简单,并且它将作为一个相当简单的功能正常工作,但作为练习 TDD 的一部分,现在是时候遵循 TDD 生命周期。生命周期的第一阶段是根据开发待办事项列表编写测试。
这里是第一次测试的步骤:
-
打开
calculator.js。 -
创建一个新的函数
multipleTest1来测试乘法 *3 * 3,之后calculator.js文件将如下所示:
function multipleTest1() {
// Test
var result = calculator.multiply(3, 3);
// Assert Result is expected
if (result === 9) {
console.log('Test Passed');
} else {
console.log('Test Failed');
}
};
multipleTest1();
测试调用一个 multiply 函数,该函数仍需要定义。然后它通过显示通过或失败消息来断言结果是否符合预期。
注意
请记住,在 TDD 中,你正在查看方法的使用,并明确地编写它应该如何使用。这允许你根据用例定义接口,而不是只关注正在开发的功能的有限范围。
TDD 生命周期的下一步是专注于使测试运行。
让测试运行
在这一步中,我们将运行测试,就像裁缝对套件所做的那样。测试步骤中进行了测量,现在应用程序可以塑形以适应这些测量。
以下是要运行测试的步骤:
-
在网页浏览器中打开
testRunner.html。 -
在浏览器中打开 JavaScript 开发者 控制台 窗口。
测试将抛出一个错误,这将在浏览器开发者控制台中可见,如下面的截图所示:

抛出的错误是预期的,因为计算器应用程序调用了一个尚未创建的函数--calculator.multiply。
在 TDD 中,重点是添加最简单的更改以使测试通过。实际上不需要实现乘法逻辑。这看起来可能不太直观。重点是,一旦存在通过测试,它应该始终通过。当一个方法包含相当复杂的逻辑时,运行通过测试以确保它符合预期更容易。
什么是最容易的改变,可以使测试通过?通过返回预期的9值,测试应该通过。尽管这不会添加乘法功能,但它将确认应用程序的连接。此外,在通过测试后,未来的更改将变得容易,因为我们只需确保测试通过即可!
现在,添加multiply函数并使其返回所需的9值,如下所示:
var calculator = {
multiply : function() {
return 9;
}
};
现在,让我们刷新页面重新运行测试并查看 JavaScript 控制台。结果应该如下面的截图所示:

是的!没有更多错误。显示测试已通过的消息。
现在通过了测试,下一步将是将multiply函数中的硬编码值移除。
让项目变得更好
重构步骤需要从multiply函数中移除硬编码的return值,这是我们为了通过测试而添加的最简单解决方案,并添加所需的逻辑以获得预期的结果。
所需的逻辑如下:
var calculator = {
multiply : function(amount1, amount2) {
return amount1 * amount2;
}
};
现在,让我们刷新浏览器重新运行测试;它将像之前一样通过测试。太棒了!现在multiply函数已经完成。
calculator.js文件的完整代码,包括calculator对象的测试如下所示:
var calculator = {
multiply : function(amount1, amount2) {
return amount1 * amount2;
}
};
function multipleTest1() {
// Test
var result = calculator.multiply(3, 3);
// Assert Result is expected
if (result === 9) {
console.log('Test Passed');
} else {
console.log('Test Failed');
}
}
multipleTest1();
测试机制
要成为一个遵循 TDD 的开发者,了解一些基本的测试技术和测试方法非常重要。在本节中,我们将通过几个示例介绍本书中将利用的测试技术和机制。
这将主要包含以下要点:
-
使用Jasmine间谍进行测试双
-
重构现有测试
-
构建模式
这里将使用以下附加术语:
-
待测试函数:这是正在被测试的函数。它也被称为系统测试对象、测试对象等。
-
3A(准备、行动、断言):这是一种设置测试的技术,最初由 Bill Wake 描述(
xp123.com/articles/3a-arrange-act-assert/)。3A 将在第二章JavaScript 测试的细节中进一步讨论。
使用框架进行测试
我们已经看到了对计算器应用程序进行测试的快速简单方法,其中我们设置了multiply方法的测试。但在现实生活中,它将更加复杂,并且是一个更大型的应用程序,其中早期技术将过于复杂而难以管理。在这种情况下,使用测试框架将非常方便且易于使用。测试框架提供方法和结构来进行测试。这包括创建和运行测试的标准结构,创建断言/期望的能力,使用测试替身的能力,以及更多。本书使用 Jasmine 作为测试框架。Jasmine 是一个行为驱动测试框架。它与测试 Angular 应用程序高度兼容。在第二章《JavaScript 测试的细节》中,我们将深入探讨 Jasmine。
以下示例代码并不是 Jasmine 测试/规范运行器实际运行的方式,它只是关于双倍工作原理或这些双倍如何返回预期结果的想法。在第二章《JavaScript 测试的细节》中,我们将展示如何使用 Jasmine 规范运行器正确使用这个双倍。
使用 Jasmine 间谍进行测试替身
测试替身是一个充当并用于替代另一个对象的对象。Jasmine 有一个称为spies的测试替身函数。Jasmine 间谍与spyOn()方法一起使用。
让我们看看以下需要测试的testableObject对象。使用测试替身,我们可以确定testableFunction被调用的次数。
以下是一个测试替身的示例:
var testableObject = {
testableFunction : function() { }
};
jasmine.spyOn(testableObject, 'testableFunction');
testableObject.testableFunction();
testableObject.testableFunction();
testableObject.testableFunction();
console.log(testableObject.testableFunction.count);
上述代码使用 Jasmine 间谍(jasmine.spyOn)创建了一个测试替身。以下是一些 Jasmine 测试替身提供的功能:
-
函数调用次数
-
指定返回值(模拟返回值)的能力
-
将调用传递给底层函数(传递)的能力
在整本书中,我们将进一步获得使用测试替身(test doubles)的经验。
模拟返回值
使用测试替身的优点在于,不需要调用方法的基本代码。使用测试替身,我们可以指定方法在给定测试中应该返回的确切值。
考虑以下对象和函数的示例,其中函数返回一个字符串:
var testableObject = {
testableFunction : function() { return 'stub me'; }
};
前面的对象testableObject有一个需要模拟的函数testableFunction。
因此,为了模拟单个返回值,需要链式调用and.returnValue方法,并将预期值作为param传递。
这是如何将单个返回值间谍链式调用以进行模拟的方法:
jasmine.spyOn(testableObject, 'testableFunction')
.and
.returnValue('stubbed value');
现在,当调用testableObject.testableFunction时,将返回stubbed value。
考虑以下关于先前单个stubbed value的示例:
var testableObject = {
testableFunction : function() { return 'stub me'; }
};
//before the return value is stubbed
Console.log(testableObject.testableFunction());
//displays 'stub me'
jasmine.spyOn(testableObject,'testableFunction')
.and
.returnValue('stubbed value');
//After the return value is stubbed
Console.log(testableObject.testableFunction());
//displays 'stubbed value'
同样,我们可以像前面的示例一样传递多个返回值。
下面是如何将多个返回值链式调用以逐个存根的示例:
jasmine.spyOn(testableObject, 'testableFunction')
.and
.returnValues('first stubbed value', 'second stubbed value', 'third stubbed value');
因此,对于testableObject.testableFunction的每次调用,它将按顺序返回存根值,直到达到返回值列表的末尾。
考虑前述多个存根值的示例:
jasmine.spyOn(testableObject, 'testableFunction')
.and
.returnValue('first stubbed value', 'second stubbed value', 'third stubbed value');
//After the is stubbed return values
Console.log(testableObject.testableFunction());
//displays 'first stubbed value'
Console.log(testableObject.testableFunction());
//displays 'second stubbed value'
Console.log(testableObject.testableFunction());
//displays 'third stubbed value'
测试参数
测试替身可以揭示方法在应用程序中的使用方式。例如,一个测试可能想要断言方法被调用时的参数或方法被调用的次数。下面是一个示例函数:
var testableObject = {
testableFunction : function(arg1, arg2) {}
};
下面是测试前述函数调用参数的步骤:
-
创建一个间谍,以便捕获被调用的参数:
jasmine.spyOn(testableObject, 'testableFunction'); -
然后,为了访问参数,运行以下命令:
//Get the arguments for the first call of the function
var callArgs = testableObject.testableFunction
.call.argsFor(0);
console.log(callArgs);
//displays ['param1', 'param2']
下面是如何使用console.log显示参数的示例:
var testableObject = {
testableFunction : function(arg1, arg2) {}
};
//create the spy
jasmine.spyOn(testableObject, 'testableFunction');
//Call the method with specific arguments
testableObject.testableFunction('param1', 'param2');
//Get the arguments for the first call of the function
var callArgs = testableObject.testableFunction.call.argsFor(0);
console.log(callArgs);
//displays ['param1', 'param2']
重构
重构是指对代码进行重构、重写、重命名和删除,以提高代码的设计、可读性、可维护性和整体美观。TDD 生命周期步骤中的“使项目更好”主要关注重构。本节将带我们通过一个重构示例。
看一下以下需要重构的函数示例:
var abc = function(z) {
var x = false;
if(z > 10)
return true;
return x;
}
这个函数运行良好,不包含任何语法或逻辑问题。问题是这个函数难以阅读和理解。重构这个函数将改进其命名、结构和定义。练习将消除伪装的复杂性,揭示函数的真实含义和意图。
下面是步骤:
-
将函数和变量名重命名为更有意义,即重命名
x和z,使它们有意义:var isTenOrGreater = function(value) { var falseValue = false; if(value > 10) return true; return falseValue; }现在,函数可以很容易地阅读,命名也更有意义。
-
移除任何不必要的复杂性。在这种情况下,可以完全删除
if条件语句,如下所示:var isTenOrGreater = function(value) { return value > 10; }; -
反思结果。
到目前为止,重构已完成,函数的目的应该很明显。接下来应该问的问题是:“这个方法最初为什么存在?”。
这个例子只提供了一个简要的步骤概述,说明了如何识别代码中的问题以及如何改进它们。本书中还将给出其他示例。
使用建造者构建
这些天,设计模式已经变成了一种常见的实践,我们遵循设计模式来使生活变得更简单。出于同样的原因,这里也将遵循建造者模式。
建造者模式使用builder对象来创建另一个对象。想象一个有 10 个属性的对象。如何为每个属性创建测试数据?是否需要在每个测试中重新创建对象?
builder对象定义了一个可以在多个测试中重用的对象。以下代码片段提供了一个使用此模式的示例。此示例将在validate方法中使用builder对象:
var book = {
id : null,
author : null,
dateTime : null
};
book对象有三个属性:id、author和dateTime。从测试的角度来看,我们希望有创建一个有效对象的能力,即所有字段都已定义的对象。我们可能还想创建一个缺少属性的无效对象,或者我们可能想设置对象中的某些值来测试验证逻辑。就像这里的dateTime是一个实际的日期时间,应该由构建器对象分配。
创建bookBuilder对象的构建器的步骤如下:
-
创建一个构建器函数,如下所示:
var bookBuilder = function() {}; -
在构建器内创建一个有效的对象,如下所示:
var bookBuilder = function() { var _resultBook = { id: 1, author: 'Any Author', dateTime: new Date() }; } -
创建一个函数来返回构建的对象:
var bookBuilder = function() { var _resultBook = { id: 1, author: "Any Author", dateTime: new Date() }; this.build = function() { return _resultBook; } } -
如上图所示,创建另一个函数来设置
_resultBook的作者字段:var bookBuilder = function() { var _resultBook = { id: 1, author: 'Any Author', dateTime: new Date() }; this.build = function() { return _resultBook; }; this.setAuthor = function(author){ _resultBook.author = author; }; }; -
将函数定义修改为可以链式调用:
this.setAuthor = function(author) { _resultBook.author = author; return this; }; -
还将创建一个用于
dateTime的设置函数,如下所示:this.setDateTime = function(dateTime) { _resultBook.dateTime = dateTime; return this; };
现在,可以使用bookBuilder创建一个新的书籍,如下所示:
var bookBuilder = new bookBuilder();
var builtBook = bookBuilder.setAuthor('Ziaul Haq')
.setDateTime(new Date())
.build();
console.log(builtBook.author); // Ziaul Haq
之前的构建器现在可以在我们的测试中用来创建一个单一的一致对象。
这里是完整的构建器,供参考:
var bookBuilder = function() {
var _resultBook = {
id: 1,
author: 'Any Author',
dateTime: new Date()
};
this.build = function() {
return _resultBook;
};
this.setAuthor = function(author) {
_resultBook.author = author;
return this;
};
this.setDateTime = function(dateTime) {
_resultBook.dateTime = dateTime;
return this;
};
};
让我们创建一个validate方法来验证从构建器创建的书籍对象:
var validate = function(builtBookToValidate){
if(!builtBookToValidate.author) {
return false;
}
if(!builtBookToValidate.dateTime) {
return false;
}
return true;
};
让我们首先通过传递所有必要的信息使用构建器创建一个有效的书籍对象,如果通过validate对象传递,则应该显示有效消息:
var validBuilder = new bookBuilder().setAuthor('Ziaul Haq')
.setDateTime(new Date())
.build();
// Validate the object with validate() method
if (validate(validBuilder)) {
console.log('Valid Book created');
}
同样,让我们通过在必要信息中传递一些 null 值来通过构建器创建一个无效的书籍对象。通过将对象传递给validate方法,它应该显示解释为什么它是无效的消息:
var invalidBuilder = new bookBuilder().setAuthor(null).build();
if (!validate(invalidBuilder)) {
console.log('Invalid Book created as author is null');
}
var invalidBuilder = new bookBuilder().setDateTime(null).build();
if (!validate(invalidBuilder)) {
console.log('Invalid Book created as dateTime is null');
}
提示
下载示例代码
您可以从www.packtpub.com下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
自我测试问题
Q1. 测试替身是重复测试的另一个名称。
-
True
-
False
Q2. TDD 代表测试驱动开发。
-
True
-
False
Q3. 重构的目的是提高代码质量。
-
True
-
False
Q4. 测试对象构建器用于整合测试对象的创建。
-
True
-
False
Q5. 三个 A 代表一个运动队。
-
True
-
False
摘要
本章介绍了 TDD(测试驱动开发)。它讨论了 TDD 的生命周期(先测试,然后运行,最后改进),任何人都可以使用这些步骤进行 TDD 方法,就像我们看到的裁缝使用的方法一样。最后,它概述了本书将讨论的一些测试技术,包括测试替身、重构和构建模式。
虽然 TDD 是一个很大的主题,但本书仅关注与 Angular 一起使用的 TDD 原则和实践。
在下一章中,我们将了解有关 JavaScript 测试的详细信息。
第二章 JavaScript 测试的细节
练习 TDD 是获得高质量软件并具有令人满意准确性的好方法,即使人数较少。对于网络应用程序,JavaScript 已经成为最受欢迎的脚本语言,测试 JavaScript 代码已成为一项挑战。基于浏览器的测试实际上是一种时间杀手,对于 TDD 来说很难跟踪,但随后这个问题的解决方案伴随着一些支持 JavaScript 自动化测试的酷工具。大多数网络应用程序项目仅限于单元测试,而没有自动化测试工具,端到端测试或功能测试几乎是不可能的。
许多专注于 JavaScript 测试的工具和框架正在出现,它们提供不同的解决方案,使开发者生活变得更轻松。除了发明新的 JavaScript 框架外,开发者社区还发明了一些工具集来简化测试。例如,Angular 团队带来了像 Karma 这样的酷工具。我们还有测试框架或工具的重复,其中两者都解决了类似的问题,但以不同的方式。选择哪些工具或框架取决于开发者;他们必须选择最适合他们需求的工具。
在本章中,我们将涵盖以下内容:
-
自动化测试的简要介绍
-
专注于 JavaScript 的不同类型的测试
-
一些测试工具和框架的简要介绍
JavaScript 测试的技艺
我们都知道 JavaScript 是一种动态类型、解释型语言。因此,与类似 Java 的其他编译语言不同,它没有编译步骤来帮助你找出错误。所以,JavaScript 开发者应该分配更多的时间来测试代码。然而,现在生活变得更简单了,因为开发者可以使用最新的工具技术以最少的步骤和时间来覆盖测试。这是自动化测试的一部分,其中代码在更改时将自动进行测试。在这个过程中,测试可能是一个在后台运行的任务,它可以集成到 IDE 或 CLI 中,并在开发过程中提供测试结果。
在接下来的章节中,我们将讨论如何使用测试运行器和无头浏览器在多个浏览器中自动化测试过程。
自动化测试
测试很有趣,编写测试会使代码更好;这是一个好习惯,但程序化手动测试有点耗时,容易出错,且难以重现。在这个过程中,需要编写测试规范,更改代码以通过测试,刷新浏览器以获取结果,并重复这个过程几次。作为一个程序员,重复同样的事情有点无聊。
除了单调之外,它还极大地减缓了开发过程,这会降低开发者练习 TDD 的积极性。所以,当手动过程减缓进度时,我们必须寻找一些自动化过程来完成这项工作,并为其他可能增加更多商业价值的工作节省时间。
因此,拥有一些工具或技术来帮助程序员摆脱这些重复且无聊的手动步骤,从而加快流程、自动完成任务并节省时间,使他们能为业务创造更多价值,将会非常棒。幸运的是,有一些工具可以自动化这些测试。我们将详细介绍这些工具和技术,但不是在本节中。
除了减缓开发进程的问题外,当我们谈论测试功能时,还有一个重要的问题浮出水面,那就是跨浏览器兼容性问题。由于 Web 应用程序应该在现代平台和浏览器上完美运行,而手动逐个测试几乎是不可能的,因此,使用 WebDriver 和无头浏览器的自动化测试可能是解决方案。
让我们回顾一下在前一章中解释的基本测试流程——测试它,让它运行,让它变得更好。为了使这个过程自动化,开发者可以在命令行界面(CLI)或开发 IDE 中实现工具集,并且这些测试将在一个独立进程中连续运行,无需开发者任何输入。
让我们考虑一下任何应用程序的注册或注册功能,其中我们必须手动填写表单并点击提交按钮,每次我们想要测试该功能并通过更改数据重复此过程。这实际上被称为功能测试(我们将在本章末尾讨论)。为了自动执行这些过程,我们将使用 CLI 中的工具集(测试运行器、WebDriver 和无头浏览器)并通过带有一些参数的单个命令完成整个过程。
在自动化测试中测试 JavaScript 并不是一个新概念,实际上,它是最常用的自动化浏览器。Selenium 就是在 2004 年为此而发明的,此后,涌现出了许多工具,包括 PhantomJS、Karma、Protractor 和 CasperJS。在本章中,我们将讨论其中的一些。
测试类型
在 TDD(测试驱动开发)中,开发者必须遵循一个流程来实现测试的目标。在这个流程中,每一步都有一个单独的测试目标。例如,一些测试仅用于以多种方式测试每个函数的行为,而另一些则是为了测试模块/功能的流程。基于此,我们将在这里讨论两种主要的测试类型。它们如下:
-
单元测试:这主要用于行为测试。
-
端到端测试:这通常被称为 e2e 测试,用于功能测试。
单元测试
单元测试是一种软件开发过程,其中任何应用程序的最小可测试部分被称为单元,该小部分的行为应该在隔离状态下可测试,而不依赖于其他部分。如果我们把 JavaScript 应用程序看作是软件,那么该应用程序的每个具有特定行为的独立方法/函数都将是一个代码单元。这些方法或代码单元的行为应该以隔离的方式进行测试。
单元测试的一个重要观点是,任何代码单元都应该在隔离状态下运行/可测试,并且可以按任何顺序运行,这意味着如果单元测试在任何应用程序中成功运行,它代表了该应用程序组件或模块的隔离性。
例如,我们在上一章中已经展示了一个小的测试示例,说明了如何对方法进行测试;尽管我们没有使用任何测试框架,但理念是相同的。我们通过传递一些参数来调用该方法,获取该方法的返回结果,然后将其与预期值进行比较。
通常,我们将使用我们选择的单元测试框架来编写这样的测试。现在有许多测试框架和工具,我们必须根据我们的需求进行选择和挑选最佳的框架。最常用的框架是 Jasmine、Mocha 和 QUnit。我们将在本章深入讨论这些工具,并在后续章节中涵盖实际案例。
测试应该运行得快,并且具有清晰的输出,实现自动化。例如,你可以验证如果函数被调用时使用特定的参数,它应该返回预期的结果。
单元测试可以在任何时间运行测试,例如以下情况:
-
从开发过程的开始阶段,即使有失败的测试
-
在完成任何功能的开发后,为了验证行为是否正确
-
修改任何现有功能后,为了验证行为没有改变
-
在现有应用程序中添加新功能后,我们需要验证该新功能是隔离的,并且没有破坏其他功能
端到端测试
端到端测试是一种测试方法,用于测试应用程序的流程是否从开始到结束都按设计执行。例如,如果用户从产品列表中点击一个产品,应该会提示模态显示所选产品的详细信息。在这种情况下,产品/项目所有者将在规范中逐步定义项目需求。开发过程完成后,将根据规范的工作流程对项目进行测试。这被称为功能/流程测试,也是端到端测试的另一个名称。
除了单元测试之外,端到端测试对于确认各个组件作为一个应用程序协同工作、传递信息和相互通信非常重要。与单元测试的主要区别在于它不测试任何单独的组件;相反,它是对所有相关组件一起的流程的联合测试。
考虑一个注册模块,用户应提供一些有效信息以完成注册,该模块/应用程序的功能/流程测试应遵循一些步骤以完成测试。
步骤如下:
-
加载/编译表单
-
获取表单元素的 DOM
-
触发提交按钮的点击事件
-
从输入字段收集值以进行验证
-
验证输入字段
-
调用模拟 API 以存储数据
在每个步骤中,都会有一些结果与预期的结果集进行比较。
这类功能/流程测试可以通过人工填写表格,点击按钮进行下一步,完成应用程序流程,并将结果与在实现过程中定义的规范进行比较来手动测试。
然而,有一些技术可以实现这种功能/流程测试的自动化,而不需要任何人的输入,这被称为端到端测试。为了使测试过程更容易,有一些工具可用;最常用的有 Selenium、PhantomJS 和 Protractor。这些工具可以轻松地与任何应用程序测试系统集成。在本章中,我们将更详细地讨论这些测试工具,在随后的章节中,我们将将这些工具集成到应用程序的测试套件中。
测试工具和框架
了解不同的测试工具是战斗的一半。其中一些对于 Angular 测试非常重要,需要深入了解;我们将在整本书中详细学习它们。然而,在本节中,我们将了解一些在不同 Web 应用程序中用于各种测试和方法的知名工具和框架。它们如下:
-
Karma:这是 JavaScript 的测试运行器
-
Protractor:这是一个端到端测试框架
-
Jasmine:指的是行为驱动的 JavaScript 测试框架
-
Mocha:这是一个 JavaScript 测试框架
-
QUnit:代表单元测试框架
-
Selenium:这是一个自动化 Web 浏览器的工具
-
PhantomJS:这是一个无头 webkit 浏览器
Karma
在讨论 Karma 是什么之前,最好先讨论它不是什么。它不是一个编写测试的框架;它是一个测试运行器。这意味着 Karma 能够以自动化的方式在多个不同的浏览器中运行测试。在过去,开发者必须执行手动步骤来完成这项工作,包括以下步骤:
-
打开浏览器
-
将浏览器指向项目 URL
-
运行测试
-
确认所有测试都已通过
-
进行更改
-
刷新页面
使用 Karma,自动化使开发者能够运行单个命令,并确定整个测试套件是否通过或失败。从 TDD 的角度来看,这使我们能够快速找到并修复失败的测试。
与手动过程相比,使用 Karma 的优点如下:
-
能够在多个浏览器和设备上自动化测试
-
能够监视文件
-
在线文档和支持
-
只做一件事——运行 JavaScript 测试——并且做得很好
-
使其易于与持续集成服务器集成
使用 Karma 的缺点:
- 需要额外的工具来学习、配置和维护
自动化测试过程和使用 Karma 非常有利。在本书的 TDD 之旅中,Karma 将是我们的主要工具之一。我们将在 第三章 中详细了解 Karma,Karma 的方法。
Protractor
Protractor 是一个端到端测试工具,允许开发者模拟用户交互。它通过浏览器交互自动化测试功能和特性。Protractor 有特定的方法来协助测试 Angular,但它们并不局限于 Angular。
使用 Protractor 的优点如下:
-
可配置以测试多个环境
-
与 Angular 容易集成
-
语法和测试可以与为单元测试选择的测试框架相似
使用 Protractor 的缺点:
- 其文档和示例有限
为了测试本书中的示例的端到端测试,Protractor 将是我们的主要框架。Protractor 将在 第四章 中详细介绍,使用 Protractor 进行端到端测试。
Jasmine
Jasmine 是一个用于测试 JavaScript 代码的行为驱动开发框架。它可以轻松集成并运行于网站,且对 Angular 无关。它提供了间谍和其他功能。它也可以在不使用 Karma 的情况下独立运行。在本章中,我们将学习 Jasmine 常用内置全局函数的细节,并了解 Jasmine 测试套件如何满足 Web 应用程序的测试需求。此外,在本书中,我们将使用 Jasmine 作为我们的测试框架。
使用 Jasmine 的优点如下:
-
默认与 Karma 集成
-
提供了额外的功能来协助测试,例如测试间谍、模拟和透传功能
-
清晰的语法,允许测试以与被测试行为相关的方式格式化
-
与多个输出报告器的集成
使用 Jasmine 的缺点如下:
-
运行测试时没有文件监视功能。这意味着当文件更改时,测试必须由用户重新运行。
-
对于所有 Protractor 方法和技术,学习曲线可能很陡峭。
Mocha
Mocha 最初是为 Node.js 应用程序编写的测试框架,但也支持浏览器测试。它与 Jasmine 非常相似,并且语法上有很多相似之处。Mocha 与 Jasmine 的主要区别在于它不能作为独立的测试框架运行——它需要一些插件和库来作为测试框架运行,而 Jasmine 是独立的。它更易于配置和使用。
让我们讨论一下 Mocha 的一些优点:
-
易于安装
-
有良好的文档
-
有几个报告器
-
与多个 Node.js 项目兼容
这里有一些缺点:
-
需要单独的插件/模块来进行断言、间谍等操作
-
使用 Karma 时需要额外的配置
QUnit
QUnit 是一个强大且易于使用的 JavaScript 单元测试套件。它被 jQuery、jQuery UI 和 jQuery Mobile 项目使用,并且能够测试任何通用的 JavaScript 代码。QUnit 专注于在浏览器中测试 JavaScript,同时尽可能为开发者提供便利。
QUnit 的一些优点:
-
易于安装
-
有良好的文档
这里是使用 QUnit 的一个缺点:
- 主要为 jQuery 开发,与其他框架使用不佳
Selenium
Selenium (www.seleniumhq.org/)将其自身定义为如下:
“Selenium 自动化浏览器。仅此而已!”
浏览器自动化意味着开发者可以轻松地与浏览器交互。他们可以点击按钮或链接,输入数据等。Selenium 是一个强大的工具集,当正确使用和设置时,有很多好处;然而,设置起来可能会很复杂。
Selenium 的一些优点如下:
-
功能集庞大
-
分布式测试
-
通过Sauce Labs等服务提供 SaaS 支持 (
saucelabs.com/) -
有文档和资源
这里是 Selenium 的一些缺点:
-
必须作为一个单独的进程运行
-
配置需要几个步骤
由于 Protractor 是 Selenium 的包装器,所以不会详细讨论。
PhantomJS
PhantomJS 是一个可使用 JavaScript API 进行脚本化的无头 WebKit。它对各种网络标准有快速和原生的支持;DOM 处理、CSS 选择器、JSON、Canvas 和 SVG。PhantomJS 用于测试工作流程。
简单来说,PhantomJS 是一个无头浏览器(也就是说,不会绘制屏幕)。它带来的好处是速度——如果你正在控制你电脑上的实际程序,启动浏览器、配置配置文件等会有一定的开销。
PhantomJS 不是用来替换测试框架的;它将与测试框架一起工作。
选择权在我们手中
正如我们所看到的,有很多工具集和框架用于测试 JavaScript 项目:
-
对于断言框架,我们将选择 Jasmine,因为 Angular 本身使用 Jasmine 作为断言;但在某些情况下,主要是对于 Node.js 项目,Mocha 也很受欢迎
-
只要我们专注于自动化测试套件,测试运行器对我们来说就非常重要,而且当涉及到 Angular 项目时,没有什么能比得上 Karma。
-
对于端到端测试,Protractor 是最好的框架,我们将在本章中使用它。
-
只要进行的是端到端测试,它就必须是自动化的,Selenium 就在这里为我们自动化浏览器。
-
运行测试时,重要的是要支持多浏览器,PhantomJS 就在这里为我们提供无头浏览器服务。
欢迎使用 Jasmine 测试套件
只要我们需要使用测试框架来构建测试套件,所有框架上都有一些基本的常见断言。理解这些断言和间谍以及何时使用它们是很重要的。
在本节中,我们将解释 Jasmine 的断言和间谍,因为 Jasmine 将作为本书中的测试框架。
套件
任何测试套件都以一个全局 Jasmine describe 函数开始,该函数接收两个参数。第一个参数是一个字符串,第二个参数是一个函数。这个字符串是套件名称/标题,而函数是将在套件中实现的代码块。
考虑以下示例:
describe("A sample test suite to test jasmine assertion", function() {
// .. implemented code block
});
Spec
任何使用 Jasmine 的全局 it 函数定义的 spec,类似于接收两个参数的 suite,都涉及第一个参数是一个字符串,第二个参数是一个函数。这个字符串是 spec 名称/标题,而函数是将在 spec 中实现的代码块。看看以下示例:
describe("A sample test suite to test jasmine assertion", function() {
var a;
it("Title for a spec", function() {
// .. implemented code block
});
});
期望
任何使用 expect 函数定义的期望,该函数接收一个名为实际值的参数。这个函数是一个与匹配函数链式调用的函数,它将期望值作为参数与实际值进行匹配。
有一些常用的匹配器;它们都实现了实际值和期望值之间的布尔比较。任何匹配器都可以通过将 expect 方法与 not 关键字链式调用来评估负值。
一些常见的匹配器有 toBe、toEqual、toMatch、toBeNull、toBeDefined、toBeUndefined 和 toContain。
考虑以下示例:
describe("A sample test suite to test jasmine assertion", function() {
var a, b;
it("Title for a spec", function() {
var a = true;
expect(a).toBe(true);
expect(b).not.toBe(true);
});
});
设置和清理
为了通过 DRY(不要重复自己)原则减少重复的设置和清理代码,Jasmine 提供了一些全局函数用于设置和清理。这些全局函数(beforeEach、afterEach 等)如下,它们按名称运行。
每个函数都会与一个测试 spec 运行。Jasmine 的全局设置和清理函数是 beforeEach、afterEach、beforeAll 和 afterAll。
考虑以下示例:
describe("A sample test suite to test jasmine assertion", function() {
var a=0;
beforeEach(function() {
a +=1;
});
afterEach(function() {
a =0;
});
it("Title for a spec 1", function() {
expect(a).toEqual(1);
});
it("Title for a spec 2", function() {
expect(a).toEqual(1);
expect(a).not.toEqual(0);
});
});
间谍
间谍是 Jasmine 中的测试双函数;它们可以模拟任何函数并跟踪对其及其所有参数的调用。有一些匹配器可以用来跟踪是否有任何间谍被调用。这些是 toHaveBeenCalled、toHaveBeenCalledTimes 等。
有一些与 spy 一起使用的有用链式方法,例如 returnValue/ returnValues,当通过 spy 链式调用时,将返回一个或多个值。还有一些类似的有用方法,例如 callThrough、call、stub、call.allArgs、call.first 和 call.reset。
考虑以下示例:
describe("A sample test suite to test jasmine assertion", function() {
var myObj, a, fetchA;
beforeEach(function() {
myObj = {
setA: function(value) {
a = value;
},
getA: function(value) {
return a;
},
};
spyOn(myObj, "getA").and.returnValue(789);
myObj.setA(123);
fetchA = myObj.getA();
});
it("tracks that the spy was called", function() {
expect(myObj.getA).toHaveBeenCalled();
});
it("should not affect other functions", function() {
expect(a).toEqual(123);
});
it("when called returns the requested value", function() {
expect(fetchA).toEqual(789);
});
});
Jasmine 的测试套件
在前面的章节中,我们查看了一些在所有测试框架中,包括 Jasmine,在任意类型的测试套件中常用的断言。
尽管在这本书中,我们将为 Angular 测试构建一个自动化测试套件,但让我们尝试在 Jasmine 测试套件中进行一些断言,看看它是如何工作的。这个示例测试套件将让我们亲身体验到断言在测试套件中的工作方式。
对于这个测试套件,我们将使用 Jasmine 的示例 spec 运行器项目(在示例中,它包含在下载的 Jasmine 包中),项目的文件夹结构将如下所示:

让我们快速查看在 Jasmine 的示例 SpecRunner 项目中需要更新的文件:
SpecRunner.html:
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Jasmine Spec Runner v2.4.1</title>
<link rel="shortcut icon" type="image/png" href="lib/jasmine-2.4.1/jasmine_favicon.jpg">
<link rel="stylesheet" type="text/css" href="lib/jasmine-2.4.1/jasmine.css">
<script type="text/javascript" src="img/jasmine.js"></script>
<script type="text/javascript" src="img/jasmine-html.js"></script>
<script type="text/javascript" src="img/boot.js"></script>
<!-- include source files here... -->
<script type="text/javascript" src="img/mySource.js"></script>
<!-- include spec files here... -->
<script type="text/javascript" src="img/mySpec.js"></script>
</head>
<body>
</body>
</html>
src/mySource.js:
var a,
myObj = {
setA: function(value) {
a = value;
},
getA: function(value) {
return a;
},
};
Spec/mySpec.js:
describe("A sample test suite to test jasmine assertion", function() {
var fetchA;
beforeEach(function() {
spyOn(myObj, "getA").and.returnValue(789);
myObj.setA(123);
fetchA = myObj.getA();
});
it("tracks that the spy was called", function() {
expect(myObj.getA).toHaveBeenCalled();
});
it("should not affect other functions", function() {
expect(a).toEqual(123);
});
it("when called returns the requested value", function() {
expect(fetchA).toEqual(789);
});
});
只要是一个基于浏览器的测试套件,我们就必须将 SpecRunner.html 指向一个网络浏览器以获取测试结果。我们将所有测试通过,我们的测试结果将如图所示:

Angular 的 Jasmine 测试套件
在前面的示例中,我们看到了用于 JavaScript 测试的 Jasmine 测试套件,但对于 Angular 呢?它应该是什么样子?实际上,没有直接的答案,因为对于 Angular 项目的测试套件,我们不会使用基于浏览器的测试套件;我们有一个带有 Karma 的测试运行器用于测试套件。但因为我们熟悉前面示例中的基于浏览器的 Jasmine 测试套件,让我们看看如果为 Angular 项目创建一个类似的套件,它将是什么样子。
我们需要在 Angular 项目的 src 子文件夹中添加一个子文件夹,然后项目的文件夹结构将如下所示:

注意
在 Angular 项目中,我们将使用 TypeScript 而不是纯 JavaScript,因为 Angular 官方建议使用 TypeScript。因此,我们希望我们都了解 TypeScript 语法,并知道如何编译成 JS。
在这本书中,对于 Angular 测试套件,我们将使用 SystemJS 作为模块加载器,因为 Angular 官方建议这样做;我们将看看 SystemJS。
这个示例 Angular 测试套件只是为了展示我们如何轻松地为 Angular 项目创建测试套件,尽管它并不遵循最佳实践和加载模块的最佳方式。
在第三章《Karma 方式》中,我们将使用真实世界的示例更新这个测试套件,并使用 SystemJS 作为模块加载器。
在 GitHub 上,Angular 团队有一个名为Angular2-seed的种子项目,用于启动任何带有测试套件的 Angular 项目;我们将遵循这个项目来构建我们的真实 Angular 项目。
如果我们查看文件夹结构,它与上一个版本几乎相同,spec 文件中的更改也最小;spec 文件中唯一的更改是与 TypeScript 相关的:
src/unit-tests.html:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>NG2 App Unit Tests</title>
<link rel="stylesheet" href="node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
<script src="img/jasmine.js"></script>
<script src="img/jasmine-html.js"></script>
<script src="img/boot.js"></script>
<script src="img/mysource.js"></script>
<script src="img/my.spec.js"></script>
</head>
<body>
</body>
</html>
app/mysource.ts:
export class Source {
// ...
}
src/my.spec.ts:
describe('1st tests', () => {
it('true is true', () => expect(true).toEqual(true));
it('null is not the same thing as undefined',
() => expect(null).not.toEqual(undefined)
);
});
由于这也是一个基于浏览器的测试套件,我们必须在网页浏览器中指向unit-tests.html以获取测试结果。我们将通过所有测试,并且我们的测试结果将看起来像这样:

自我测试题
自我测试题将帮助你进一步测试使用 TDD 进行 JavaScript 应用程序测试的知识。
Q1. 端到端测试意味着什么类型的测试?
-
功能测试
-
行为测试
Q2. Protractor 是一个单元测试框架。
-
真的
-
假的
Q3. PhantomJS 是一种浏览器类型。
-
真的
-
假的
Q4. QUnit 是一个测试框架用于什么?
-
jQuery
-
Angular
-
NodeJS
Q5. 设置和清理是 Jasmine 的一个特性。
-
真的
-
假的
摘要
在本章中,我们回顾了 TDD 的不同测试机制,并涵盖了自动化测试。我们回顾了不同种类的 JavaScript 测试框架和工具,并回顾了这些框架的使用及其优缺点。我们还回顾了一些 Jasmine 的常见断言,并亲自动手尝试了它们的工作方式。
在下一章中,我们将学习 Karma,并了解它是如何与 Angular 测试套件一起工作的。
第三章。Karma 的方式
多亏了 Karma,JavaScript 测试已经进入主流,Karma 使得测试 JavaScript 变得无缝。Angular 是围绕测试创建的。
在本章中,我们将了解一些关于 Karma 的内容,包括以下内容:
-
Karma 的起源
-
为什么以及 Karma 如何与 Angular 项目协同工作
-
使用 Angular 项目进行 Karma 配置和实现
-
Travis CI 概述
Karma 的诞生
当选择一个新的工具时,了解它的来源和为什么被构建是很重要的。本节将为我们提供 Karma 起源的背景。
Karma 的差异
Karma 是由 Vojta Jína 创建的。该项目最初被称为 Testacular。在 Vojtech Jína 的论文中,他讨论了 Karma 的设计、目的和实现。
在他的论文(JavasScript Test Runner,github.com/karma-runner/karma/raw/master/thesis.pdf)中,他这样描述 Karma:
“...一个帮助 Web 应用程序开发者通过简化自动化测试并使其更快来提高生产力和效率的测试运行器。实际上,我有一个更高的抱负,这篇论文只是其中的一部分——我想推广测试驱动开发(TDD)作为“唯一”的 Web 应用程序开发方式,因为我相信这是开发高质量软件最有效的方式。”
Karma 具有在真实浏览器上轻松自动运行 JavaScript 单元测试的能力。传统上,测试是通过手动启动浏览器并不断点击刷新按钮来检查结果的。这种方法很笨拙,并且往往导致开发者限制编写的测试数量。
使用 Karma,开发者几乎可以在任何标准测试框架中编写测试,选择要运行的浏览器,设置要监控变化的文件,然后!我们就有了持续自动测试。我们只需简单地检查输出窗口以查看失败的或通过的测试。
结合 Karma 和 Angular 的重要性
Karma 是为 AngularJS 构建的。在 Karma 之前,基于 Web 的 JavaScript 开发者缺乏自动化测试工具。
记住,Karma 是一个测试运行器,而不是测试框架。它的任务是运行测试并报告哪些测试会通过或失败。这有什么帮助?测试框架是您编写测试的地方。除了做这件事,您还需要专注于轻松运行测试并查看结果。Karma 可以轻松地在多个不同的浏览器上运行测试。它还有一些其他功能,如文件监控,这些将在本书的后面部分详细讨论。
安装 Karma
是时候开始使用 Karma 了。安装和应用程序不断变化。以下指南旨在简明扼要;您可以访问 Karma 网站 karma-runner.github.io/ 并在那里找到最新的说明。
本节的主要重点将是本书中使用的特定配置,而不是深入的安装指南。
安装先决条件
要安装 Karma,我们需要在我们的计算机上安装 Node.js。Node.js 在 Google 的 V8 引擎上运行,并允许 JavaScript 在多个操作系统上运行。
开发者可以使用 NPM(Node 包管理器)发布节点应用程序和模块。NPM 允许开发者快速将应用程序和模块集成到他们的应用程序中。
Karma 通过 npm 包运行和安装;因此,在我们可以使用或安装 Karma 之前,我们需要 Node.js。要安装 Node.js,请访问 nodejs.org/ 并遵循安装说明。
一旦我们安装了 Node.js,让我们在命令提示符中输入以下命令来安装 Karma:
$ npm install karma -g
上述命令使用 npm 通过 -g 全局安装 Karma。这意味着我们可以在命令提示符中简单地输入以下内容来使用 Karma:
$ karma --version
默认情况下,安装 Karma 将安装 karma-chrome-launcher 和 karma-jasmine 作为依赖项。请确保这些模块也全局安装。
配置 Karma
Karma 配备了自动创建配置文件的方式。要使用自动方式,请输入以下命令:
$ karma init
这里是选择的一些选项示例:

自定义 Karma 的配置
以下说明描述了为使 Karma 在项目中运行所需的特定配置。自定义包括测试框架(Jasmine)、用于测试的浏览器(Chrome)以及要测试的文件。要自定义配置,打开 karma.conf.js 并执行以下步骤:
-
使用以下代码确保启用的框架为
jasmine:frameworks: ['jasmine'], -
配置
test目录。请注意,以下定义需要包括需要运行的测试以及任何潜在的依赖项。将存放我们测试的目录是/test/unit/:files: [ 'test/unit/**/*.js' ], -
将测试浏览器设置为 Chrome,如下所示。然后它将在每次测试后初始化并运行一个弹出窗口:
browsers: ['Chrome'],
确认 Karma 的安装和配置
要确认 Karma 的安装和配置,请执行以下步骤:
-
运行以下命令以确认 Karma 无错误启动:
$ karma start -
输出应该是这样的:
$ INFO [karma]: Karma v0.12.16 server started at http://localhost:9876/ -
此外,输出还应声明没有找到测试文件:
$ WARN [watcher]: Pattern "test/unit/**/*.js" does not match any file.输出应该这样做,并伴随一个失败的测试消息:
$ Chrome 35.0.1916 (Windows 7): Executed 0 of 0 ERROR (0.016 secs / 0 secs)注意
一个需要注意的重要点是,我们需要在系统上全局安装
jasmine-core,否则 Karma 将无法成功运行。
这是预期的,因为没有创建任何测试。如果 Karma 启动,请继续到下一步,我们将看到以下输出的 Chrome 浏览器:

常见的安装/配置问题
如果缺少 Jasmine 或 Chrome 启动器,请执行以下步骤:
-
运行测试时,可能会出现错误,表示缺少 Jasmine 或 Chrome Launcher。如果你遇到这个错误,请输入以下命令来安装缺少的依赖项:
$ npm install karma-jasmine -g $ npm install karma-chrome-launcher -g -
重试测试并确认错误已被解决。
在某些情况下,你可能无法使用
-g命令全局安装npm_modules。这通常是由于你的计算机上的权限问题。以下是你需要做的来提供权限(sudo/administrator):-
解决方案是在你的项目文件夹中直接安装 Karma。使用不带
-g的相同命令来完成此操作:$ npm install karma -
使用相对路径运行 Karma:
$ ./node_modules/karma/bin/karma --version
-
现在 Karma 已安装并运行,是时候开始使用了。
使用 Karma 进行测试
在本节中,我们将创建一个测试来确认 Karma 按预期工作。为此,执行以下步骤:
-
创建测试目录。在 Karma 配置中,测试定义在以下目录:
files: [ 'test/unit/**/*.js' ], -
继续创建
test/unit目录。 -
在
test/unit目录中创建一个新的firstTest.js文件。 -
按如下方式编写第一个测试:
describe('when testing karma', function (){ it('should report a successful test', function (){ expect(true).toBeTruthy(); }); });
前面的测试使用了 Jasmine 函数,并具有以下属性:
-
describe:这提供了一个简短的字符串描述测试套件,将要测试的内容。 -
it:这提供了一个简短的字符串,表示特定的断言,即测试规范 -
expect:这提供了一种断言值的方法 -
toBeTruthy:这是期望的几个属性之一,可以用来进行断言
此测试除了确认通过测试的输出外,没有其他实际价值。
嘭!让我们检查我们的控制台窗口,看看 Karma 是否已执行我们的测试。并且我们的命令行应该像这样:
$ INFO [watcher]: Added file "./test/unit/firstTest.js"
此输出意味着 Karma 自动识别到已添加新文件。下一个输出应该是这样的:
$ Chrome 35.0.1916 (Windows 7): Executed 1 of 1 SUCCESS (0.02 secs
/ 0.015 secs)
这意味着我们的测试已经通过!
确认 Karma 安装
现在,Karma 的初始设置和配置已完成。以下是步骤回顾:
-
我们通过
npm命令安装了 Karma。 -
我们通过
karma init命令初始化了一个默认配置。 -
接下来,我们使用 Jasmine 和
test/unit测试目录配置了 Karma。 -
我们启动了 Karma 并确认它可以与 Chrome 一起打开。
-
然后,我们在
test/unit测试目录中添加了一个 Jasmine 测试,firstTest.js。 -
Karma 识别到
firstTest.js已被添加到测试目录。 -
最后,Karma 执行了我们的
firstTest.js并报告了我们的输出。
通过几个步骤,我们能够看到 Karma 正在运行并自动执行测试。从 TDD 的角度来看,我们可以专注于将测试从失败转换为通过,而不需要付出太多努力。无需刷新浏览器;只需检查命令输出窗口。保持 Karma 运行,所有我们的测试和文件将自动添加并运行。
在接下来的章节中,我们将看到如何使用 TDD 方法应用 Karma。如果你对 Karma 感到满意并想继续到 Protractor,请跳到下一章。
使用 Karma 与 Angular
在这里,我们将通过 TDD 方法来介绍 Angular 组件。到本章结束时,我们应该能够做到以下几点:
-
对使用 Karma 及其配置有信心
-
理解 Jasmine 测试的基本组件
-
开始理解 TDD 方法在 Angular 应用程序中的集成
获取 Angular
Angular 的安装不能通过 Bower 进行;就像 Angular1 一样,它必须通过 npm 安装。启动 Angular 应用程序不像 Angular1 那样简单,因为 Angular 不使用纯 JavaScript。它使用 TypeScript 或 ES6(ES2015),这两种都需要在运行之前编译成纯 JavaScript。
我们相信大多数开发者已经了解 Angular 的变化以及其编译方式。快速回顾一下——在我们的 Angular 示例项目中,我们将使用 TypeScript,尽管有使用 ES6 的选项。我们将使用 node/npm tsc 模块将 TypeScript 编译成纯 JavaScript;node/npm 也将是我们的 CLI 工具,用于构建/启动项目并运行测试。
需要基本了解 node/npm 模块,特别是 npm 命令的工作方式。
Angular 项目
我们将不会演示如何安装 Angular 以及如何从头开始构建项目,因为 Angular 文档网站已经很好地展示了这一点。因此,我们将从 Angular 团队提供的示例中获取一个简单的 Angular 项目,并更新它以适应我们的实现。
我们将从 Angular GitHub 仓库克隆quickstart项目,并从那个项目开始。希望我们所有人都已经全局安装了git,除了 node/npm。
$ git clone https://github.com/angular/quickstart.git angular-
karma
这将把项目复制到本地作为angular-karma,文件夹结构将如图所示:

让我们继续进行并准备运行:
$ cd angular-karma
$ npm install
下面是一些准备示例项目的步骤。npm install命令将安装项目根目录中package.json文件定义的项目依赖所需的模块。
然后,我们将使用npm start运行项目;这个脚本定义在package.json中,用于在本地服务器上运行项目。
让我们编译并运行项目:
$ npm start
如果所有必需的依赖项都已安装,此命令将编译 TypeScript 到纯 JavaScript,并在本地服务器上运行项目。
项目将在浏览器中启动,外观如下:

如果这个示例项目运行成功,那么我们就可以进入下一步,在那里我们将添加测试规范,这将包括 Karma,并使用 Karma 运行器运行这些测试。
准备工作
由于我们克隆了示例 quickstart 项目,该项目已经集成了并配置了 Karma。为了学习目的,我们希望在现有项目中集成 Karma。
要这样做,我们必须从项目根目录中删除现有的 karma.conf.js 文件。此外,我们还将从 node_modules 中删除 Karma、Jasmine 和相关模块。
有趣的是,我们不必手动操作,可以轻松地使用一个简单的命令创建基本的 Karma 配置文件。并且使用这个命令时,它将询问一些基本问题,就像我们在本章前面的部分所看到的那样。
设置 Karma
在 Angular 项目中设置 Karma,首要任务是创建一个位于项目根目录的 karma.conf.js 文件。这个文件基本上包含了一些键值对形式的配置。
有趣的是,我们不必手动操作,可以轻松地使用一个简单的命令创建基本的 Karma 配置文件。并且使用这个命令时,它将询问一些基本问题,就像我们在本章前面的部分所看到的那样:
$ karma init
使用默认答案。在当前目录中创建 karma.conf.js 后,打开配置。可能需要更改一些配置,主要是 Karma 要使用的文件定义。
在 files 部分使用以下定义,它定义了运行测试所需的文件:
files: [
// System.js for module loading
'node_modules/systemjs/dist/system.src.js',
// Polyfills
'node_modules/core-js/client/shim.js',
'node_modules/reflect-metadata/Reflect.js',
// zone.js
'node_modules/zone.js/dist/zone.js',
'node_modules/zone.js/dist/long-stack-trace-zone.js',
'node_modules/zone.js/dist/proxy.js',
'node_modules/zone.js/dist/sync-test.js',
'node_modules/zone.js/dist/jasmine-patch.js',
'node_modules/zone.js/dist/async-test.js',
'node_modules/zone.js/dist/fake-async-test.js',
// RxJs
{ pattern: 'node_modules/rxjs/**/*.js', included: false,
watched: false }, { pattern: 'node_modules/rxjs
/**/*.js.map', included: false, watched: false },
// Angular itself
{ pattern: 'node_modules/@angular/**/*.js', included:
false, watched: false },
{ pattern: 'systemjs.config.js', included: false, watched: false },
{ pattern: 'systemjs.config.extras.js', included: false,
watched: false },
'karma-test-shim.js',
{pattern: 'app/**/*.js', included: false, watched: true}
]
注意
在这里,通过模式,我们传递了两个选项,included 和 watched。included 指的是我们是否想要包含带有 <script> 标签的文件;在这里,我们将通过 SystemJS 添加它,所以这被传递为 false。而 watched 指的是在文件更改期间是否需要监视这个文件。我们将其设置为 true,因为我们想监视更改。
好像添加了很多文件,但这些是基本文件,是运行测试所必需的。
让我们更仔细地看看这些文件。在第一部分,这些主要是库文件,包括作为模块加载器的 SystemJS、作为同步处理器的 zonejs、作为响应式库的 RxJS 以及 Angular 库本身。
重要的是,第二部分中有一个新文件 karma-test-shim.js,我们需要将其与 Karma 一起作为测试套件中的模块加载器使用,即使用 SystemJS 加载 Karma 测试运行器中的模块。我们将在本节稍后查看该文件。
然后,这就是我们的应用程序源文件;我们将在同一目录中放置测试/规范文件,这样它们将加载所有我们的模块文件,包括它们的测试/规范文件。
除了文件之外,我们可能还需要根据需求更改一些更多的配置点,如下所示:
-
plugins:这是必需的,因为 Karma 将使用这些npm模块来执行。如果我们计划使用更多的npm模块,我们需要在这里添加它们;例如,如果我们计划使用 PhantomJS 作为我们的浏览器,我们需要在列表中添加'karma-phantomjs-launcher':plugins: [ 'karma-jasmine', 'karma-chrome-launcher' ] -
frameworks:目前不需要更改,因为默认情况下它选择 Jasmine;然而,如果我们计划使用其他框架,如 Mocha,则需要更新它。在这种情况下,以下选项应该更新:frameworks: ['jasmine'], -
browsers:当我们需要同时在多个浏览器中运行测试时,这很有用,而且大多数时候,我们可能需要在 PhantomJS 中运行测试,因此我们可以添加多个浏览器,如下所示:browsers: ['Chrome', 'PhantomJS']
到目前为止,这些是我们需要在 karma.conf.js 文件中进行的更改。
让我们看看我们的 karma.conf.js 文件,看看它看起来是什么样子:
module.exports = function(config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher')
],
files: [
// System.js for module loading
'node_modules/systemjs/dist/system.src.js',
// Polyfills
'node_modules/core-js/client/shim.js',
'node_modules/reflect-metadata/Reflect.js',
// zone.js
'node_modules/zone.js/dist/zone.js',
'node_modules/zone.js/dist/long-stack-trace-zone.js',
'node_modules/zone.js/dist/proxy.js',
'node_modules/zone.js/dist/sync-test.js',
'node_modules/zone.js/dist/jasmine-patch.js',
'node_modules/zone.js/dist/async-test.js',
'node_modules/zone.js/dist/fake-async-test.js',
// RxJs
{ pattern: 'node_modules/rxjs/**/*.js', included: false,
watched: false },
{ pattern: 'node_modules/rxjs/**/*.js.map', included:
false, watched: false },
// Paths loaded via module imports:
// Angular itself
{ pattern: 'node_modules/@angular/**/*.js', included:
false, watched: false },
{ pattern: 'node_modules/@angular/**/*.js.map', included:
false, watched: false },
{ pattern: 'systemjs.config.js', included: false,
watched: false },
{ pattern: 'systemjs.config.extras.js', included: false,
watched: false },
'karma-test-shim.js',
{ pattern: 'app/**/*.js', included: false, watched: true }
],
port: 9876,
colors: true,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
})
}
我们在文件列表中添加的另一个重要文件是 karma-test-shim.js;正如我们之前提到的,它是我们使用 Karma 与 SystemJS(模块加载器)所需的;我们已经从 Angular 快速启动项目中复制了该文件,并且根据项目结构,这可能需要做出一些更改。
让我们看看我们的 karma.conf.js 文件:
Error.stackTraceLimit = 0; // "No stacktrace"" is usually best for app testing.
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000;
var builtPath = '/base/app/';
__karma__.loaded = function () { };
function isJsFile(path) {
return path.slice(-3) == '.js';
}
function isSpecFile(path) {
return /\.spec\.(.*\.)?js$/.test(path);
}
function isBuiltFile(path) {
return isJsFile(path) && (path.substr(0, builtPath.length) ==
builtPath);
}
var allSpecFiles = Object.keys(window.__karma__.files)
.filter(isSpecFile)
.filter(isBuiltFile);
System.config({
baseURL: 'base',
// Extend usual application package list with test folder
packages: { 'testing': { main: 'index.js', defaultExtension: 'js'
} },
// Assume npm: is set in `paths` in systemjs.config
// Map the angular testing umd bundles
map: {
'@angular/core/testing':
'npm:@angular/core/bundles/core-testing.umd.js',
'@angular/common/testing':
'npm:@angular/common/bundles/common-testing.umd.js',
'@angular/compiler/testing':
'npm:@angular/compiler/bundles/compiler-testing.umd.js',
'@angular/platform-browser/testing':
'npm:@angular/platform-browser/bundles/
platform-browser-testing.umd.js',
'@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic/bundles
/platform-browser-dynamic-testing.umd.js',
'@angular/http/testing':
'npm:@angular/http/bundles/http-testing.umd.js',
'@angular/router/testing':
'npm:@angular/router/bundles/router-testing.umd.js',
'@angular/forms/testing':
'npm:@angular/forms/bundles/forms-testing.umd.js',
},
});
System.import('systemjs.config.js')
.then(importSystemJsExtras)
.then(initTestBed)
.then(initTesting);
/** Optional SystemJS configuration extras. Keep going w/o it */
function importSystemJsExtras(){
return System.import('systemjs.config.extras.js')
.catch(function(reason) {
console.log(
'Warning: System.import could not load the optional "systemjs.config.extras.js". Did you omit it by accident? Continuing without it.'
);
console.log(reason);
});
}
function initTestBed(){
return Promise.all([
System.import('@angular/core/testing'),
System.import('@angular/platform-browser-dynamic/testing')
])
.then(function (providers) {
var coreTesting = providers[0];
var browserTesting = providers[1];
coreTesting.TestBed.initTestEnvironment(
browserTesting.BrowserDynamicTestingModule,
browserTesting.platformBrowserDynamicTesting());
})
}
// Import all spec files and start karma
function initTesting () {
return Promise.all(
allSpecFiles.map(function (moduleName) {
return System.import(moduleName);
})
)
.then(__karma__.start, __karma__.error);
}
测试 Karma 运行器
Karma 的初始设置几乎完成;我们将运行我们的测试并看看进展如何。在运行之前,我们还需要做一件事--我们必须在 npm 脚本中添加 karma 任务,以便通过 npm 命令运行。为此,我们将在 package.json 文件的脚本部分添加一个名为 test 的任务:
"scripts": {
"test": "karma start karma.conf.js"
}
添加此片段后,我们可以通过 npm 运行测试,使用 npm test,这与 karma start 的工作方式相同:
$ npm test
所以,最终,我们准备通过 Karma 运行我们的测试。然而,哎呀,我们遇到了一些错误!缺少了运行测试所需的 jasmine-core 模块;实际上,可能还有更多模块缺失。
出现错误的输出如下:

是的,我们确实缺少了模块,这些实际上是我们的测试套件中的开发依赖项。在下一节中,我们将了解更多关于它们的信息。
缺少依赖项
虽然我们正在为 Angular 构建一个基本的测试套件,但我们仍然缺少一些必需的 npm 模块,这些是测试套件的开发依赖项,如下所示:
-
jasmine-core:这表示 Jasmine 是我们的测试框架 -
karma:这是我们的测试套件的 Karma 测试运行器 -
karma-chrome-launcher:这是从 Karma 启动 Chrome 所必需的,因为我们已在karma.config中定义 Chrome 为我们的浏览器 -
karma-jasmine:这是 Karma 对 Jasmine 的适配器
只要这些是依赖项,我们就应该安装这些模块,并将它们包含在 package.json 中。
我们可以像这里显示的那样一起安装所有这些模块:
$ npm install jasmine-core karma karma-chrome-launcher karma-
jasmine --save-dev
在成功安装所有必需的依赖项后,看起来我们的配置过程已经完成,我们准备再次运行 test:
$ npm test
命令输出应该显示如下:
$ Chrome 39.0.2623 (Mac OS X 10.10.5): Executed 0 of 0 ERROR
(0.003 secs / 0 secs)
就这些了。Karma 现在正在运行第一个 Angular 应用程序。
使用 Angular 和 Karma 进行测试
使用 Karma 进行的第一次测试的目的是创建一个动态待办事项列表。这次演练将遵循我们在第一章中讨论的 TDD 步骤,测试驱动开发简介:先测试,然后运行,然后改进。这将使我们能够获得更多使用 Angular 应用程序进行 TDD 的经验。
一个开发待办事项列表
在我们开始测试之前,让我们通过一个开发待办事项列表来集中关注需要开发的内容。这将使我们能够组织我们的思想。
这是待办事项列表:
-
维护项目列表:示例列表包括测试、执行和重构
-
向列表中添加一个项目:在添加项目后,示例列表是测试、执行、重构和重复
-
从列表中删除一个项目:在添加和删除项目后,示例列表是测试、执行和重构
测试项目列表
第一项开发任务是提供在组件上拥有项目列表的能力。接下来的几个步骤将引导我们通过 TDD 生命周期添加第一个功能。
先测试
确定从哪里开始通常是最难的部分。最好的方法是记住三个A(组装、动作和断言)并从基本的 Jasmine 模板格式开始。执行此操作的代码如下:
describe('Title of the test suite', () => {
beforeEach(() => {
// ....
});
it('Title of the test spec', () => {
// ....
});
});
让我们看看解释:
-
describe: 这定义了我们正在测试的主要功能。字符串将以可读的术语解释功能,然后函数将跟随测试。 -
beforeEach: 这是一步组装步骤。在这个步骤中定义的函数将在每个断言之前执行。最好在这个函数中在每个测试之前放置所需的测试设置。 -
it: 这是一步动作和断言步骤。在it部分,我们将执行正在测试的动作,然后进行一些断言。动作步骤不需要进入it函数。根据测试,它可能更适合在beforeEach函数中。
三个 A - 组装、动作和断言
现在模板已经准备好了,我们可以开始将这些部分拼凑起来。我们还将遵循三个 A 的咒语。
以下是组装部分的两个部分。
在第一部分,我们使用以下代码初始化组件并执行类的构造函数:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AppComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
...
在这里,我们导入了某些 Angular 测试 API,例如async和Testbed。在这里,async用于加载所需的模块以引导应用程序进行测试套件,而TestBed是编写 Angular API 单元测试的主要 API。它有一些服务用于在测试套件中创建、编译和初始化组件。
我们还没有定义AppComponent组件,但我们在得到失败的测试后将会这样做。
第二部分讨论了AppComponent对象。AppComponent对象将包含其this变量上的项目列表。将以下代码添加到beforeEach中,以获取组件对象:
// comp will hold the component object
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
在断言中,又有两个部分:
第一个断言是确保 AppComponent 对象有一个名为 items 的变量定义,包含三个项目。items 变量将用于存储所有项目的列表:
it('Should define a list object', () => {
expect(com.items).toBeDefined();
});
第二个和第三个断言将用于确认列表中的数据是否正确:
//Second test
it('Should have 3 items in list', () => {
expect(com.items.length).toBe(3);
});
//Third test
it('List items should be as expected', () => {
expect(com.items).toEqual(['test','execute','refactor']);
});
就这些;第一个是 test,第二个是 execute,第三个是 refactor。
让它运行
TDD 生命周期的下一步是让应用程序运行,并修复代码以便测试通过。记住,通过以下步骤考虑可以添加的最小组件,以使测试通过:
-
通过输入以下命令运行 Karma 测试:
$ npm start $ npm test -
如果我们遇到
TypeError: app_component_1.AppComponent is not a constructor错误,那么它可能是因为以下原因:- 前面的错误信息表明
AppComponent对象尚未定义。由于错误信息告诉我们需要什么,这是一个完美的开始点。
- 前面的错误信息表明
-
将
AppComponent类添加到app.component.ts文件中,如下所示:export class AppComponent { }; -
再次从
npm控制台运行start和test命令。现在我们应该看到一个新错误。错误:The expected undefined to be defined as follow-
新的错误信息再次清晰。我们还可以看到代码现在已经通过了以下断言点:
expect(com.items).toBeDefined(); -
由于对象中没有项目,我们需要添加一个。按照以下方式更新
app/app.component.ts文件:export class AppComponent { items:Array<string>; };
-
-
让我们从 npm 控制台再次运行
start和test命令。现在我们应该看到三个测试中的一个通过!这意味着我们已经成功使用 TDD 和 Karma 通过了第一个测试。现在,我们需要修复其他三个。-
下一个错误是:
expected 0 to equal 3 -
错误输出再次准确地描述了需要发生的事情。我们只需要初始化数组,包含元素 test、execute 和 run。让我们转到
app/app.component.ts并将数据添加到数组初始化中:export class AppComponent { items:Array<string>; constructor() { this.items = ['test','execute','refactor']; } };
-
-
再次从 npm 控制台运行
start和test命令。太棒了!输出是绿色的,表示所有测试都已通过。此步骤的结果组件和类代码如下:import {Component} from '@angular/core'; @Component({ // ... }) export class AppComponent { items:Array<string>; constructor() { this.items = ['test','execute','refactor']; } };
现在 让它运行 步骤已经完成,我们可以继续下一步,让它变得更好。
让它变得更好
到目前为止,没有直接需要重构的或已在开发待办事项列表中识别出的内容。审查开发待办事项列表显示,可以勾掉一项:
-
查看待办事项列表项的列表:示例列表包括 test、execute 和 refactor
-
向待办事项列表添加一项:在我们添加项目后,示例列表将包括 test、execute、refactor 和新项目
接下来是添加新项目到列表的要求。我们将再次遵循 TDD 节奏:先测试,然后运行,最后改进。
向组件类添加一个函数
下一个任务是给类添加添加项目到对象的能力。这需要向对象添加一个方法。本指南将遵循我们之前遵循的相同 TDD 步骤。
先进行测试
而不是创建一个新文件并复制一些组装步骤,以下测试将被插入到最后一个it方法下。原因是将使用相同的模块和控制器:
describe('Title of the test suite', () => {
let app:AppComponent;
beforeEach(() => {
// ....
});
it('Title of the test spec', () => {
// ....
});
describe('Testing add method', () => {
beforeEach(() => {
// ....
});
it('Title of the test spec', () => {
// ....
});
});
});
三个 A - 组装、行动和断言
现在模板已经准备好了,我们可以使用 3A 咒语来填补空白:
-
组装:由于组件和对象将继承,因此不需要初始化或设置。
-
行动:在这里,我们需要使用新项目对
add方法进行操作。我们将act函数放入beforeEach函数中。这允许我们在添加更多测试时/如果需要重复相同的步骤:beforeEach(() => { com.add('new-item') }); -
断言:在这里,应该向列表中添加一个项目,然后需要确认数组中的最后一个项目符合预期:
it('Should have 4 items in list', () => { expect(com.items.length).toBe(4); }); it('Should add a new item at the end of list', () => { var lastIndexOfList = com.items.length - 1; expect(com.items[lastIndexOfList]).toEqual('new-item'); });
让它运行
TDD 生命周期中的下一步是让它运行。记住,考虑可以添加的最小组件以使测试通过,如下所示:
-
通过输入以下命令确保 Karma 在我们的控制台中运行:
$ npm start $ npm test -
第一个错误将声明
TypeError: undefined is not a function。这个错误指的是以下代码行:
app.add('new-item');错误告诉我们
add方法尚未定义。需要将add函数添加到app/app.component.ts代码中。类已经定义,因此需要将add函数放在类中:add() { this.items.push('new-item'); };注意
add函数不包含任何逻辑。已经添加了最小的组件以使测试满足错误信息。 -
查看控制台窗口以获取下一个错误。
成功!现在所有五个测试都已通过。
为了使测试通过,添加的代码如下:
import {Component} from '@angular/core';
@Component({
selector: 'my-app',
template: `<h3>MY Items</h3><ul><li *ngFor="let item of items">{{
item }}</li></ul>`
})
export class AppComponent {
items:Array<string>;
constructor() {
this.items = ['test','execute','refactor'];
}
add() {
this.items.push('new-item');
}
};
让它变得更好
我们需要重构的主要问题是add函数尚未完全实现。它包含一个硬编码的值,一旦我们向add函数发送不同的项目,测试就会失败。
保持 Karma 运行,以便在做出更改时继续通过测试。当前add方法的主要问题如下:
-
它不接受任何参数
-
它没有将参数推送到列表中,而是使用硬编码的值
现在的add函数应该看起来如下:
add(item) {
this.items.push(item);
};
再次从npm控制台运行start和test命令。确认 Karma 输出仍然显示SUCCESS:
$ Chrome 49.0.2623 (Mac OS X 10.10.5): Executed 5 of 5 SUCCESS
(0.016 secs / 0.002 secs)
配置 Karma 与 Travis CI
持续集成(CI)是一种开发实践,其中开发人员需要将代码集成到共享仓库中。它在与代码库中的任何更改发生时在自动构建过程中运行,并执行测试。这可以在代码推送到生产之前检测到错误。有许多 CI 服务,包括 Travis CI、Jenkins CI、Circle CI 等。
在本节中,我们将看到如何将 Karma 与 Travis 集成。
Travis CI
Travis CI 是一个流行的托管持续集成平台,它与 GitHub 项目/存储库集成,可以自动在代码库的任何分支或甚至每个 pull request 的更改时运行测试。只需在项目根目录中放置一个包含有关项目配置信息的.travis.yml文件,就可以轻松地获得集成系统。
因此,我们可能会问,为什么是 Travis?还有其他一些 CI 服务。如果我们将 Travis 与其他 CI 服务进行比较,它相对于其他服务有一些优势:
-
它是一个托管服务;无需托管、安装和配置
-
它是免费且开源的
-
它为每个分支都有独立的测试代码,因此很容易为单个分支运行测试
配置 Travis
如我们所说,我们将在项目目录中有一个.travis.yml文件,其中包含一些配置和关于我们项目的信息。
这里是 YAML 文件中的基本配置看起来像什么:
-
指定语言:我们在这里使用了 Node.js:
language: node_js node_js: - "4" -
命令或脚本:这是在每次构建前后必需的;如所示,此脚本将在每次运行构建之前设置
git用户名:before_script: - git config - -global user.name jquerygeek
在前面的示例中,我们通过 karma 将配置传递给在真实浏览器(Firefox)中运行构建过程,默认使用虚拟屏幕,这已经使用 PhantomJS 无头浏览器运行了该过程。这可能很有用,只要 Travis 支持 PhantomJS 之外的实时浏览器:
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
-
通知:这是设置电子邮件和聊天通知所必需的。在这里,我们将
email设置为false,因为我们不希望收到关于构建的过度电子邮件通知:notifications: email: false
使用 Karma 设置测试
如前所述,我们猜测我们的项目根目录中有一个package.json文件,其中包含 npm 包;如果没有,让我们在那里创建一个package.json文件并添加这些配置片段。这里,第一个是 Karma 的依赖项,第二个是为npm test设置所需的参数,因为 Travis 将运行此命令来触发我们的测试。这些将告诉 Travis 如何运行我们的测试:
'devDependencies': {
'karma': '~0.12'
},
'scripts': {
'test': 'karma start - -single-run - -browsers PhantomJS '
}
我们最初的设置和配置已经准备好进行测试。我们定义了 Karma 依赖项,因为 Travis 将为每个套件运行nmp install,并将采取添加 Karma 的必要步骤。在运行测试时,它将调用npm test,我们定义了该测试任务将如何运行测试。在这里,我们已将默认浏览器设置为 PhantomJS,以便测试与其一起运行。然而,如果我们需要使用不同的浏览器运行测试,我们应该在.travis.yml文件中使用before_script命令定义这一点,就像我们之前为 Firefox 所做的那样。
在那种情况下,npm test将不会使用自定义浏览器运行测试;为此,我们必须使用浏览器名称进行自定义调用,如下所示:
karma start - -browsers Firefox - -single-run
自测问题
以下自我测试问题将帮助你进一步测试你使用 AngularJS 和 Karma 进行 TDD 的知识:
Q1. 你如何使用 Karma 创建一个配置文件?
-
karma config -
karma init -
karma -config karma.conf.js
Q2. Jasmine 测试方法,名为before,在每次测试之前执行。
-
正确
-
错误
Q3. Bower 用于安装 Karma。
-
正确
-
错误
Q4. 3A 代表以下哪一个?
-
一群超级英雄
-
组装、行动和断言
-
接受、批准和行动
摘要
在本章中,我们讨论了 Karma 如何变得重要。我们看到了如何安装、配置和运行 Karma。最后,我们通过一个使用 Karma 进行 TDD 的 Angular 示例项目进行了演示。
在下一章中,我们将学习使用 Protractor 进行端到端测试。
第四章。使用 Protractor 进行端到端测试
单元测试只是测试的一个方面,它只测试每一块代码的责任。然而,当涉及到测试任何组件、模块或完整应用程序的流程和功能时,那么端到端(e2e)测试是唯一的解决方案。
在本章中,我们将逐步查看应用程序的所有层级的端到端测试流程。我们将介绍来自 Angular 团队的端到端测试工具 Protractor。我们已经知道了它的原因,为什么它被创建,以及它解决了哪些问题。
在本章中,我们将:
-
安装和配置 Protractor 的过程
-
在我们的现有 Angular 项目中实现 Protractor 端到端测试
-
e2e 测试运行
-
返回测试结果
Protractor 概述
Protractor 是一个使用 Node.js 运行的端到端测试工具,作为 npm 包提供。在具体讨论 Protractor 之前,我们需要了解什么是端到端测试。
我们已经在第二章JavaScript 测试的细节中简要了解了端到端测试。但是,让我们快速回顾一下:
端到端测试是对应用程序的所有相互连接的移动部分和层进行测试。这与单元测试不同,单元测试的重点在于单个组件,例如类、服务和指令。在端到端测试中,重点是整个应用程序或模块如何工作,例如确认按钮的点击会触发 x、y 和 z 动作。
Protractor 允许通过交互应用程序的 DOM 元素来对任何模块或任何规模的 Web 应用程序进行端到端测试。它提供了选择特定 DOM 元素、与该元素共享数据、模拟按钮点击以及以用户的方式与应用程序交互的能力。然后允许根据用户期望设置期望。
Protractor 的核心
在快速概述中,我们得到了关于 Protractor 的基本概念——它需要如何选择 DOM 元素并与它们交互,就像真实用户在运行任何应用程序的端到端测试时那样。为了进行这些活动,Protractor 提供了一些全局函数;其中一些来自其核心 API,一些来自 WebDriver。我们将在第五章Protractor,一步领先中详细讨论它们。
然而,现在让我们快速概述一下:
-
浏览器:Protractor 提供了全局函数
browser,这是一个来自 WebDriver 的全局对象,主要用于在端到端测试过程中与运行应用程序的应用程序浏览器进行交互。它提供了一些有用的方法来交互,如下所示:browser.get('http://localhost:3000'); // to navigate the browser to a specific url address browser.getTitle(); // this will return the page title that defined in the projects landing page还有更多,我们将在下一章中讨论。
-
元素:这是 Protractor 提供的一个全局函数;它基本上用于根据定位器查找单个元素,但它也支持通过链式另一个方法
.all(即element.all)进行多个元素选择,该方法也接受定位器并返回ElementFinderArray。让我们看看一个element的示例:element(Locator); // return the ElementFinder element.all(Locator); // return the ElementFinderArray element.all(Locator).get(position); // will return the defined position element from the ElementFinderArray element.all(Locator).count(); // will return the total number in the select element's array还有更多,我们将在下一章讨论。
-
动作:正如我们所见,
element方法将返回一个选定的 DOMelement对象,但我们需要与 DOM 进行交互,而执行这项工作的动作方法由一些内置方法提供。DOM 不会通过任何动作方法调用与浏览器单元进行联系。让我们看看动作的一些示例:element(Locator).getText(); // return the ElementFinder based on locator element.(Locator).click(); // Will trigger the click handler for that specific element element.(Locator).clear(); // Clear the field's value (suppose the element is input field)还有更多,我们将在下一章讨论。
-
定位器:这实际上告诉 Protractor 如何在 DOM 元素中找到某个元素。Protractor 将
定位器导出为一个全局工厂函数,该函数将与全局by对象一起使用。让我们看看定位器的几个示例:element(by.css(cssSelector)); // select element by css selector element(by.id(id)); // select element by element ID element.(by.model); // select element by ng-model还有更多,我们将在下一章讨论。
一个快速示例
现在我们可以通过以下用户规范快速进行一个示例。
假设我在搜索框中输入 abc,以下应该发生:
-
应该点击搜索按钮
-
应至少收到一个结果
前面的规范描述了一个基本搜索功能。前面的规范中没有描述控制器、指令或服务;它只描述了预期的应用程序行为。如果用户要测试该规范,他们可能执行以下步骤:
-
将浏览器指向网站。
-
选择输入字段。
-
在输入字段中输入
abc。 -
点击 搜索 按钮。
-
确认搜索输出至少显示一个结果。
Protractor 的结构和语法与 Jasmine 和我们已在第三章中编写的测试相似,Karma 方式。我们可以将 Protractor 视为 Jasmine 的一个包装器,增加了支持端到端测试的功能。要使用 Protractor 编写端到端测试,我们可以遵循我们刚才看到的相同步骤,但使用代码。
这里是带有代码的步骤:
-
将浏览器指向网站:
browser.get('/'); -
选择输入字段:
var inputField = element.all(by.css('input')); -
在输入字段中输入
abc:inputField.setText('abc'); -
点击 搜索 按钮:
var searchButton = element.all(by.css('#searchButton'); searchButton.click(); -
在页面上找到搜索结果详情:
var searchResults = element.all(by.css('#searchResult'); -
最后,需要断言屏幕上至少有一个或多个搜索结果可用:
expect(searchResults).count() >= 1);
作为完整的测试,代码将如下所示:
describe('Given I input 'abc' into the search box',function(){
//1 - Point browser to website
browser.get('/');
//2 - Select input field
var inputField = element.all(by.css('input'));
//3 - Type abc into input field
inputField.setText('abc');
//4 - Push search button
var searchButton = element.all(by.css('#searchButton');
searchButton.click();
it('should display search results',function(){
// 5 - Find the search result details
var searchResults = element.all(by.css('#searchResult');
//6 - Assert
expect(searchResults).count() >= 1);
});
});
就这样!当 Protractor 运行时,它将打开浏览器,访问网站,遵循指示,并最终检查期望。端到端测试的技巧是有一个清晰的用户规范愿景,然后将该规范转换为代码。
之前的示例是本章将描述的内容的高级概述。现在我们已经介绍了 Protractor,本章的其余部分将展示 Protractor 在幕后是如何工作的,如何安装它,最后,通过一个使用 TDD 的完整示例来引导我们。
Protractor 的起源
Protractor 不是 Angular 团队构建的第一个端到端测试工具。第一个工具被称为Scenario Runner。为了理解为什么构建了 Protractor,我们首先需要看看它的前身--Scenario Runner。
Scenario Runner 处于维护模式,并已到达其生命的尽头。它已被 Protractor 取代。在本节中,我们将探讨 Scenario Runner 是什么以及该工具存在的空白。
Protractor 的诞生
Julie Ralph 是 Protractor 的主要贡献者。据 Julie Ralph 所说,Protractor 的动机基于以下在 Google 内部另一个项目中对 Angular Scenario Runner 的经验(javascriptjabber.com/106-jsj-protractor-with-julie-ralph/):
"我们尝试使用 Scenario Runner。我们发现它真的无法完成我们需要测试的事情。我们需要测试登录等功能。您的登录页面不是 Angular 页面,Scenario Runner 无法处理这种情况。它也无法处理弹出窗口、多个窗口、浏览历史记录导航等问题。"
基于她对 Scenario Runner 的使用经验,Julie Ralph 决定创建 Protractor 来填补空白。
Protractor 利用了 Selenium 项目的成熟度,并封装了其方法,以便它可以轻松用于 Angular 项目。记住,Protractor 是通过用户的角度进行测试的。它被设计来测试应用程序的所有层:Web UI、后端服务、持久化层等。
没有 Protractor 的生活
单元测试不是唯一需要编写和维护的测试。单元测试关注应用程序的小型单个组件。通过测试小型组件,代码和逻辑的信心增强。单元测试不关注当相互连接时整个系统是如何工作的。
使用 Protractor 进行端到端测试允许开发者专注于一个功能或模块的完整行为。回到搜索示例,测试应该只在整个用户规范通过时才通过;在搜索框中输入数据,点击搜索按钮,并查看结果。Protractor 不是唯一的端到端测试框架,但它是 Angular 应用程序的最佳选择。以下是您应该选择 Protractor 的几个原因:
-
它在 Angular 教程和示例中都有文档记录
-
它可以使用多个 JavaScript 测试框架编写,包括 Jasmine 和 Mocha
-
它为 Angular 组件提供便利方法,包括等待页面加载、对承诺的期望等
-
它封装了 Selenium 方法,这些方法会自动等待承诺得到满足
-
它由 SaaS(软件即服务)提供商支持,例如 Sauce Labs,可在
saucelabs.com/找到 -
它由维护 Angular 和 Google 的同一家公司支持和维护。
准备使用 Protractor
是时候开始动手安装和配置 Protractor 了。安装和应用程序不断变化。主要关注的是本书中使用的特定配置,而不是深入的安装指南。有几种不同的配置,因此请查阅 Protractor 网站,以获取更多详细信息。要找到最新的安装和配置指南,请访问 angular.github.io/protractor/。
安装 WebDriver 的先决条件
Protractor 有以下先决条件:
-
Node.js:Protractor 是一个使用 npm 可用的 Node.js 模块。安装 Node.js 的最佳方式是遵循官方站点
nodejs.org/download/上的说明。 -
Chrome:这是由 Google 构建的 Web 浏览器。它将在 Protractor 中运行端到端测试,而无需 Selenium 服务器。请遵循官方站点
www.google.com/chrome/browser/上的安装说明。 -
Chrome 的 Selenium WebDriver:这是一个允许您与 Web 应用程序交互的工具。Selenium WebDriver 与 Protractor 的
npm模块一起提供。我们将按照安装 Protractor 的说明进行操作。
安装 Protractor
安装 Protractor 的步骤如下:
-
一旦 Node.js 安装并可在命令提示符中使用,请输入以下命令以在当前目录中安装 Protractor:
$ npm install protractor -
前面的命令使用 Node 的
npm命令在当前本地目录中安装 Protractor。 -
要在命令提示符中使用 Protractor,请使用 Protractor bin 目录的相对路径。
-
按以下方式测试 Protractor 版本是否可以确定:
$ ./node_modules/protractor/bin/protractor --version
安装 Chrome WebDriver
安装 Chrome WebDriver 的步骤如下:
-
要安装 Chrome 的 Selenium WebDriver,请转到 Protractor 的
bin目录中的webdriver-manager可执行文件,该文件位于./node_modules/protractor/bin/,然后输入以下内容:$ ./node_modules/protractor/bin/webdriver-manager update -
确认目录结构。
-
前面的命令将在项目中创建一个包含所需 Chrome 驱动程序的 Selenium 目录。
安装现已完成。Protractor 和 Chrome 的 Selenium WebDriver 都已安装。我们现在可以继续进行配置。
自定义配置
在本节中,我们将按照以下步骤配置 Protractor:
-
从标准模板配置开始。
-
幸运的是,Protractor 的安装目录中包含了一些基本的配置。
-
我们将使用的是位于
protractor/example部分的名为conf.js的文件。 -
查看示例配置文件:
capabilities参数应仅指定浏览器的名称:exports.config = { //... capabilities: { 'browserName': 'chrome' }, //... };framework参数应指定测试框架的名称,我们将在这里使用 Jasmine:exports.config = { //... framework: 'jasmine' //... };最后一个重要的配置是源文件声明:
exports.config = { //... specs: ['example_spec.js'], //... };
太棒了!现在我们已经安装并配置了 Protractor。
确认安装和配置
要确认安装,Protractor 至少需要一个在specs配置部分中定义的文件。在添加真实测试并使事情复杂化之前,请在根目录中创建一个名为confirmConfigTest.js的空文件。然后,编辑位于项目根目录中的conf.js文件,并将测试文件添加到specs部分,使其看起来如下:
specs: ['confirmConfigTest.js'],
要确认 Protractor 已经安装,请进入项目目录的根目录,并输入以下命令:
$ ./node_modules/protractor/bin/protractor conf.js
如果一切设置正确并且安装成功,我们将在命令提示符中看到类似以下的内容:
Finished in 0.0002 seconds
0 tests, 0 assertions, 0 failures
常见的安装和配置问题
以下是一些在安装 Chrome WebDriver 时可能会遇到的一些常见问题:
| 问题 | 解决方案 |
|---|---|
| Selenium 未正确安装 | 如果测试出现与 Selenium WebDriver 位置相关的错误,您需要确保您已遵循更新 WebDriver 的步骤。更新步骤会将 WebDriver 组件下载到本地的 Protractor 安装文件夹中。在 WebDriver 更新之前,您无法在 Protractor 配置中引用它。一个简单的方法是查看 Protractor 目录,并确保存在一个 Selenium 文件夹。 |
| 找不到测试 | 当 Protractor 没有执行任何测试时,可能会让人感到沮丧。最好的开始地方是配置文件。请确保相对路径以及任何文件名或扩展名都是正确的。 |
有关完整列表,请参阅官方 Protractor 网站angular.github.io/protractor/。
将 Protractor 集成到 Angular 中
到目前为止,我们已经看到了如何安装和配置 Protractor,我们还对 Protractor 的工作原理有一个基本的概述。在本节中,我们将介绍将 Protractor 集成到现有 Angular 项目中的过程,其中我们只有单元测试,并了解 Protractor 在实际的端到端测试中的应用。
获取现有项目
此测试中的代码将利用来自第三章,“Karma 方式”的单元测试代码。我们将把代码复制到一个名为angular-protractor的新目录中。
作为提醒,该应用程序是一个待办事项应用程序,其中包含一些待办事项列表中的项目;让我们再添加一些项目到列表中。它有一个单独的组件类,AppComponent,其中包含一个项目列表和一个add方法。当前的代码目录应该按照以下结构组织:

在获得这种结构后,第一项任务是运行以下命令,在本地获取所需的依赖项node_modules:
$ npm install
这将安装所有必需的模块;接下来,让我们使用npm命令构建和运行项目:
$ npm start
一切都应该正常;项目应该在http://localhost:3000上运行,输出应该如下所示:

是的,我们已经准备好进入下一步,在 Angular 项目中实现 Protractor。
Protractor 设置流程
设置将与我们在本章前面看到的安装和配置步骤相匹配:
-
安装 Protractor。
-
更新 Selenium WebDriver。
-
根据示例配置配置 Protractor。
我们将在新的项目目录中遵循我们在上一节中涵盖的 Protractor 安装和配置步骤。唯一的区别是 Protractor 测试可以带有 e2e 前缀,例如**.e2e.js。这将使我们能够轻松地在项目结构中识别 Protractor 测试。
小贴士
这完全取决于开发者的选择;有些人只是将 Protractor 测试放在一个新的目录中,并有一个子目录,spec/e2e。这只是项目结构的一部分。
安装 Protractor
我们可能已经全局设置了 Protractor,也可能没有,所以总是很好在项目中安装 Protractor。因此,我们将本地安装 Protractor 并将其添加到package.json中作为devDependency。
要在我们的项目中安装 Protractor,请从项目目录运行此命令:
$ npm install protractor -save-dev
我们可以如下检查 Protractor:
$ ./node_modules/protractor/bin/protractor --version
这应该提供最新版本,4.0.10,如下所示:
Version 4.0.10
小贴士
我们将遵循的良好实践
我们展示了如何在目录中设置 Protractor,但最好使用以下命令全局安装 Protractor:
$ npm install -g protractor
这样我们可以轻松地从命令行调用 Protractor,就像使用protractor一样;要了解 Protractor 版本,我们可以如下调用它:
$ protractor -version
更新 WebDriver
要更新 Selenium WebDriver,请转到 Protractor 的bin目录中的webdriver-manager可执行文件,该文件位于./node_modules/protractor/bin/,并输入以下内容:
$ ./node_modules/protractor/bin/webdriver-manager update
根据通知,我们将全局安装 Protractor,如果这样做,我们也将全局安装webdriver-manager命令,这样我们就可以轻松地运行它进行update,如下所示:
$ webdriver-manager update
这将更新 WebDriver 并支持最新浏览器。
准备工作
我们已经克隆了快速入门项目样本,该项目已经集成了并配置了 Protractor。为了学习目的,我们希望在现有项目中集成 Protractor。
要做到这一点,我们必须从项目根目录中删除现有的protractor.config.js文件。
设置核心配置
如我们之前所见,Protractor 配置将存储在一个 JS 文件中。我们需要在项目根目录中创建一个配置文件;让我们将其命名为protractor.config.js。
目前,请保持可变字段为空,因为这些字段取决于项目结构和配置。因此,初始外观可能如下所示,并且这些配置选项是我们所熟知的:
exports.config = {
baseUrl: ' ',
framework: 'jasmine',
specs: [],
capabilities: {
'browserName': 'chrome'
}
};
只要我们的项目在本地端口3000上运行,我们的baseUrl变量将如下所示:
exports.config = {
// ....
baseUrl: ' http://localhost:3000',
// ....
};
我们计划将我们的端到端测试 spec 放在与单元测试文件相同的文件夹中,即app/app.component.spec.ts。这次它将有一个新的 e2e 前缀,看起来像app/app.component.e2e.ts。基于这一点,我们的 spec 和配置将更新:
exports.config = {
// ....
specs: [
'app/**/*.e2e.js'
],
// .....
};
只要是一个 Angular 项目,我们需要传递一个额外的配置,useAllAngular2AppRoots: true,因为它将告诉 Protractor 等待页面上所有 Angular 应用的根元素,而不仅仅是匹配的一个根元素:
exports.config = {
// ....
useAllAngular2AppRoots: true,
// .....
};
我们通过 node 服务器运行我们的项目;因此,我们需要传递一个额外的配置选项,以便 Jasmine 本身支持 node。这个配置在 Jasmine 2.x 版本中是必须的,但如果我们使用 Jasmine 1.x,可能不需要它。在这里,我们在jasmineNodeOpts中添加了两个最常用的选项;有一些是基于需求使用的:
exports.config = {
// ....
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000
},
// .....
};
深入测试细节
要运行 Protractor 测试,我们需要两个文件:一个是我们在项目根目录中已经创建的配置文件,名为protractor.conf.js,另一个是 spec 文件,我们将在这里定义端到端测试 spec,它将位于 app 文件夹中,名为app/app.component.e2e.ts。
因此,让我们看看我们应该在那里定义的文件:
describe('Title for test suite', () => {
beforeEach(() => {
// ...
});
it('Title for test spec', () => {
// ...
});
});;
这些语法应该是我们所熟知的,因为我们已经在单元测试套件中使用了 Jasmine 语法。
让我们快速回顾一下
-
describe:这个包含要运行的测试套件的代码块。 -
beforeEach:这个用于包含设置代码,它被用于每个测试 spec 中。 -
it:这个用于定义测试 spec 并包含运行该测试 spec 的特定代码。
运行任何网站的端到端测试的主要部分是获取该网站的 DOM 元素,然后通过测试过程与这些元素交互。因此,我们需要获取我们正在运行的项目中的 DOM 元素。
只要当前项目在浏览器中运行,我们首先需要获取浏览器本身的实例;有趣的是,Protractor 通过全局浏览器对象提供这一点。有了这个浏览器对象,我们可以获取所有浏览器级别的命令,例如 browser.get,并且我们可以导航到我们的项目 URL:
beforeEach(() => {
browser.get('');
});;
使用这个 browser.get('') 方法,我们将导航到我们项目的根目录。
我们有全局浏览器对象,我们可以用它来获取正在运行的页面的标题,这基本上是我们在这里项目 index.html 文件中定义的标题。browser.getTitle 将提供标题,然后我们可以按预期匹配它。所以,我们的测试规范将看起来像这样:
it('Browser should have a defined title', () => {
expect(browser.getTitle()).toEqual('Angular Protractor');
});
如果我们快速浏览一下,我们的简短端到端测试规范将看起来如下:
describe('AppComponent Tests', () => {
beforeEach(() => {
browser.get('');
});
it('Browser should have a defined title', () => {
expect(browser.getTitle()).toEqual('Angular Protractor');
});
});
是时候使用 Protractor 运行端到端测试了。命令看起来如下所示:
$ protractor protractor.conf.js
结果正如预期--没有失败,因为我们已经将 index.html 页面标题设置为 Angular Protractor。结果将如下所示:
1 spec, 0 failures
Finished in 1.95 seconds
现在是时候继续并添加一个新的测试规范来测试页面上剩余的 DOM 元素了,其中我们在页面上列出了列表项;因此,我们将通过 Protractor 自动测试它们。
首先,我们将检查我们是否列出了所有三个项目。我们已经在本章的早期部分学习了关于一些 Protractor 常见 API 的内容,但快速回顾一下,我们将使用 element.all 方法通过传递一些定位器(by.css、by.id 和 by.model)来获取元素数组对象。然后,我们可以使用 Jasmine 匹配器与预期值匹配,如下所示:
it('Should get the number of items as defined in item object', () => {
var todoListItems = element.all(by.css('li'));
expect(todoListItems.count()).toBe(3);
});
我们应该得到通过的结果,因为我们已经在 UI 中列出了三个项目。
我们可以添加一些额外的测试规范来测试 UI 元素。例如,为了检查列出的项目是否按正确顺序排列,我们可以检查它们的标签,如下所示:
it('Should get the first item text as defined', () => {
expect(todoListItems.first().getText()).toEqual('test');
});
it('Should get the last item text as defined', () => {
expect(todoListItems.last().getText()).toEqual('refactor');
});
我们已经将第一个和最后一个项目的标签/文本与预期值匹配,它也应该通过。
让我们将 e2e 文件中的所有测试规范合并起来。它将看起来像这样:
describe('AppComponent Tests', () => {
var todoListItems = element.all(by.css('li'));
beforeEach(() => {
browser.get('/');
});
it('Browser should have a defined title', () => {
expect(browser.getTitle()).toEqual('Angular Protractor');
});
it('Should get the number of items as defined in item object', ()
=> {
expect(todoListItems.count()).toBe(3);
});
it('Should get the first item text as defined', () => {
expect(todoListItems.first().getText()).toEqual('test');
});
it('Should get the last item text as defined', () => {
expect(todoListItems.last().getText()).toEqual('refactor');
});
});
让我们一次性运行所有规范:
$ protractor protractor.conf.js
如预期,所有测试都应该通过,结果将如下所示:
4 specs, 0 failures
Finished in 2.991 seconds
小贴士
只要我们命名我们的 Protractor 配置文件为 protractor.conf.js,在运行时就不需要提及配置文件名;Protractor 会自己获取其配置文件。如果使用其他名称,我们应该在 Protractor 中提及配置文件名。
因此,在这种情况下,我们只需按照以下方式运行测试:
$ protractor
结果将与之前相同。
通过 NPM 运行测试
在这个项目中,我们将通过 npm 构建和运行项目。在 第三章,Karma 方法中,我们通过 npm 运行了 karma 测试;同样,我们也将通过 npm 运行 protractor 测试。为此,我们必须在我们的项目 package.json 的 scripts 部分添加 protractor:
"scripts": {
// ...
"e2e": "protractor"
// ....
};
要在我们的项目中安装 protractor,从项目目录运行:
$ npm e2e
在某些操作系统上,此命令可能会产生一些npm错误。这实际上是针对webdriver-manager的,可能没有更新。为了解决这个问题,我们必须将webdriver-manager更新脚本添加到npm中,并在第一次运行时只运行一次,如下所示:
"scripts": {
// ...
"webdriver-update": "webdriver-manager update"
// ....
};
我们还必须以以下方式运行它:
$ npm webdriver-update
就这样,我们准备好再次运行 e2e 测试,这应该与protractor命令完全相同。
让我们确认这一点:
$ npm run e2e
预期的结果将如下所示:
4 specs, 0 failures
Finished in 2.991 seconds
提高测试质量
本章中讨论的一些内容需要进一步澄清。这些包括以下内容:
-
异步逻辑在哪里?
-
我们究竟如何真正地通过端到端测试实现 TDD?
异步魔法
在前面的测试中,我们看到了一些你可能质疑的魔法。以下是我们简要了解的一些魔法组件:
-
在测试执行前加载页面
-
在承诺中加载的元素上的断言
在测试执行前加载页面
在之前的测试中,我们使用了以下代码来指定浏览器应指向主页:
browser.get('');
之前的命令将启动浏览器并导航到baseUrl位置。一旦浏览器到达页面,它将必须加载 Angular 并实现 Angular 特定的功能。我们的测试没有任何等待逻辑,这正是 Protractor 与 Angular 结合的美丽之处。页面加载的等待已经为我们内置到框架中。然后我们可以非常干净地编写测试。
在承诺中加载的元素上的断言
断言和期望已经包含了承诺满足的代码。在我们的测试中,我们编写断言,使其期望计数为3:
expect(todoListItems.count()).toBe(3);
然而,在现实中,我们可能认为我们需要将异步测试添加到断言中,以便等待承诺得到满足,涉及一些更复杂的东西,如下所示:
it('Should get the number of items as defined in item object', (done) => {
var todoListItems = element.all(by.css('li'));
todoListItems.count().then(function(count){
expect(count).toBe(3);
done();
});
});
之前的代码更长,更细粒度,也更难以阅读。Protractor 具有使测试对某些内置期望的元素更加简洁的能力。
使用 Protractor 进行 TDD
在我们的第一个测试中,端到端测试和单元测试之间有一个清晰的区分。在单元测试中,我们关注将测试与代码紧密耦合。例如,我们的单元测试监视了特定组件类AppComponent的作用域。我们必须初始化组件以获取组件的实例,如下所示:
import {AppComponent} from "./app.component";
beforeEach(() => {
app = new AppComponent();
});
在 Protractor 测试中,我们不关心我们正在测试哪个组件类,我们的重点是测试的用户视角。我们从选择 DOM 中的特定元素开始;在我们的例子中,该元素与 Angular 相关联。断言是特定重复器的元素数量等于预期的计数。
通过端到端测试的松散耦合,我们可以编写一个专注于用户需求的测试,最初显示三个元素,然后有自由选择在页面、类、组件等地方以我们想要的方式编写。
自我测试问题
使用 Protractor 进行 TDD 开发第三个开发待办事项。
Q1. Protractor 使用以下哪个框架?
-
Selenium
-
Unobtanium
-
Karma
Q2. 你可以使用任何现有的 Angular 项目安装 Protractor。
-
正确
-
错误
Q3. Karma 和 Protractor 可以在单个项目中一起运行。
-
正确
-
错误
Q4. 哪个团队开发了 Protractor?
-
ReactJS 团队
-
Angular 团队
-
NodeJS 团队
摘要
本章为我们提供了使用 Protractor 进行端到端测试的概述,并提供了安装、配置和使用现有 Angular 项目进行端到端测试的必要思路。Protractor 是测试任何 Angular 应用程序的重要组成部分。它架起了桥梁,确保用户的规格工作如预期。当端到端测试根据用户规格编写时,应用程序的信心和重构能力都会增强。在接下来的章节中,我们将通过简单直接示例深入了解如何应用 Karma 和 Protractor。
下一章将带我们深入了解 Protractor,包括一些高级配置、一些 API 的细节,并且还会调试测试。
第五章:Protractor,更进一步
只要它与浏览器直接交互,端到端测试就非常有趣,但一个好的开发者应该了解 Protractor 的高级功能以执行大规模应用程序测试。除此之外,由于它依赖于浏览器的 DOM 元素,调试在端到端测试中也是一种挑战。
Protractor 提供了一些用于调试的 API。本章将主要涵盖这些 API 和功能,包括以下内容:
-
设置和配置 Protractor
-
一些高级 Protractor API,如 browser、locator 和 action
-
使用
browser.pause()和browser.debug()API 调试 Protractor
高级设置和配置
在上一章中,我们看到了 Protractor 的基本和常用设置和配置。在这里,我们将探讨一些高级配置,这些配置可以使安装更加简单和强大。
全局安装 Protractor
下面是全局安装 Protractor 的步骤:
-
一旦 Node.js 已经安装并且可以在命令提示符中使用,输入以下命令以在系统上全局安装 Protractor:
$ npm install -g protractor之前的命令使用 Node 的
npm命令全局安装 Protractor,这样我们就可以仅使用protractor命令来使用 Protractor。 -
检查 Protractor 版本是否可以如下确定:
$ protractor --version
高级配置
在本节中,我们将通过以下步骤对 Protractor 进行更多配置:
-
更新 protractor 的
config文件以支持单个测试套件中的多个浏览器。multiCapabilities参数是一个数组,它为任何测试套件接受多个browserName对象,如下所示:exports.config = { //... multiCapabilities: [{ 'browserName': 'firefox' }, { 'browserName': 'chrome' }] //... }; -
我们可以在
capabilities参数中为浏览器设置高级设置;例如,对于chrome,我们可以通过chromeOptions传递额外的参数,如下所示:exports.config = { //... capabilities: { 'browserName': 'chrome' 'chromeOptions': { 'args': ['show-fps-counter=true'] }}] //... }; -
有时候,我们可能需要在不使用 Selenium 或 WebDriver 的情况下直接运行 Protractor。这可以通过在
config.js文件中传递一个参数来实现。该参数是配置对象中的directConnect: true,如下所示:exports.config = { //... directConnect: true, //... };
太好了!我们已经将 Protractor 配置得更加深入了一步。
Protractor API
任何网页的端到端测试的主要活动是获取该页面的 DOM 元素,与之交互,为它们分配动作,并与它们共享信息;然后,用户可以获取网站的当前状态。为了使我们能够执行所有这些操作,Protractor 提供了一系列的 API(其中一些来自 web driver)。在本章中,我们将探讨一些常用 API。
在上一章中,我们看到了 Protractor 如何与 Angular 项目一起工作,其中我们必须与 UI 元素交互。为此,我们使用了几个 Protractor API,如 element.all、by.css、first、last 和 getText。然而,我们没有看到或深入理解这些 API 的工作原理。要理解 Protractor 中 API 的工作原理非常简单,但在现实生活中,我们大多数时候都必须与更大的、更复杂的项目一起工作。因此,了解并更多地了解这些 API 对于与 UI 交互和玩转其事件非常重要。
浏览器
Protractor 与 Selenium WebDriver 一起工作,这是一个浏览器自动化框架。我们可以使用 Selenium WebDriver API 中的方法从测试规范中与浏览器交互。我们将在以下部分查看其中的一些。
要在 Angular 加载之前将浏览器导航到特定的网页地址并加载该页面的模拟模块,我们将通过传递特定的地址或相对路径使用 .get() 方法:
browser.get(url);
browser.get('http://localhost:3000'); // This will navigate to
the localhost:3000 and will load mock module if needed
要获取当前页面的网页 URL,使用 CurrentUrl() 方法,如下所示:
browser.getCurrentUrl(); // will return http://localhost:3000
要导航到另一个页面并使用页面内导航浏览它,使用 setLocation,如下所示:
browser.setLocation('new-page'); // will change the url and navigate to the new url, as our current url was http://localhost:3000, now it will change and navigate to http://locahost:3000/#/new-page
要获取当前页面的标题(基本上,是设置在 HTML 页面中的标题),使用 getTitle 方法,如下所示:
browser.getTitle(); // will return the page title of our page, for us it will return us "Angular Protractor Debug"
要在 Angular 加载之前使用模拟模块重新加载当前页面,使用 refresh() 方法,如下所示:
browser.refresh(); // this will reload the full page and definitely will load the mocks module as well.
要暂停测试过程,使用 pause() 方法。这对于调试测试过程很有用,我们将在本测试调试部分使用它:
browser.pause();
要调试测试过程,使用 debugger() 方法。这种方法不同,可以被认为是 pause() 方法的进阶版本。这对于高级调试测试过程以及向浏览器中注入自定义辅助函数很有用。我们也将使用这个测试调试部分:
browser.debugger();
要关闭当前浏览器,使用 close()。这在复杂的多模块测试中很有用,有时我们需要在打开新浏览器之前关闭当前浏览器:
browser.close();
要在 Protractor 中支持 Angular,我们必须将 useAllAngularAppRoots 参数设置为 true。这样做背后的逻辑是,当我们将此参数设置为 true 时,它将在元素查找器遍历页面时搜索页面中的所有 Angular 应用:
browser.useAllAngular2AppRoots;
元素
小贴士
Protractor 本身暴露了一些全局函数,element 就是其中之一。这个函数接受一个定位器(一种选择器——我们将在下一步讨论它)并返回一个 ElementFinder。这个函数基本上基于定位器找到一个单一元素,但它支持多个元素选择,并可以通过 element.all 方法链式调用另一个方法,该方法也接受一个定位器并返回一个 ElementFinderArray。两者都支持链式调用以进行下一步操作。
元素.all
正如我们已经知道的,element.all返回一个ElementArrayFinder,它支持链式调用方法以进行下一步操作。我们将查看其中的一些以及它们是如何实际工作的:
要使用特定定位器选择多个元素作为数组,我们应该使用element.all,如下所示:
element.all(Locator);
var elementArr = element.all(by.css('.selector')); // return the ElementFinderArray
在获取了一组元素作为数组之后,我们可能需要选择特定元素。在这种情况下,我们应该通过传递特定的数组索引作为位置数字来链式调用get(position):
element.all(Locator).get(position);
elementArr.get(0); // will return first element from the ElementFinderArray
在获取了一组元素作为数组之后,我们可能需要再次使用首选定位器选择子元素,为此我们可以再次链式调用.all(locator)方法与现有元素,如下所示:
element.all(Locator).all(Locator);
elementArr.all(by.css('.childSelector')); // will return another ElementFinderArray as child elements based on child locator
在获取到所需的元素之后,我们可能想要检查所选元素的数量是否符合预期。有一个名为count()的方法用于链式调用以获取所选元素的总数:
element.all(Locator).count();
elementArr.count(); // will return the total number in the select element's array
与get(position)方法类似,我们可以通过链式调用first()方法从数组中获取第一个元素:
element.all(Locator).first();
elementArr.first(); // will return the first element from the element's array
与first()方法类似,我们可以通过链式调用last()方法从数组中获取最后一个元素:
element.all(Locator).last();
elementArr.last(); // will return the last element from the element array
只要我们有一组元素作为数组,我们可能需要遍历这些元素以执行任何操作。在这种情况下,我们可能需要通过链式调用each()方法来遍历一个循环:
element.all(Locator).each(Function) { };
elementArr.each( function (element, index) {
// ......
}); // ... will loop through out the array elements
就像each()方法一样,还有一个名为filter()的方法可以链式调用,用于遍历元素并给它们分配一个过滤器:
element.all(Locator).filter(Function) { };
elementArr.filter( function (element, index) {
// ......
}); //... will apply filter function's action to all elements
元素
element类返回ElementFinder,这意味着元素数组中的一个单个元素,这也支持链式调用方法以进行下一步操作。在之前的示例中,我们看到了如何从元素数组中获取单个所选元素,以便所有链式方法都可以在该单个元素上工作。有很多针对单个元素的操作链式方法,我们将查看其中最常用的几个。
通过将特定定位器作为参数传递给element方法,我们可以选择单个 DOM 元素,如下所示:
element(Locator);
var elementObj = element(by.css('.selector')); // return the ElementFinder based on locator
在获取到特定单个元素之后,我们可能需要找到我们必须要链式调用element.all方法的元素的子元素。为此,传递一个特定的定位器以找到子elementFinderArray,如下所示:
element(Locator).element.all(Locator);
elementObj.element.all(by.css('.childSelector')); // will return another ElementFinderArray as child elements based on child locator
在选择特定元素之后,我们可能需要检查在链式调用isPresent()方法时该元素是否存在,如下所示:
element(Locator).isPresent();
elementObj.isPresent(); // will return boolean if the selected element is exist or not.
行动
行动主要改变影响或触发所选 DOM 元素的方法。选择 DOM 元素的目标是通过触发一些行动与之交互,使其能够像真实用户一样行动。有一些常用的特定交互行动。我们在这里将查看其中的一些。
要获取任何元素的内部文本或包含的文本,我们必须在选择特定元素后链式调用getText()方法与elementFinder对象,如下所示:
element(Locator).getText();
var elementObj = element(by.css('.selector')); // return the ElementFinder based on locator
elementObj.getText(); // will return the contained text of that specific selected element
要获取任何元素的内部 HTML,我们必须在选定特定元素后,将getInnerHtml()方法与elementFinder对象链式调用,如下所示:
element.(Locator).getInnerHtml();
elementObj.getInnerHtml(); // will return the inner html of the selected element.
我们可以通过将属性键传递给getAttribute()方法来找到任何元素的任何特定属性值,这将与选定的elementFinder对象链式调用,如下所示:
element(Locator).getAttribute('attribute');
elementObj.getAttribute('data'); // will return the value of data attribute of that selected element if that have that attribute
在大多数情况下,我们需要清除输入字段的值。为此,我们可以将clear()方法与选定的elementFinder对象链式调用,如下所示:
element.(Locator).clear();
elementObj.clear(); // Guessing the elementFinder is input/textarea, and after calling this clear() it will clear the value and reset it.
小贴士
记住,只有输入或纹理可能具有一些值,需要你清除/重置其值。
当我们需要在按钮、链接或图像上触发点击事件时,在选定特定的elementFinder对象后,我们需要链式调用click()方法,它将像对该元素的实际点击一样操作:
element.(Locator).click();
elementObj.click(); // will trigger the click event as the selected element chaining it.
有时,我们可能需要触发submit()方法以提交表单。在这种情况下,我们必须将submit()方法与选定的元素链式调用。选定的元素应该是一个form元素:
element.(Locator).submit();
elementObj.submit(); // Will trigger the submit for the form
element as submit() work only for form element.
定位器
定位器通知 Protractor 如何在 DOM 元素中找到某个元素。Protractor 导出locator作为一个全局工厂函数,它将与全局by对象一起使用。我们可以根据我们的 DOM 以多种方式使用它们,但让我们看看一些最常用的方法。
我们可以通过传递任何 CSS 选择器到by.css方法来选择任何元素,如下所示:
element(by.css(cssSelector));
element.all(by.css(cssSelector));
<span class="selector"></span>
element.all(by.css('.selector')); // return the specific DOM element/elements that will have selector class on it
我们可以通过传递其元素 ID 到by.id方法来选择任何元素,如下所示:
element(by.id(id));
<span id="selectorID"></span>
element(by.id('selectorID')); // return the specific DOM element that will have selectorID as element id on it
我们也可以通过传递给by.tagName来选择特定的元素或元素,如下所示:
element(by.tagName(htmlTagName));
element.all(by.tagName(htmlTagName));
<span data="myData">Content</span>
element.all(by.tagName('span')); // will return the DOM element/elements of all span tag.
要选择任何特定输入字段的 DOM 元素,我们可以通过by.name方法传递其名称,如下所示:
element(by.name(elementName));
<input type="text" name="myInput">
element(by.name('myInput')); // will return the specific input field's DOM element that have name attr as myInput
除了 CSS 选择器或 ID 之外,我们还可以通过传递其文本标签到by.buttonText来选择特定的按钮:
<button name="myButton">Click Me</button>
element(by.buttonText('Click Me')); // will return the specific button that will have Click Me as label text
element(by.buttonText(textLabel));
我们可以通过传递定义在by.model上的模型名称来找到元素,如下所示:
element.(by.model);
<span ng-model="userName"></span>
element(by.model('userName')); // will return that specific element which have defined userName as model name
同样,我们可以通过在by.bindings中传递使用ng-bind定义的绑定来找到特定的 DOM 元素,如下所示:
element.(by.binding);
<span ng-bind="email"></span>
element(by.binding('email')); // will return the element that have email as bindings with ng-bind
除了前面解释的所有定位器之外,还有另一种找到特定 DOM 元素的方法:自定义定位器。在这里,我们必须使用by.addLocator通过传递定位器名称和回调来创建自定义定位器。然后,我们必须使用by.customLocatorName(args)传递该自定义定位器,如下所示:
element.(by.locatorName(args));
<button ng-click="someAction()">Click Me</button>
by.addLocator('customLocator', function(args) {
// .....
})
element(by. customLocator(args)); // will return the element that will match with the defined logic in the custom locator. This useful mostly when user need to select dynamic generated element.
Protractor 测试 - 剖析
调试端到端测试有点困难,因为它们依赖于应用程序的整个生态系统。有时它们依赖于先前的操作,如登录,有时它们依赖于权限。调试端到端测试的另一个主要障碍是其对 WebDriver 的依赖。由于它在不同的操作系统和浏览器中的行为不同,这使得调试端到端测试变得困难。除此之外,它生成长错误消息,这使得区分浏览器相关问题和测试过程错误变得困难。
尽管如此,我们仍将尝试调试所有 e2e 测试,看看这对我们的情况有何影响。
失败类型
只要测试套件依赖于 WebDriver 以及系统中的各个部分,就可能存在各种导致测试套件失败的原因。
让我们看看一些已知的失败类型:
-
WebDrive 失败:当命令无法完成时,WebDriver 会抛出错误。例如,浏览器无法获取定义的地址以帮助其导航,或者可能找不到预期的元素。
-
WebDriver 非预期失败:有时,当 WebDriver 失败并给出错误,无法更新 WebDriver 管理器时,WebDriver 会失败。这是一个与浏览器和操作系统相关的问题,尽管它并不常见。
-
Protractor Angular 失败:当在库中找不到预期的 Angular 时,Protractor 将会失败,因为 Protractor 测试依赖于 Angular 本身。
-
Protractor Angular2 失败:当配置中缺少
useAllAngular2AppRoots参数时,Protractor 将会失败,因为在这种情况下,测试过程将查看单个根元素,而期望过程中有更多元素。 -
Protractor 超时失败:有时,当测试规范陷入循环或长时间等待并无法及时返回数据时,Protractor 会因为超时而失败。然而,超时是可配置的,因此可以根据需要增加。
-
期望失败:这是测试规范中常见的失败类型。
加载现有项目
用于此测试的代码来自 第四章,使用 Protractor 进行端到端测试。我们将把代码复制到一个新目录:angular-protractor-debug。
作为提醒,该应用程序是一个待办事项应用程序,其中包含一些待办事项,我们向其中添加了一些项目。它有一个单独的组件类,AppComponent,其中包含一个项目列表和一个 add 方法。
当前目录的结构应该如下所示:

在验证文件夹结构与前面截图所示相同后,第一项任务是运行以下命令,在本地获取所需的依赖项,node_modules:
$ npm install
这将安装所有所需的模块。现在,让我们使用 npm 命令构建和运行项目:
$ npm start
现在应该一切正常了:项目应该在 http://localhost:3000 上运行,输出应该如下所示:

这样,我们就准备好继续在 Angular 项目中实现调试器的下一步了。
将调试器包含到项目中
在将调试器添加到我们的项目之前,让我们在我们的现有项目中运行 e2e 测试。我们希望 e2e 测试规范通过,没有任何失败。
让我们使用以下命令运行它:
$ npm run e2e
如预期的那样,我们的测试通过了。结果如下:

我们可以在通过测试规范相同的位置添加我们的调试代码,但让我们将通过的测试用例保持独立,并在不同的目录中与调试器互动。让我们创建一个新的目录,debug/。我们将在目录中需要两个文件:一个用于配置,另一个用于规范。
对于 Protractor 配置文件,我们可以复制 protractor.conf.js 文件并将其重命名为 debugConf.js。
配置中的所有内容都将与之前的配置相同。然而,我们需要增加 Jasmine 测试的默认超时时间,否则在调试过程中测试将超时。
让我们将超时时间增加到 3000000 毫秒:
exports.config = {
// ....
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 3000000
},
// .....
};
接下来,我们需要一个规范文件来编写测试规范和调试测试。将新的规范文件保存为 app.debug.e2e.ts。哦,是的,我们还需要再次更改配置文件以定义调试的规范文件。
exports.config = {
// ....
specs: [
'app.debug.e2e.js'
],
// .....
};
我们可以为 app.debug.e2e.ts 创建一个简单的测试规范文件。然后,我们可以添加调试代码并与之互动。
简单的测试规范如下:
describe('AppComponent Tests', () => {
beforeEach(() => {
browser.get('/');
});
it('Test spec for debug and play', () => {
});
});
暂停和调试
要调试任何测试规范,我们必须暂停测试过程并逐步查看发生了什么。Protractor 还内置了暂停过程的方法。以下是两种暂停和调试测试过程的方法:
-
browser.pause() -
browser.debugger()
使用暂停
使用 browser.pause() 命令,调试 Protractor 测试变得简单且直接。使用 pause() 方法,我们可以进入 Protractor 调试器的控制流并执行一些命令来检查测试控制流中的情况。通常,当测试因未知错误失败并且有长错误消息时,开发者会在测试中使用调试器。
使用 browser.pause() 命令后,我们可以根据需要使用更多命令。
让我们简要看看:
-
c: 如果我们输入c作为命令,它将在测试中向前移动一步,我们将看到测试命令是如何深入工作的。如果我们计划继续进行测试,最好快速操作,因为存在超时问题(Jasmine 默认超时),我们已经了解过。我们将在稍后看到一个示例。 -
repl: 通过输入repl作为命令,我们可以进入调试的交互模式。这被称为交互模式,因为从这里,我们可以通过输入 WebDriver 命令直接从终端与浏览器交互。来自浏览器的响应、结果或错误也会在终端上显示。我们将在稍后看到更多实际示例。 -
Ctrl + C: 按下 Ctrl + C 以退出暂停模式并继续测试。当我们使用这个命令时,测试将从暂停的地方继续进行。
一个快速示例
要在测试规范中使用 browser.pause(),我们必须将方法添加到我们想要暂停测试并观察控制流以进行调试的测试规范位置。这里我们只有一个包含错误/失败的测试用例的测试规范,我们知道它会失败,我们将找出它失败的原因。
我们必须将pause()方法,如图所示,添加到测试spec it() {}函数中:
it('Test spec for debug and play', () => {
browser.pause();
// There is not element with the id="my_id", so this will fail
the test
expect(element(by.id('my_id')).getText()).toEqual('my text')
});
是时候运行测试了。由于我们已经将调试器的测试规范分离出来,我们将通过 Protractor(不是npm)运行测试。
让我们使用以下命令运行测试:
$ protractor debug/debugConf.js
由于我们在expect()方法之前放置了browser.pause()方法,它将在这里暂停。我们可以在控制流中看到这让它等待 Angular:

我们将向前移动;为此,让我们输入C。它将运行executeAsyncScript并等待 Angular 加载:

我们将通过输入C再向前迈一步。它将尝试根据我们提供的定位器选择元素,即element(by.id('my_id'):

我们现在非常接近得到测试结果了。为此,我们必须通过输入C再向前迈一步。现在,它将尝试根据定位器选择元素,并且它将无法选择该元素。这将给出一个带有错误信息的预期结果:

交互模式调试
要进入交互模式,我们必须输入repl,之后我们可以在测试规范中运行任何命令。
让我们找到元素及其文本:
> element(by.id('my_id')).getText()
结果与我们之前通过逐步前进,输入C得到的结果相同。
结果:NoSuchElementError: No element found using locator: By (css selector, *[id="my_id"])
现在,让我们看看对于有效的定位器,交互模式是如何工作的,当element将被找到时:
> element.all(by.css('li')).first().getText()
结果:test
使用调试器
使用browser.debugger()命令进行调试稍微复杂一些,比使用browser.pause()命令更高级。使用browser.pause()命令,我们可以暂停测试的控制流,并将自定义辅助函数注入到浏览器中,这样调试就会像我们在浏览器控制台中调试一样进行。
这种调试应该在调试模式下的 node 中,就像在这里的 Protractor 调试中。这种调试对于不擅长 node 调试的人来说没有用。
这里有一个例子:
要在测试规范中使用browser.debugger()方法,我们必须在想要设置断点和监视控制流的点添加该方法。
对于我们来说,我们必须将debugger()方法添加到test spec it() {}函数中,这将是我们断点:
it('Test spec for debug and play', () => {
browser.debugger();
// There is not element with the id="my_id", so this will fail
the test
expect(element(by.id('my_id')).getText()).toEqual('my text')
});
现在让我们运行它:
$ protractor debug debug/debugConf.js
注意
要运行调试器,我们必须在protractor命令后添加debug:
运行命令后,我们必须通过输入C来向前移动,但在这里我们只需要做一次。输出如下:

自我测试问题
Q1. Selenium WebDriver 是一个浏览器自动化框架。
-
True
-
False
Q2. 使用browser.debugger()是调试 Protractor 的简单方法。
-
True
-
False
Q3. by.css(), by.id(), 和 by.buttonText() 被称为什么?
-
元素
-
定位器
-
操作
-
浏览器
摘要
Protractor 拥有各种类型的 API。在本章中,我们尝试通过一些示例理解一些最常用的 API。我们还详细介绍了 API 类型(如 browser、elements、locator 和 actions),以及它们是如何相互连接的。
在本章中,我们介绍了调试,并尝试学习使用browser.pause()的简单调试方法,进行了更详细的介绍,然后我们转向了复杂的方法(browser.debugger()),并了解到复杂的开发者需要具备 node 调试器的经验。
在下一章中,我们将深入探讨更多真实世界的项目;进一步地,我们将学习自顶向下和自底向上的方法,并掌握它们。
第六章。第一步
第一步总是最困难的。本章提供了一个使用组件、类和模型通过 TDD 构建 Angular 应用程序的初步介绍性概述。我们将能够开始 TDD 之旅,并看到基础原理的实际应用。到目前为止,这本书一直专注于 TDD 的基础和所需的工具。现在,我们将换挡,深入 Angular 的 TDD。
本章将是 TDD 的第一步。我们已经看到了如何安装 Karma 和 Protractor,以及一些小例子和如何应用它们的概述。在本章中,我们将专注于:
-
创建一个简单的评论应用程序
-
将 Karma 和 Protractor 集成到应用程序中
-
涵盖测试组件及其相关类
准备应用程序的规范
创建一个用于输入评论的应用程序。该应用程序的规范如下:
-
如果我在发表新评论时,点击 提交 按钮,评论应该被添加到评论列表中
-
对于评论,当我点击 点赞 按钮时,评论的点赞数应该增加
现在我们有了应用程序的规范,我们可以创建我们的开发待办事项列表。创建整个应用程序的待办事项列表并不容易。根据用户规范,我们有一个关于需要开发什么内容的想法。以下是 UI 的初步草图:

在跳入实现并思考我们将如何使用组件类、*ngFor 等之前,先忍住。忍住,忍住,忍住!虽然我们可以想象未来会如何开发,但直到我们深入研究代码,这永远是不清晰的,而且那正是我们将开始遇到麻烦的地方。TDD 及其原则就在这里帮助我们把思想和注意力放在正确的位置。
设置 Angular 项目
在前面的章节中,我们详细讨论了如何设置项目,查看涉及的不同组件,并走过了整个测试过程。我们将跳过这些细节,并在下一节提供一个列表,列出初始操作以设置项目并准备好单元测试和端到端测试的配置。
加载现有项目
我们将从 Angular 团队的示例中获取一个简单的 Angular 项目,并对其进行修改以适应我们的实现。
我们将从 Angular GitHub 仓库克隆 quickstart 项目,并从这个项目开始。除了 node/npm 之外,我们还应该全局安装 Git。
$ git clone https://github.com/angular/quickstart.git
angular-project
这将把项目复制到本地,命名为 angular-project;这个项目可能包含一些额外的文件(它们可能会持续更新),但我们将努力保持我们的项目文件夹结构如下:

我们最初会保持简单,然后逐步添加我们需要的文件。这将使我们更有信心。
让我们继续进行并运行以下命令:
$ cd angular-project
$ npm install
npm install 命令将安装项目根目录中 package.json 文件中定义的项目依赖所需的模块。
设置目录
在之前的示例中,我们只是为了保持简单,将组件、单元测试规范和端到端测试规范放在同一个文件夹中。对于更大的项目,将所有内容放在同一个文件夹中很难管理。
为了便于管理,我们将测试规范放在一个单独的文件夹中。在这里,我们的示例 quickstart 项目已经将测试规范放在了默认文件夹中,但我们将有一个新的结构,并将测试文件放在新的结构中。
让我们开始设置项目目录:
-
导航到项目根目录:
cd angular-project -
初始化测试(
spec)目录:mkdir spec -
初始化
unit测试目录:mkdir spec/unit -
初始化端到端(
e2e)测试目录:mkdir spec/e2e
初始化完成后,我们的文件夹结构应该如下所示:

设置 Karma
关于 Karma 的详细信息可以在 第三章 Karma 方式 中找到。在这里,我们将主要查看 Karma 配置文件。
在这个 quickstart 项目中,我们已安装并配置了 Karma,并在项目目录中拥有 karma.conf.js 文件。
为了确认系统中已安装 Karma,让我们使用以下命令全局安装它:
npm install -g karma
如前所述,我们已经在 quickstart 项目中配置了 Karma,作为项目的一部分,并在项目目录中拥有 karma.conf.js 文件。
现在,我们将查看一些每个人都应该知道的基本配置选项。在这个配置文件中,有一些高级选项,如测试报告和错误报告。我们将跳过这些,因为它们在这个初始阶段并不是很重要。
让我们更详细地了解一些我们将需要进一步进行的配置。
当我们在服务器中有项目的自定义路径时,应该更新 basePath。目前它是 '',因为这个项目是在根路径上运行的。下一个选项是 frameworks;默认情况下,我们在这里使用 jasmine,但如果我们想使用其他框架,如 mocha,我们可以更改框架名称。需要记住的一点是,如果我们计划使用不同的框架,我们必须添加相关的插件。
basePath: '',
frameworks: ['jasmine'],
需要插件,因为 Karma 将使用这些 npm 模块来执行操作;例如,如果我们计划使用 PhantomJS 作为浏览器,我们需要将 'karma-phantomjs-launcher' 添加到列表中:
plugins: [
'karma-jasmine',
'karma-chrome-launcher'
]
下一个最重要的选项是 files[];通过这个选项,Karma 将包含所有测试所需的文件。它根据依赖关系加载文件。我们将所有所需的文件放在 files[] 数组中。
首先,我们将添加 System.js,因为我们在这个应用程序中使用 systemjs 作为模块加载器。然后,添加 polyfills 以支持所有浏览器的 shim,zone.js 以支持应用程序中的异步操作,RxJS 作为响应式库,Angular 库文件,Karma 测试的 shim,组件文件,最后是测试规范。列表中可能还有一些其他文件用于调试和报告;我们将跳过它们的解释。
这就是我们的 files[] 数组看起来像:
files: [
// System.js for module loading
'node_modules/systemjs/dist/system.src.js',
// Polyfills
'node_modules/core-js/client/shim.js',
'node_modules/reflect-metadata/Reflect.js',
// zone.js
'node_modules/zone.js/dist/zone.js',
'node_modules/zone.js/dist/long-stack-trace-zone.js',
'node_modules/zone.js/dist/proxy.js',
'node_modules/zone.js/dist/sync-test.js',
'node_modules/zone.js/dist/jasmine-patch.js',
'node_modules/zone.js/dist/async-test.js',
'node_modules/zone.js/dist/fake-async-test.js',
// RxJs
{ pattern: 'node_modules/rxjs/**/*.js', included: false,
watched: false },
{ pattern: 'node_modules/rxjs/**/*.js.map', included:
false, watched: false },
// Paths loaded via module imports:
// Angular itself
{ pattern: 'node_modules/@angular/**/*.js', included:
false, watched: false },
{ pattern: 'node_modules/@angular/**/*.js.map', included:
false, watched: false },
{ pattern: 'systemjs.config.js', included: false, watched:
false },
{ pattern: 'systemjs.config.extras.js', included: false,
watched: false },
'karma-test-shim.js',
// transpiled application & spec code paths loaded via
module imports
{ pattern: appBase + '**/*.js', included: false, watched:
true },
{ pattern: testBase + '**/*.spec.js', included: false,
watched: true },
],
目前我们只需要在 karma.conf 文件中了解这些。如果需要,我们将通过更新这些设置来继续。
让我们看一下完整的 karma.conf.js 文件:
module.exports = function(config) {
var appBase = 'app/'; // transpiled app JS and map files
var appSrcBase = 'app/'; // app source TS files
var appAssets = 'base/app/'; // component assets fetched by
Angular's compiler
var testBase = 'spec/unit/'; // transpiled test JS and map
files
var testSrcBase = 'spec/unit/'; // test source TS files
config.set({
basePath: '',
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'), // click "Debug" in
browser to see it
require('karma-htmlfile-reporter') // crashing w/ strange
socket error
],
customLaunchers: {
// From the CLI. Not used here but interesting
// chrome setup for travis CI using chromium
Chrome_travis_ci: {
base: 'Chrome',
flags: ['--no-sandbox']
}
},
files: [
// System.js for module loading
'node_modules/systemjs/dist/system.src.js',
// Polyfills
'node_modules/core-js/client/shim.js',
'node_modules/reflect-metadata/Reflect.js',
// zone.js
'node_modules/zone.js/dist/zone.js',
'node_modules/zone.js/dist/long-stack-trace-zone.js',
'node_modules/zone.js/dist/proxy.js',
'node_modules/zone.js/dist/sync-test.js',
'node_modules/zone.js/dist/jasmine-patch.js',
'node_modules/zone.js/dist/async-test.js',
'node_modules/zone.js/dist/fake-async-test.js',
// RxJs
{ pattern: 'node_modules/rxjs/**/*.js', included: false,
watched: false },
{ pattern: 'node_modules/rxjs/**/*.js.map', included: false,
watched: false },
// Paths loaded via module imports:
// Angular itself
{ pattern: 'node_modules/@angular/**/*.js', included: false,
watched: false },
{ pattern: 'node_modules/@angular/**/*.js.map', included:
false, watched: false },
{ pattern: 'systemjs.config.js', included: false, watched:
false },
{ pattern: 'systemjs.config.extras.js', included: false,
watched: false },
'karma-test-shim.js',
// transpiled application & spec code paths loaded via module
imports
{ pattern: appBase + '**/*.js', included: false, watched: true
},
{ pattern: testBase + '**/*.spec.js', included: false, watched:
true },
// Asset (HTML & CSS) paths loaded via Angular's component
compiler
// (these paths need to be rewritten, see proxies section)
{ pattern: appBase + '**/*.html', included: false, watched: true
},
{ pattern: appBase + '**/*.css', included: false, watched: true
},
// Paths for debugging with source maps in dev tools
{ pattern: appSrcBase + '**/*.ts', included: false, watched:
false },
{ pattern: appBase + '**/*.js.map', included: false, watched:
false },
{ pattern: testSrcBase + '**/*.ts', included: false, watched:
false },
{ pattern: testBase + '**/*.js.map', included: false, watched:
false }
],
// Proxied base paths for loading assets
proxies: {
// required for component assets fetched by Angular's compiler
"/app/": appAssets
},
exclude: [],
preprocessors: {},
// disabled HtmlReporter; suddenly crashing w/ strange socket
error
reporters: ['progress', 'kjhtml'],//'html'],
// HtmlReporter configuration
htmlReporter: {
// Open this file to see results in browser
outputFile: '_test-output/tests.html',
// Optional
pageTitle: 'Unit Tests',
subPageTitle: __dirname
},
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: true
})
};
测试目录已更新
我们在第三章中看到了 karma-test-shim.js 的详细内容,Karma 方式。这是通过 Karma 运行单元测试所需的。
我们已更改测试规范目录/位置,并基于项目的默认结构配置了 karma-test-shim.js。因为我们已经将测试移动到不同的位置并移出了 app/ 文件夹,所以我们需要相应地更新 karma-test-shim.js。
这里是需要进行的更改:
var builtPath = '/base/';
设置 Protractor
在第四章中,我们讨论了 Protractor 的完整安装和设置。这个示例应用程序已经安装并配置了 Protractor。因此,我们只需查看 protractor.conf.js 文件。
这个配置的 Protractor 实例实现了测试报告。我们将跳过配置文件中的这些部分,只查看常见的设置选项。
在我们进入配置文件概述之前,为了确保,我们将在系统上全局安装 Protractor:
$ npm install -g protractor
更新 Selenium WebDriver:
$ webdriver-manager update
我们必须确保 Selenium 已安装。
如预期的那样,protractor.conf.js 文件位于应用程序的根目录。以下是 protractor.conf.js 文件的完整配置:
var fs = require('fs');
var path = require('canonical-path');
var _ = require('lodash');
exports.config = {
directConnect: true,
// Capabilities to be passed to the webdriver instance.
capabilities: {
'browserName': 'chrome'
},
// Framework to use. Jasmine is recommended.
framework: 'jasmine',
// Spec patterns are relative to this config file
specs: ['**/*e2e-spec.js' ],
// For angular tests
useAllAngular2AppRoots: true,
// Base URL for application server
baseUrl: 'http://localhost:8080',
// doesn't seem to work.
// resultJsonOutputFile: "foo.json",
onPrepare: function() {
//// SpecReporter
//var SpecReporter = require('jasmine-spec-reporter');
//jasmine.getEnv().addReporter(new
SpecReporter({displayStacktrace: 'none'}));
//// jasmine.getEnv().addReporter(new SpecReporter({
displayStacktrace: 'all'}));
// debugging
// console.log('browser.params:' +
JSON.stringify(browser.params));
jasmine.getEnv().addReporter(new Reporter( browser.params )) ;
// Allow changing bootstrap mode to NG1 for upgrade tests
global.setProtractorToNg1Mode = function() {
browser.useAllAngular2AppRoots = false;
browser.rootEl = 'body';
};
},
jasmineNodeOpts: {
// defaultTimeoutInterval: 60000,
defaultTimeoutInterval: 10000,
showTiming: true,
print: function() {}
}
};
自顶向下与自底向上方法 - 我们使用哪一个?
从开发的角度来看,我们必须确定从哪里开始。本书中我们将讨论的方法如下:
-
自底向上的方法:采用这种方法,我们考虑我们需要的不同组件(类、服务、模块等),然后选择最合理的一个并开始编码。
-
自顶向下的方法:采用这种方法,我们从用户场景和 UI 开始工作。然后,我们围绕应用程序中的组件创建应用程序。
这两种方法都有优点,选择可以基于你的团队、现有组件、需求等。在大多数情况下,最好根据最小阻力原则来做出选择。
在本章中,规范的采用自顶向下的方法;从用户场景开始,所有内容都为你准备好了,这将允许你围绕 UI 有机地构建应用程序。
测试组件
在深入到规格和即将交付的功能的心态之前,了解测试组件类的基本原理非常重要。在 Angular 中,组件是一个在大多数应用程序中使用的核心功能。
准备出发
我们的示例应用程序(quickstart)有一些非常基本的单元和端到端测试规格。我们将从开始就采用 TDD 方法,因此我们不会在我们的实现中使用任何测试规格和现有组件的代码。
为了做到这一点,我们可以清理这个示例应用程序,我们只保留文件夹结构和应用程序引导文件。
因此,首先,我们必须删除单元测试文件(app.component.spec.ts)和端到端测试文件(app.e2e-spec.ts)。这是应用程序结构中存在的两个测试规格。
设置简单的组件测试
当测试组件时,将组件注入测试套件并作为第二项任务初始化组件类非常重要。测试确认组件范围内的对象或方法是否按预期可用。
要在测试套件中拥有组件实例,我们将在测试套件中使用简单的import语句,并在beforeEach方法中初始化组件对象,这样我们就可以为每个测试规格创建一个新的组件对象。以下是一个示例,展示这将如何看起来:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {AppComponent} from "../../app.component";
describe('AppComponent Tests Suite', () => {
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AppComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
});
因此,只要为每个测试规格初始化组件类,它将为每个规格提供一个新实例,内部作用域将根据这一点进行操作。
初始化组件
要测试一个组件,初始化组件类非常重要,这样我们就可以在测试套件的范围内获得组件对象,并且该对象的全部成员在特定的测试套件中都是可用的。
只要组件包含用于渲染 UI 的模板,在开始端到端测试之前初始化组件非常重要,并且它依赖于 DOM 元素。
因此,当我们计划对任何组件进行端到端测试时,我们应该在 DOM 中初始化它,如下所示:
<body>
<my-app></my-app>
</body>
组件的端到端测试与单元测试的比较
在前面的示例中,我们查看的是组件测试套件,它是用于单元测试的,我们必须导入并创建组件类的实例作为单元测试。我们将测试组件中定义的每个方法的函数或特性。
另一方面,对于端到端测试,我们不需要导入或创建组件类的实例,因为我们不需要与组件对象或其所有成员进行注释。相反,它需要与应用程序运行页面的 DOM 元素交互。
因此,我们需要运行应用程序并导航测试套件到应用程序的着陆页,我们可以使用 Protractor 本身提供的全局browser对象来完成这一点。
下面是一个示例,展示它应该看起来像什么:
import { browser, element, by } from 'protractor';
describe('Test suite for e2e test', () => {
beforeEach(() => {
browser.get('');
});
});
我们可以使用browser.get('path')按需导航到应用程序的所有 URL。
深入我们的评论应用程序
现在设置和方案已经确定,我们可以开始我们的第一个测试。从测试的角度来看,因为我们将会使用自顶向下的方法,我们将首先编写 Protractor 测试,然后构建应用程序。我们将遵循我们已经审查过的相同 TDD 生命周期:先测试,然后运行,最后改进。
先测试
给定的场景已经以良好的格式指定,并且适合我们的 Protractor 测试模板:
describe('', () => {
describe('', () => {
beforeEach(() => {
});
it('', () => {
});
});
});
将场景放入模板中,我们得到以下代码:
describe('Given I am posting a new comment', () => {
describe('When I push the submit button', () => {
beforeEach(() => {
// ...
});
it('Should then add the comment', () => {
// ...
});
});
});
按照三个 A(组装、行动、断言),我们将用户场景适配到模板中。
组装
浏览器需要指向应用程序的第一页。因为基本 URL 已经定义,我们可以在测试中添加以下内容:
beforeEach(() => {
browser.get('');
});
现在测试已经准备就绪,我们可以进行下一步:行动。
行动
根据用户规格,下一步我们需要做的是添加一个实际的评论。最简单的方法是将一些文本放入输入框中。对于这个测试,同样不知道元素将被命名为什么或它将做什么,我们可以根据它应该是什么来编写它。
这是为应用程序添加评论部分的代码:
beforeEach(() => {
...
var commentInput = element(by.css('input'));
commentInput.sendKeys('a sample comment');
});
最后一个组装组件,作为测试的一部分,是点击提交按钮。这可以通过 Protractor 中的click函数轻松实现。即使我们没有页面,或者任何属性,我们仍然可以命名将要创建的按钮:
beforeEach(() => {
...
var submitButton = element(by.buttonText('Submit')).click();
});
最后,我们将触及测试的核心,并断言用户的期望。
断言
用户期望的是,一旦点击提交按钮,评论就会被添加。这有点模糊,但我们可以确定用户需要以某种方式被告知评论已被添加。
最简单的方法是在页面上显示所有评论。在 Angular 中,最简单的方法是添加一个显示所有评论的*ngFor对象。为了测试这一点,我们将添加以下内容:
it('Should then add the comment', () => {
var comment = element.all(by.css('li')).first();
expect(comment.getText()).toBe('a sample comment');
});
现在测试已经构建并符合用户规格。它既小又简洁。以下是完成后的测试:
describe('Given I am posting a new comment', () => {
describe('When I push the submit button', () => {
beforeEach(() => {
//Assemble
browser.get('');
var commentInput = element(by.css('input'));
commentInput.sendKeys('a sample comment');
//Act
var submitButton = element(by.buttonText
('Submit')).click();
});
//Assert
it('Should then add the comment', () => {
var comment = element.all(by.css('li')).first();
expect(comment.getText()).toBe('a sample comment');
});
});
});
让它运行
根据错误和测试输出,我们将逐步构建我们的应用程序。
使用以下命令启动网络服务器:
$ npm start
运行 Protractor 测试以查看第一个错误:
$ protractor
或者,我们可以运行以下命令:
$ npm run e2e // run via npm
我们可能遇到的第一个错误是它没有获取定位器期望的元素:
$ Error: Failed: No element found using locator:
By(css selector, input)
错误的原因很简单:它没有获取定位器中定义的元素。我们可以看到当前应用程序以及为什么它没有获取到元素。
回顾当前应用程序
只要我们将样本 Angular quickstart项目克隆为我们测试的应用程序,它就有一个现成的 Angular 环境。它使用定义了My First Angular 2 App作为输出的简单应用程序组件引导 Angular 项目。
因此,在我们的 TDD 方法中,我们不应该有任何环境/Angular 引导相关的错误,而且看起来我们正在正确的道路上。
让我们看看我们现在在示例应用程序中的情况。在我们的着陆页index.html中,我们已经包含了所有必需的库文件并实现了system.js来加载应用程序文件。
在index.html文件中的<body>标签中,我们如下启动了应用程序:
<body>
<my-app>Loading...</my-app>
</body>
HTML 标签期望一个具有my-app作为选择器的组件,是的,我们有一个作为app.component.ts,如下所示:
import {Component} from '@angular/core';
@Component({
selector: 'my-app',
template: '<h1>My First Angular 2 App</h1>'
})
export class AppComponent { }
Angular 引入了ngModule作为appModule来模块化和管理每个组件的依赖关系。使用这个appModule,一个应用程序可以一目了然地定义所有必需的依赖关系。除此之外,它还帮助懒加载模块。我们将在 Angular 文档中查看ngModule的详细信息。
它导入了应用程序中所有必需的模块,从单个入口点声明所有模块,并定义了引导组件。
应用程序始终根据此文件的配置引导。
该文件位于应用程序根目录下的app.module.ts,如下所示:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
imports: [ BrowserModule],
declarations: [ AppComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
应用程序的入口点是main.ts文件,它将导入appModule文件并指示根据该文件引导应用程序:
import { platformBrowserDynamic } from '@angular/platform
-browser-dynamic';
import { AppModule } from './app.module';
platformBrowserDynamic().bootstrapModule(AppModule);
测试找不到我们的输入定位器。我们需要将输入添加到页面上,并且我们需要通过组件的模板来完成。
添加输入
我们需要遵循以下步骤将输入添加到页面上:
-
我们将不得不向应用程序组件的模板中添加一个简单的
input标签,如下所示:template: ` <input type='text' />` -
再次运行测试后,似乎没有更多与输入定位器相关的错误,但有一个新的错误,因为
button标签缺失:$ Error: Failed: No element found using locator: by.buttonText('Submit') -
就像之前的错误一样,我们需要在模板中添加一个带有适当文本的
button:template: ` ........... <button type='button'>Submit</button>` -
再次运行测试后,似乎没有更多与
button定位器相关的错误,但再次出现新的错误,因为重复器定位器缺失:$ Error: Failed: No element found using locator: By (css selector, li)
这似乎是我们假设提交的评论将通过*ngFor在页面上可用。为了将此添加到页面上,我们将使用组件类中的方法来提供重复器的数据。
组件
如前所述,错误发生是因为没有comments对象。为了添加comments对象,我们将使用在其作用域中有一个comments数组组件类。
执行以下步骤以将comments对象添加到作用域:
-
由于我们已经在组件中有一个
AppComponent类,我们需要定义评论数组,我们可以使用它在一个重复器中:export class AppComponent { comments:Array<string>; } -
然后,我们将在模板中添加一个用于评论的重复器,如下所示:
template: `.......... <ul> <li *ngFor="let comment of comments">{{comment}}</li> </ul>` -
让我们运行 Protractor 测试,看看我们现在在哪里:
$ Error: Failed: No element found using locator: By(css selector, li)
哎呀!我们还在得到相同的错误。然而,别担心;可能还有其他问题。
让我们看看实际渲染的页面,看看发生了什么。在 Chrome 中,导航到http://localhost:3000并打开控制台以查看页面源代码(Ctrl + Shift + J)。注意,重复器和组件都在那里;然而,重复器被注释掉了。由于 Protractor 只查看可见元素,它不会找到列表。
太好了!现在我们知道为什么重复列表不可见,但我们必须修复它。为了让注释显示出来,它必须存在于组件的comments作用域中。
最小的更改是向数组添加一些内容以初始化它,如下面的代码片段所示:
export class AppComponent {
comments:Array<string>;
constructor() {
this.comments = ['First comment', 'Second comment',
'Third comment'];
}
};
现在,如果我们运行测试,我们会得到以下输出:
$ Expected 'First comment' to be 'a sample comment'.
太好了,看起来我们越来越接近了,错误已经减少!我们几乎解决了所有意外错误,并达到了我们的预期。
因此,让我们看看我们迄今为止所做的更改以及我们的代码看起来像什么。
这是index.html文件的body标签:
<body>
<my-app>Loading...</my-app>
</body>
应用组件文件如下:
import {Component} from '@angular/core';
@Component({
selector: 'my-app',
template: `<h1>My First Angular 2 App</h1>
<input type='text' />
<button type='button'>Submit</button>
<ul>
<li *ngFor="let comment of comments">{{comment}}</li>
</ul>`
})
export class AppComponent {
comments:Array<string>;
constructor() {
this.comments = ['First comment', 'Second comment',
'Third comment'];
}
}
使其通过
使用 TDD,我们希望添加最小的组件来使测试通过。
由于我们目前将注释数组硬编码为初始化为三个项目,第一个项目为First comment,将First comment更改为一个示例注释;这应该会使测试通过。
这是使测试通过的代码:
export class AppComponent {
comments:Array<string>;
constructor() {
this.comments = ['a sample comment', 'Second comment',
'Third comment'];
}
};
运行测试,然后!我们得到了一个通过测试:
$ 1 test, 1 assertion, 0 failures
等一下!我们还有一些工作要做。虽然我们通过了测试,但这还没有完成。我们只是添加了一些黑客手段来让它通过。有两件事很突出:
-
我们点击了提交按钮,但实际上它没有任何功能
-
我们硬编码了注释预期值的初始化
上述更改是我们需要在我们继续前进之前执行的临界步骤。它们将在 TDD 生命周期的下一阶段解决,即改进(重构)。
改进
需要重做的两个组件如下:
-
为提交按钮添加行为
-
移除注释的硬编码值
实现提交按钮
提交按钮实际上需要做一些事情。我们通过仅硬编码值来规避了实现。使用我们经过验证和可靠的 TDD 技术,转向关注单元测试的方法。到目前为止,重点一直在于 UI 和向代码中推送更改;我们还没有编写任何单元测试。
对于接下来的工作,我们将转换方向,专注于通过测试驱动开发提交按钮。我们将遵循 TDD 生命周期(先测试,然后运行,最后改进)。
配置 Karma
我们在第三章的待办事项列表应用程序中做了类似的事情,因果之道。我们不会花太多时间深入代码,所以请回顾前面的章节,以深入了解一些属性。
我们需要遵循以下步骤来配置 Karma:
-
更新
files部分以包含添加的文件:files: [ ... // Application files {pattern: 'app/**/*.js', included: false, watched: true} // Unit Test spec files {pattern: 'spec/unit/**/*.spec.js', included: false, watched: true} ... ], -
启动 Karma:
$ karma start -
确认 Karma 正在运行:
$ Chrome 50.0.2661 (Mac OS X 10.10.5): Executed 0 of 0 SUCCESS (0.003 secs / 0 secs)
先进行测试
让我们从spec/unit文件夹中的新文件开始,命名为app.component.spec.ts。这将包含单元测试的测试规范。我们将使用基础模板,包括所有必要的导入,如TestBed:
describe('', () => {
beforeEach(() => {
});
it('', () => {
});
});
根据规范,当点击提交按钮时,需要添加一个注释。我们需要填写测试的三个组成部分的空白(组装、行动和断言)。
组装
该行为需要成为组件的一部分,以便前端可以使用它。在这种情况下,测试的对象是组件的作用域。我们需要将此添加到本测试的组装中。像我们在第三章中做的那样,Karma 之道,我们将在以下代码中做同样的事情:
import {AppComponent} from "../../app/app.component";
describe('AppComponent Unit Test', () => {
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(() => { fixture = TestBed.create
Component(AppComponent);
comp = fixture.componentInstance;
});
});
现在,组件对象及其成员在测试套件中可用,并将按预期可测试。
行动
规范确定我们需要在组件对象中调用一个add方法。向测试的beforeEach部分添加以下代码:
beforeEach(() => { comp.add('a sample comment');
});
现在,断言应该获取第一个注释进行测试。
断言
断言component对象中的注释项现在包含任何注释作为第一个元素。向测试中添加以下代码:
it('',function(){
expect(com.comments[0]).toBe('a sample comment');
});
保存文件,让我们继续到生命周期中的下一个步骤,让它运行(执行)。
让它运行
现在我们已经准备好了测试,我们需要让测试通过。查看 Karma 运行的控制台输出,我们看到以下内容:
$ TypeError: com.add is not a function
查看我们的单元测试,我们看到这是add函数。让我们继续按照以下步骤在控制器的scope对象中添加一个add函数:
-
打开控制器作用域并创建一个名为
add的函数:export class AppComponent { ............. add() { // .... } } -
检查 Karma 的输出,看看我们现在在哪里:
$ Expected 'First comment' to be 'a sample comment'. -
现在,我们已经达到了预期。记得考虑最小的改动以使其工作。修改
add函数,在调用时将$scope.comments数组设置为任何注释:export class AppComponent { ............. add() { this.comments.unshift('a sample comment'); } };注意
unshift函数是一个标准的 JavaScript 函数,它将一个项目添加到数组的开头。
当我们检查 Karma 的输出时,我们会看到以下内容:
$ Chrome 50.0.2661 (Mac OS X 10.10.5): Executed 1 of 1
SUCCESS (0.008 secs / 0.002 secs)
成功!测试通过了,但仍然需要一些工作。让我们继续到下一个阶段,让它变得更好(重构)。
让它变得更好
需要重构的主要点是add函数。它不接受任何参数!这应该很容易添加,并且只是确认测试仍然运行。更新app.component.ts中的add函数,使其接受一个参数并使用该参数将内容添加到comments数组中:
export class AppComponent {
.............
add(comment) {
this.comments.unshift(comment);
}
};
检查 Karma 的输出窗口,确保测试仍然通过。完整的单元测试如下所示:
import {AppComponent} from "../../app/app.component";
describe('AppComponent Tests', () => {
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
comp.add('a sample comment');
});
it('First item inthe item should match', () => {
expect(com.comments[0]).toBe('a sample comment');
});
});
AppComponent类文件现在看起来是这样的:
import {Component} from '@angular/core';
@Component({
selector: 'my-app',
template: `<h1>My First Angular 2 App</h1>
<input type='text' />
<button type='button'>Submit</button>
<ul>
<li *ngFor="let comment of comments">{{comment}}</li>
</ul>`
})
export class AppComponent {
comments:Array<string>;
constructor() {
this.comments = ['First comment', 'Second comment',
'Third comment'];
}
add(comment) {
this.comments.unshift(comment);
}
}
备份测试链
我们完成了单元测试并添加了 add 函数。现在我们可以添加函数来指定提交按钮的行为。将 add 方法链接到按钮的方式是使用 (click) 事件。将行为添加到提交按钮的步骤如下:
-
打开
app.component.ts文件并按以下方式更新:@Component({ template: `....... <button type="button" (click)="add('a sample comment')">Submit</button> ...........` })等一下!这个值是硬编码的吗?嗯,再次,我们想要做出最小的改变并确保测试仍然通过。我们将通过我们的重构直到代码达到我们想要的样子,但不是采用大爆炸方法,我们想要做出小的、渐进的改变。
-
现在,让我们重新运行 Protractor 测试并确保它仍然通过。输出显示它通过了,我们没问题。硬编码的值没有被从注释中移除。让我们现在就移除它。
-
AppComponent类文件现在应该看起来如下:constructor() { this.comments = []; } -
运行测试并查看我们是否仍然得到一个通过测试。
我们最后需要清理的是 (click) 中的硬编码值。被添加的注释应该由注释输入文本中的输入决定。
绑定输入
这里是我们需要遵循的步骤来绑定输入:
-
为了能够将输入绑定到有意义的东西,向
input标签添加一个ngModel属性:@Component({ template: `............. <input type="text" [(ngModel)]="newComment"> ...........` }) -
然后,在
(click)属性中,简单地使用newComment模型作为输入:@Component({ template: `....... <button type="button" (click)="add(newComment)"> Submit</button> ...........` }) -
我们将不得不在应用模块 (
app.module.ts) 中导入表单模块,因为它是ngModel的依赖项:import { FormsModule } from '@angular/forms'; @NgModule({ imports: [ BrowserModule, FormsModule ], }) -
运行 Protractor 测试并确认一切通过并且可以继续。
向上走
现在我们已经让第一个规范工作,并且它是端到端和单元测试的,我们可以开始下一个规范。下一个规范表明用户想要点赞评论的能力。
我们将使用自顶向下的方法,并从 Protractor 开始我们的测试。我们将继续遵循 TDD 生命周期:先测试,然后运行,然后改进。
先测试
按照模式,我们将从一个基本的 Protractor 测试模板开始:
describe('', () => {
beforeEach(() => {
});
it('', () => {
});
});
当我们填写规范时,我们得到以下内容:
describe('When I like a comment', () => {
beforeEach(() => {
});
it('should then be liked', () => {
});
});
在模板就位后,我们准备好构建测试。
组装
这个测试的组装需要存在一个注释。将注释放置在现有的已发布注释测试中。它应该看起来像这样:
describe(''Given I am posting a new comment', () => {
describe('When I like a comment', () => {
...
});
});
行动
我们测试的用户规范是点赞按钮对特定评论执行一个动作。以下是执行这些步骤所需的步骤和代码(注意以下步骤将添加到 beforeEach 文本中):
-
存储第一个评论以便在测试中使用:
var firstComment = null; beforeEach(() => { ... } -
找到第一个评论的
likeButton:var firstComment = element.all(by.css('li').first(); var likeButton = firstComment.element(by.buttonText('like')); -
当点击点赞按钮时的代码如下:
likeButton.click();
断言
规范期望是,一旦注释被点赞,它就被点赞了。这最好通过放置点赞数量的指示器并确保计数为 1 来完成。代码将如下所示:
it('Should increase the number of likes to one', () => {
var commentLikes = firstComment.element(by.binding('likes'));
expect(commentLikes.getText()).toBe(1);
});
创建的测试现在看起来如下:
describe('When I like a comment', () => {
var firstComment = null;
beforeEach(() => {
//Assemble
firstComment = element.all(by.css('li').first();
var likeButton = firstComment.element(by.buttonText('like'));
//Act
likeButton.click();
});
//Assert
it('Should increase the number of likes to one', () => {
var commentLikes = firstComment.element(by.css('#likes'));
expect(commentLikes.getText()).toBe(1);
});
});
让它运行
测试已经准备就绪,迫不及待地想要运行。我们现在将运行它,并修复代码直到测试通过。以下步骤将详细说明错误和修复循环,以使测试路径通过:
-
运行 Protractor。
-
在命令行中查看错误信息:
$ Error: No element found using locator: by.buttonText("like") -
正如错误信息所述,没有 点赞 按钮。请继续添加该按钮:
@Component({ template: `........ <ul> <li *ngFor="let comment of comments"> {{comment}} <button type="button">like</button> </li> </ul>` }); -
运行 Protractor。
-
查看下一个错误信息:
$ Expected 'a sample comment like' to be 'a sample comment'. -
通过添加 点赞 按钮,我们导致其他测试失败。原因是我们的
getText()方法使用不当。Protractor 的getText()方法获取内部文本,包括内部元素。 -
为了修复这个问题,我们需要更新之前的测试以包括 点赞 作为测试的一部分:
it('Should then add the comment', () => { var comments = element.all(by.css('li')).first(); expect(comments.getText()).toBe('a sample comment like'); }); -
运行 Protractor。
-
查看下一个错误信息:
$ Error: No element found using locator: by.css("#likes") -
是时候添加一个
likes绑定了。这个稍微复杂一些。likes需要绑定到一个评论上。我们需要更改组件中保存评论的方式。评论需要保存comment标题和点赞数。一个评论应该是一个像这样的对象:{title:'',likes:0} -
再次强调,这一步的重点只是确保测试通过。下一步是更新组件的
add函数,以便根据我们在前几步中描述的对象创建评论。 -
打开
app.component.ts并编辑add函数,如下所示:export class AppComponent { ...... add(comment) { var commentObj = {title: comment, likes: 0}; this.comments.unshift(commentObj); } } -
更新页面以使用评论的值:
@Component({ template: `........... <ul> <li *ngFor="let comment of comments"> {{comment.title}} </li> </ul>` }) -
在重新运行 Protractor 测试之前,我们需要将新的
comment.likes绑定添加到 HTML 页面:@Component({ template: `........... <ul> <li *ngFor="let comment of comments"> {{comment.title}} ............. <span id="likes">{{comment.likes}}</span> </li> </ul>` }) -
现在重新运行 Protractor 测试,看看错误在哪里:
$ Expected 'a sample comment like 0' to be 'a sample comment like' -
由于评论的内部文本已更改,我们需要更改测试的预期:
it('Should then add the comment',() => { ... expect(comments.getText()).toBe('a sample comment like 0'); }); -
运行 Protractor:
$ Expected '0' to be '1'. -
最后,我们来到了测试的预期。为了使这个测试通过,最小的更改将是使 点赞 按钮更新
comment数组上的点赞数。第一步是在控制器中添加一个like方法,该方法将更新点赞数:export class AppComponent { ...... like(comment) { comment.like++; } } -
使用按钮上的
(click)属性将like方法链接到 HTML 页面,如下所示:@Component({ template: `........ <ul> <li *ngFor="let comment of comments"> {{comment}} <button type="button" (click)="like(comment)"> like</button> <span id="likes">{{comment.likes}}</span> </li> </ul>` }); -
运行 Protractor 并确认测试通过!
页面现在看起来如下截图所示:

与本章开头的图示相比,所有功能都已创建。现在我们已经使用 Protractor 使测试通过,我们需要检查单元测试以确保我们的更改没有破坏它们。
修复单元测试
所需的主要更改之一是将评论变成一个包含值和点赞数的对象。在过多地考虑单元测试可能受到影响之前,让我们启动它们。执行以下命令:
$ karma start
如预期的那样,错误与新的 comment 对象有关:
$ Expected { value : 'a sample comment', likes : 0 } to be
'a sample comment'.
检查预期,似乎唯一需要的是在预期中使用 comment.value,而不是 comment 对象本身。按照以下方式更改预期:
it('',() => {
var firstComment = app.comments[0];
expect(firstComment.title).toBe('a sample comment');
})
保存文件并检查 Karma 输出。确认测试通过。Karma 和 Protractor 测试都通过了,我们已经完成了添加评论和点赞的主要用户行为。现在我们可以自由地继续下一步并使事情变得更好。
让它变得更好
总的来说,这种方法最终达到了我们想要的结果。用户现在能够在 UI 中点赞评论并看到点赞数。从重构的角度来看,主要的事情是我们没有对 like 方法进行单元测试。
回顾我们的开发待办事项列表,我们看到列表是我们写下的一项操作。在完全完成功能之前,让我们讨论为 like 功能添加单元测试的选项。
测试耦合
如前所述,测试与实现紧密耦合。当涉及复杂的逻辑或我们需要确保应用程序的某些方面以特定方式行为时,这是一个好事。重要的是要意识到耦合,并知道何时将其引入应用程序,何时不引入。我们创建的 like 函数只是简单地在一个对象上增加计数器。这可以很容易地进行测试;然而,我们将通过单元测试引入的耦合不会给我们带来额外的价值。
在这个情况下,我们不会为 like 方法添加另一个单元测试。随着应用程序的发展,我们可能会发现需要添加单元测试以开发和扩展功能。
在添加测试时,我会考虑以下一些事情:
-
添加测试是否超过了维护它的成本?
-
测试是否为代码增加了价值?
-
它是否有助于其他开发者更好地理解代码?
-
是否以某种方式正在测试功能?
根据我们的决定,不再需要重构或测试。在下一节中,我们将退后一步,回顾本章的主要观点。
自我测试问题
Q1. Karma 需要 Selenium WebDriver 来运行测试。
-
正确
-
错误
Q2. 给定以下代码段,你会如何选择以下按钮:
<button type="button">Click Me</button>?
-
element.all(by.button('button')) -
element.all(by.css('type=button')) -
element(by.buttonText('Click Me'))
摘要
在本章中,我们介绍了使用 Protractor 和 Karma 一起进行 TDD 技术的方法。随着应用程序的开发,我们能够看到在哪里、为什么以及如何应用 TDD 测试工具和技术。
这种方法,自顶向下,与第三章(“Karma 方式”,ch03.html)、第四章(“使用 Protractor 进行端到端测试”,ch04.html)中讨论的自底向上方法不同。在自底向上的方法中,规格用于构建单元测试,然后在上面构建 UI 层。在本章中,展示了自顶向下的方法,重点关注用户的行为。
自顶向下的方法首先测试 UI,然后通过其他层过滤开发。这两种方法都有其优点。在应用 TDD(测试驱动开发)时,了解如何使用这两种方法是至关重要的。除了介绍不同的 TDD 方法外,我们还看到了 Angular 的一些核心测试组件,例如以下内容:
-
从端到端和单元测试的角度测试组件
-
将组件类导入测试套件并为其进行单元测试的初始化
-
Protractor 能够绑定到
ngModel,向输入列发送按键,并通过其内部 HTML 代码及其所有子元素获取元素文本的能力
下一章将基于这里使用的技术,探讨无头浏览器测试、Protractor 的高级技术以及如何测试 Angular 路由。
第七章。翻转
到目前为止,我们应该对使用 TDD 进行 Angular 应用程序的初始实现有信心。我们还应该熟悉使用测试优先方法。测试优先方法在学习阶段非常好,但有时当我们遇到很多错误时,它可能会浪费时间。对于简单和已知的行为,可能不是采用测试优先方法的最佳选择。
我们已经看到了测试优先方法是如何工作的,因此我们可以通过检查任何功能而不创建这些组件来跳过这些步骤。除此之外,我们可以更进一步,使我们更有信心更快地编写组件。我们可以让组件准备好,然后编写端到端测试规范来测试预期的行为。如果 e2e 测试失败,我们可以在 Protractor 调试器中触发错误。
在本章中,我们将继续扩展我们应用 TDD(但不是测试优先方法)与 Angular 的知识。在这里,我们不会讨论基本 Angular 组件生态系统的细节;相反,我们将更进一步,包括更多 Angular 功能。我们将通过以下主题进一步扩展我们的知识:
-
Angular 路由
-
导航到路由
-
与路由参数数据通信
-
使用 CSS 和 HTML 元素进行 Protractor 位置引用
TDD 的基础
在本章中,我们将逐步介绍将 TDD 应用于搜索应用的路线和导航。在开始逐步介绍之前,我们需要了解本章中将使用的一些技术、配置和函数,包括以下内容:
-
Protractor 定位器
-
无头浏览器测试
在回顾了这些概念之后,我们可以继续进行逐步介绍。
Protractor 定位器
Protractor 定位器是每个人都应该花时间学习的关键组件。在之前的 Protractor 章节中,我们通过一些工作示例了解了一些常用定位器。在这里,我们将提供一些 Protractor Locator 的示例。
Protractor 定位器允许我们在 HTML 页面中查找元素。在本章中,我们将看到 CSS、HTML 和 Angular 特定定位器的实际应用。定位器被传递给 element 函数。element 函数将找到并返回页面上的元素。通用的定位器语法如下:
element(by.<LOCATOR>);
在前面的代码中,<LOCATOR> 是一个占位符。以下几节将描述这些定位器中的几个。
CSS 定位器
CSS 用于向 HTML 页面添加布局、颜色、格式化和样式。从端到端测试的角度来看,一个元素的看起来和样式可能是一个规范的一部分。例如,考虑以下 HTML 片段:
<div class="anyClass" id="anyId"></div>
// ...
var e1 = element(by.css('.anyClass'));
var e2 = element(by.css('#anyId'));
var e3 = element(by.css('div'));
var e4 = $('div');
所有这四个选择都将选择 div 元素。
按钮和链接定位器
除了能够选择和解释某物的外观方式外,也很重要能够找到页面中的按钮和链接。这将允许测试轻松地与网站交互。以下是一些示例:
buttonText定位器:
<button>anyButton</button>
// ...
var b1 = element(by.buttonText('anyButton'));
linkText定位器:
<a href="#">anyLink</a>
// ...
var a1 = element(by.linkText('anyLink'));
URL 位置引用
当测试 Angular 路由时,我们需要能够测试我们测试的 URL。通过在 URL 和位置周围添加测试,我们必须确保应用程序与特定的路由一起工作。这很重要,因为路由为我们提供了应用程序的接口。以下是如何在 Protractor 测试中获得 URL 引用的方法:
var location = browser.getLocationAbsUrl();
现在我们已经看到了如何使用不同的定位器,是时候将知识付诸实践了。
准备 Angular 项目
获得快速设置项目的过程和方法很重要。你花在思考目录结构和所需工具上的时间越少,你可以在开发上花的时间就越多!
因此,在前面的章节中,我们探讨了如何获取作为 quickstart 项目开发的简单现有 Angular 项目 github.com/angular/quickstart。
然而,有些人使用 angular2-seed github.com/mgechev/angular2-seed 项目、Yeoman 或创建自定义模板。尽管这些技术很有用,并且有其优点,但在 Angular 的入门阶段,理解从头开始构建应用程序所需的东西是至关重要的。通过自己构建目录结构和安装工具,我们将更好地理解 Angular。
你将能够根据你特定的应用和需求做出布局决策,而不是将它们适应到某个其他模块中。随着你的成长和成为更好的 Angular 开发者,这一步可能不再需要,并且会变得自然而然。
加载现有项目
首先,我们将从 Angular 的 quickstart 项目 github.com/angular/quickstart 克隆项目,将其重命名为 angular-flip-flop,我们的项目文件夹结构将如下所示:

在前面的章节中,我们讨论了如何设置项目,理解了涉及的不同组件,并走过了整个过程。我们将跳过这些细节,并假设你可以回忆起如何执行必要的安装。
准备项目
这个 quickstart 项目没有在项目的着陆页(index.html)中包含基本 href。为了完美地进行路由,我们需要这个 href,所以让我们在 index.html 的 <head> 部分添加一行(base href):
<base href="/">
在这里,我们的引导组件位于应用程序组件中,HTML 模板位于组件本身中。在继续之前,我们应该将模板分离到一个新文件中。
为了这个,我们将更新我们的应用程序组件(app/app.component.ts),如下所示:
import { Component } from '@angular/core';
@Component({
moduleId: module.id,
selector: 'my-app',
templateUrl: 'app.component.html'
})
export class AppComponent {
};
让我们在 app/app.component.html 创建我们的单独模板文件。代码将如下所示:
<h1>My First Angular 2 App</h1>
运行项目
让我们继续进行,并准备好使用以下命令运行:
$ cd angular-flip-flop
$ npm install // To install the required node modules.
$ npm run // To build and run the project in http server.
为了确认安装并运行项目,应用程序将自动在网页浏览器中运行。
运行项目后的预期输出如下:

重构项目
让我们稍微改变一下项目结构,但不是很多。默认情况下,它已经将单元测试包含在组件文件相同的目录中,并将端到端测试文件分离到app/文件夹外的e2e/文件夹中。
然而,我们将保持所有测试在同一位置,即app之外;我们将保持所有测试在spec/e2e和spec/unit。
目标是将测试规范与组件分离。这样,我们可以将单元测试文件放在spec/unit文件夹的外部。
因此,我们当前的项目结构将看起来像这样:

注意
只要我们更改了单元和端到端测试的路径,我们就需要在 Karma 配置文件和 Protractor 配置文件中更改路径。
为 Karma 设置无头浏览器测试
在前面的章节中,我们使用默认配置运行 Karma。默认的 Chrome 配置会在每次测试时启动 Chrome。针对应用程序将运行的实际代码和浏览器进行测试是一个强大的工具。然而,在启动时,浏览器可能并不总是知道你希望它如何表现。从单元测试的角度来看,你可能不希望浏览器在窗口中启动。原因可能是测试可能需要很长时间才能运行,或者你可能并不总是安装了浏览器。
幸运的是,Karma 配备了轻松配置 PhantomJS(一个无头浏览器)的能力。无头浏览器在后台运行,不会在 UI 中显示网页。PhantomJS 无头浏览器是用于测试的真正出色的工具。它甚至可以设置来对测试进行截图!有关如何进行此操作以及 PhantomJS 网站上使用的 WebKit 的更多信息,请参阅phantomjs.org/。以下设置配置将展示如何使用 Karma 设置 PhantomJS 进行无头浏览器测试。
预配置
当 Karma 安装时,它将自动包含 PhantomJS 浏览器插件。有关进一步参考,插件位于github.com/karma-runner/karma-phantomjs-launcher。不应需要更多的安装或配置。
然而,如果你的设置表明它缺少karma-phantomjs-launcher,你可以很容易地使用npm安装它,如下所示:
$ npm install karma-phantomjs-launcher --save -dev
配置
PhantomJS 已在 Karma 配置的browsers部分进行配置。打开karma.conf.js文件,并使用以下详细信息更新它:
browsers: ['PhantomJS'],
同样在plugins选项中进行设置:
plugins: [
.........
require('karma-phantomjs-launcher'),
],
现在项目已经初始化并配置了无头浏览器测试,你可以通过以下步骤查看其运行情况。
Angular 路由和导航的概述
本指南将利用 Angular 路由。路由是 Angular 的一个极其有用的功能,就像在 Angular 1.x 中一样,但更强大。它们允许我们使用不同的组件来控制应用程序的某些方面。
本指南将切换组件以展示如何使用 TDD(测试驱动开发)来构建路由。以下是一些规范。将有一个导航菜单,其中包含两个菜单项,View1和View2:
-
在导航菜单中,点击标签View1
-
内容区域(路由出口)将加载/切换View1内容
接下来是第二部分:
-
在导航菜单中,点击标签View2
-
内容区域(路由出口)将加载/切换View2内容
实质上,这将是一个在两个视图之间切换的应用程序。
设置 Angular 路由
路由是 Angular 中的一个可选服务,因此它不包括在 Angular 核心中。如果我们需要使用路由,我们必须在我们的应用程序中安装 Angular 的router服务。
只要我们从quickstart克隆了我们的项目,我们就应该没问题,因为它最近已经将其依赖项中的 Angular 路由添加了进来,但我们应该检查并确认。如果package.json中的依赖项中没有@angular/router,我们可以使用npm安装 Angular 路由,如下所示:
$ npm install @angular/router --save
定义方向
一个路由指定了一个位置并期望一个结果。从 Angular 的角度来看,路由必须首先指定,然后与某些组件相关联。
要在我们的应用程序中实现路由,我们需要在应用程序模块中导入路由模块,在那里它将在应用程序中注册路由。之后,我们需要配置所有路由并将该配置传递给应用程序模块。
路由模块
要在应用程序中实现路由,我们需要在我们的应用程序模块app/app.module.ts中导入RouterModule,如下所示:
import {RouterModule} from "@angular/router";
这将使router模块在应用程序系统中可用,但我们将必须有一个路由配置来定义整个应用程序中所有可能的路由,然后通过应用程序模块将此配置导入到应用程序生态系统中。
配置路由
路由在没有配置之前是无用的,为此,我们首先需要导入router组件。配置将主要包含一个数组列表,其中路由路径和相关组件作为键值对存在。我们可以将配置数组添加到应用程序模块中,或者我们可以创建一个单独的配置文件并将应用程序模块包含在内。我们将选择第二种方法,以便将路由配置与应用程序模块分离。
让我们在应用程序根目录中创建一个名为app/app.routes.ts的路由配置文件。在那里,首先,我们需要从 Angular 服务中导入 Angular 的Routes,如下所示:
import {Routes} from '@angular/router';
从路由配置文件中,我们需要导出配置数组,如下所示:
export const rootRouterConfig: Routes = [
// List of routes will come here
];
应用程序中的路由器
我们已经将 router 模块导入到位于 app/app.module.ts 的应用程序模块中。
然后,我们需要将路由配置文件 (rootRouterConfig) 导入到这个应用程序模块文件中,如下所示:
import {rootRouterConfig} from "./app.routes";
在应用程序模块中,我们知道 NgModule 将可选模块导入到应用程序生态系统中,同样地,为了将路由包含在应用程序中,RouterModule 有一个名为 RouterModule.forRoot(RouterConfig) 的函数,它接受 routerConfiguration 以在整个应用程序中实现路由。
应用程序模块 (app/app.module.ts) 将如下导入 RouterModule:
@NgModule({
declarations: [AppComponent, ........],
imports : [........., RouterModule.forRoot(rootRouterConfig)],
bootstrap : [AppComponent]
})
export class AppModule {
}
配置中的路由
现在,让我们向我们的 Routes 配置数组添加一些路由,该数组位于 app/app.routes.ts。路由配置数组包含一些作为键值对的对象,每个对象中通常有两个到三个元素。
数组对象中的第一个元素包含 path,第二个元素包含为该 path 加载的相关 component。
让我们在配置数组中添加两条路由,如下所示:
export const rootRouterConfig: Routes = [
{
path: 'view1',
component: View1Component
},
{
path: 'view2',
component: View2Component
}
];
在这里,定义了两个路由 view1 和 view2,并为该路由分配了两个组件。
在某些情况下,我们可能需要从一个路由重定向到另一个路由。例如,对于应用程序的根路径 (''),我们可能计划重定向到 view1 路由。为此,我们必须在对象中设置 redirectTo 元素,并将其值指定为某个路由名称。我们还需要添加一个额外的元素作为 pathMatch 并将其值设置为 full,这样它将在重定向到其他路由之前匹配完整路径。
代码将如下所示:
export const rootRouterConfig: Routes = [
{
path: '',
redirectTo: 'view1',
pathMatch: 'full'
},
..............
];
因此,是的,我们的初始路由配置已经准备就绪。现在,完整的配置将如下所示:
import {Routes} from '@angular/router';
import {View1Component} from './view/view1.component';
import {View2Component} from './view/view2.component';
export const rootRouterConfig: Routes = [
{
path: '',
redirectTo: 'view1',
pathMatch: 'full'
},
{
path: 'view1',
component: View1Component
},
{
path: 'view2',
component: View2Component
}
];
我应该在这里提到,我们必须导入 view1 和 view2 组件,因为我们已经在路由配置中使用了它们。
要详细了解 Angular 路由,请参阅 angular.io/docs/ts/latest/guide/router.html。
实践路由
到目前为止,我们已经安装并导入了一个路由模块,配置了路由,并将一些内容包含在应用程序生态系统中。我们还需要做一些相关任务,例如创建路由出口、创建导航以及创建在路由中定义的组件,以便获得对路由的实践经验。
定义路由出口
只要路由在 appComponent 中配置,我们就需要一个占位符来加载路由的导航组件,Angular 将其定义为路由出口。
RouterOutlet 是一个占位符,Angular 根据应用程序的路由动态填充。
对于我们的应用程序,我们将在 appComponent 模板中放置 router-outlet,该模板位于 (/app/app.component.html),如下所示:
<router-outlet></router-outlet>
准备导航
在路由配置中,我们为我们的应用程序设置了两个路径,/view1和/view2。现在,让我们创建一个带有两个路由路径的导航菜单,以便于导航。为此,我们可以创建一个单独的简单组件,以便在整个应用程序组件中隔离导航。
在/app/nav/navbar.component.ts中创建一个新的NavbarComponent组件文件,如下所示:
import {Component} from '@angular/core';
@Component({
selector: 'app-navbar',
templateUrl: 'navbar.component.html',
styleUrls: ['navbar.component.css']
})
export class NavbarComponent {}
此外,创建导航组件的模板(/app/nav/navbar.component.html),如下所示:
<main>
<nav>
<a [routerLink]="['/view1']">View1</a>
<a [routerLink]="['/view2']">View2</a>
<a [routerLink]="['/members']">Members</a>
</nav>
</main>
注意
目前不必担心导航中的members链接;我将在稍后的部分告诉你它是什么。
让我们在/app/nav/navbar.component.css中创建导航组件的基本 CSS 样式,以便更好地查看,如下所示:
:host {
border-color: #e1e1e1;
border-style: solid;
border-width: 0 0 1px;
display: block;
height: 48px;
padding: 0 16px;
}
nav a {
color: #8f8f8f;
font-size: 14px;
font-weight: 500;
margin-right: 20px;
text-decoration: none;
vertical-align: middle;
}
nav a.router-link-active {
color: #106cc8;
}
我们有一个导航组件。现在我们将需要将其绑定到我们的应用程序组件,即我们的应用程序着陆页。
为了做到这一点,我们必须将以下内容添加到位于/app/app.component.html的appComponent模板中:
<h1>My First Angular 2 App</h1>
<app-navbar></app-navbar>
<router-outlet></router-outlet>
准备组件
对于每个定义的路由,我们需要创建一个单独的组件,因为每个路由都将与一个组件相关联。
在这里,我们有两个定义的路由,我们需要为每个路由创建两个单独的组件来处理导航。我们将根据我们的要求创建View1Component和View2Component。
在/app/view/view1.component.ts中创建一个新的View 1组件文件,如下所示:
import {Component} from '@angular/core';
@Component({
selector: 'app-view1',
template: '<div id="view1">I am view one component</div>'
})
export class View1Component { }
在/app/view/view2.component.ts中创建另一个View 2组件文件:
import {Component} from '@angular/core';
@Component({
selector: 'app-view2',
template: '<div id="view2">I am view two component</div>'
})
export class View2Component { }
我们已经准备好了我们的路由和相关组件(Navigation、View1和View2)。希望一切都能按预期工作,我们可以在浏览器中看到应用程序的输出。
等一下,在查看浏览器中的预期输出之前,让我们使用端到端测试来测试预期结果。现在我们知道了预期的行为,我们将根据我们的期望编写 e2e 测试规范。一旦我们准备好了 e2e 测试规范,我们将看到它是如何满足我们的期望的。
组装翻转/切换测试
按照三个 A 中的第一个 A,组装,这些步骤将向我们展示如何组装测试:
-
从 Protractor 基础模板开始,如下所示:
describe('Given views should flip through navigation interaction', () => { beforeEach( () => { // ..... }); it('Should fliped to the next view', () => { // .... }); }); -
使用以下代码导航到应用程序的根目录:
browser.get('view1'); -
beforeEach方法需要确认显示的是正确的组件视图。这可以通过使用 CSS 定位器查找view1的div标签来实现。期望结果如下所示:var view1 = element(by.css('#view1')); expect(view1.isPresent()).toBeTruthy(); -
然后,添加一个期望,即
view2不可见:var view2 = element(by.css('#view2')); expect(view2.isPresent()).toBeFalsy(); -
然后,通过获取
view1组件的整个文本来进一步确认:var view1 = element(by.css('#view1')); expect(view1.getText()).toEqual('I am view one component');
切换到下一个视图
前面的测试需要确认当在导航中点击view2链接时,view2组件的内容将加载。为了进行测试,我们可以使用by.linkText定位器。它将看起来像这样:
var view2Link = element(by.linkText('View2'));
view2Link.click();
beforeEach函数现在已完成,如下所示:
var view1 = element(by.css('#view1'));
var view2 = element(by.css('#view2'));
beforeEach(() => {
browser.get('view1');
expect(view1.isPresent()).toBeTruthy();
var view2Link = element(by.linkText('View2'));
view2Link.click();
})
接下来,我们将添加断言。
断言翻转
断言将再次使用 Protractor 的 CSS 定位器,如下所示,以查找view2是否可用:
it('Should fliped to View2 and view2 should visible', () => {
expect(view2.isPresent()).toBeTruthy();
});
我们还需要确认view1不再可用。添加期望,view1不应存在,如下所示:
it('Should fliped to View2 and view1 should not visible', () => {
expect(view1.isPresent()).toBeFalsy();
});
为了确保无误,我们还可以检查view2的内容是否已加载,如下所示:
it('Should fliped to View2 and should have body content as expected', () => {
expect(view2.getText()).toEqual('I am view two component');
});
由于我们即将通过点击导航中的view2链接来切换测试从view1组件到view2组件,让我们通过点击导航中的view1链接回到view1组件,希望一切按预期工作:
it('Should flipped to View1 again and should visible', () => {
var view1Link = element(by.linkText('View1'));
view1Link.click();
expect(view1.isPresent()).toBeTruthy();
expect(view2.isPresent()).toBeFalsy();
});
测试现在已经组装完成。
运行翻转/翻转测试
我们的测试规范已经准备好,现在是时候运行它并查看结果了。
首先,我们必须通过 HTTP 服务器保持我们的项目运行,使用以下命令:
$ npm start
然后,我们必须运行 Protractor。确保运行中的应用程序的端口号和 Protractor 配置文件正确;为了确保无误,更新配置中的运行服务器端口号。要运行 Protractor,请使用以下命令:
$ npm run e2e
结果应该如下所示:
Suite: Given views should flip through navigation in
passed - View1 should have body content as expected
passed - Should flipped to View2 and view2 should visible
passed - Should flipped to View2 and should have body content
as expected
passed - Should flipped to View1 again and should visible
Suite passed: Given views should flip through navigation in
根据我们的预期,Protractor 测试已经通过。现在我们可以查看浏览器,检查事情是否与 e2e 测试结果一样工作。
在浏览器中打开应用
只要我们为 e2e 测试运行了npm start命令,我们的应用程序就可以在本地主机的特定端口3000上运行。默认情况下,它将在浏览器中打开。
预期输出显示在以下屏幕截图:

以 TDD 方式搜索
这个流程将展示我们如何构建一个简单的搜索应用程序。它有两个组件:第一个讨论搜索查询组件,第二个使用路由来显示搜索结果详情。
搜索查询的流程
正在构建的应用程序是一个搜索应用程序。第一步是设置搜索区域和搜索结果。想象一下我正在进行搜索。在这种情况下,以下操作将会发生:
-
输入搜索查询
-
结果显示在搜索框的底部
这部分应用与我们在第六章中看到的测试、布局和方法的相似性非常高,第一步。应用需要使用输入,响应用户点击,并确认结果数据。由于测试和代码使用与上一个示例相同的函数,因此没有必要提供完整的搜索功能流程。相反,以下小节将展示所需的代码和一些解释。
搜索查询测试
以下代码表示搜索查询功能的测试:
describe('Given should test the search feature', () => {
let searchBox, searchButton, searchResult;
beforeEach(() => {
//ASSEMBLE
browser.get('');
element(by.linkText('Search')).click();
searchResult = element.all(by.css('#searchList tbody tr'));
expect(searchResult.count()).toBe(3);
//ACT
searchButton = element(by.css('form button'));
searchBox = element(by.css('form input'));
searchBox.sendKeys('Thomas');
searchButton.click();
});
//Assert
it('There should be one item in search result', () => {
searchResult = element.all(by.css('#searchList tbody tr'));
expect(searchResult.count()).toBe(1);
});
});
我们应该注意到与之前的测试有相似之处。功能被编写来模拟用户在搜索框中输入的行为。测试找到输入字段,输入一个值,然后选择显示搜索的按钮。断言确认结果包含单个值。
搜索应用程序
要执行搜索操作,我们需要创建一个包含用于接受用户输入(搜索查询)的输入字段和用于执行用户动作的点击事件的按钮的搜索组件。除此之外,它可能还有一个占位符来包含搜索结果。
只要我们的应用程序已经包含了路由器,我们就可以为特定的路由放置搜索组件。
注意,我们已将我们的搜索组件命名为MembersComponent,因为我们已经在搜索组件中处理了一些成员数据。并且路由也将根据这一点进行配置。
因此,在我们的现有app.routes.ts文件中,我们将添加以下搜索路由:
export const rootRouterConfig: Routes = [
{
path: '/members',
component: MembersComponent
}
...................
];
搜索组件
搜索组件(MembersComponent)将是此处搜索功能的主要类。它将执行搜索并返回搜索结果。
在搜索组件的初始加载过程中,它将没有任何搜索查询,因此我们已将行为设置为返回所有数据。然后,在搜索触发后,它将根据搜索查询返回数据。
搜索组件将被放置在app/members/members.compoennt.ts。在代码中,一开始,我们不得不导入所需的 Angular 服务,如下所示:
import { Component, OnInit } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Router } from '@angular/router';
我们将使用Http服务进行 AJAX 调用,默认情况下,在 Angular 中,Http服务返回一个可观察对象。然而,处理一个承诺比处理一个可观察对象更容易。因此,我们将这个可观察对象转换为承诺。Angular 建议使用rxjs模块,它包含用于将可观察对象转换为承诺的toPromise方法。因此,我们将导入rxjs模块,如下所示:
import 'rxjs/add/operator/toPromise';
Angular 引入了ngOnInit()方法,在初始化组件时执行,类似于任何类中的构造函数方法,但它有助于运行测试规范。为此,我们已从 Angular 核心导入OnInit接口,Component类将实现OnInit接口以获取ngOnInit方法。
此外,Component类应该注入所需的模块,例如Http和Router,如下所示:
export class MembersComponent implements OnInit {
constructor(private http:Http, private router:Router) {
}
}
如前所述,我们将使用ngOnInit()方法,并从那里初始化搜索机制,如下所示:
export class MembersComponent implements OnInit {
ngOnInit() {
this.search();
}
在这里,我们将对成员列表应用search功能,为此,我们在app/data/people.json中有些示例数据。我们将从这里检索数据并在数据上执行搜索操作。让我们看看如何:
getData()方法将从 API 检索数据,并将返回一个承诺。
getData() {
return this.http.get('app/data/people.json')
.toPromise()
.then(response => response.json());
}
searchQuery()方法将解析返回的承诺,并根据搜索查询创建一个数据数组。如果没有提供搜索查询,它将返回完整的数据集作为数组:
searchQuery(q:string) {
if (!q || q === '*') {
q = '';
} else {
q = q.toLowerCase();
}
return this.getData()
.then(data => {
let results:Array<Person> = [];
data.map(item => {
if (JSON.stringify(item).toLowerCase().includes(q)) {
results.push(item);
}
});
return results;
});
}
search()方法将为前端绑定模板准备数据集:
search(): void {
this.searchQuery(this.query)
.then(results => this.memberList = results);
}
在这里,我们还有一个可选的方法,用于导航到成员详情组件。我们将其称为 person 组件。在这里,viewDetails() 方法将传递成员 ID,router.navigate() 方法将使用 ID 作为参数将应用程序导航到 person 组件,如下所示:
viewDetails(id:number) {
this.router.navigate(['/person', id]);
}
MembersComponent 的完整代码如下:
import { Component, OnInit } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Router } from '@angular/router';
import 'rxjs/add/operator/toPromise';
import { Person } from './person/person.component';
@Component({
selector: 'app-member',
moduleId: module.id,
templateUrl: 'members.component.html',
styleUrls: ['members.component.css']
})
export class MembersComponent implements OnInit {
memberList: Array<Person> = [];
query: string;
constructor(private http:Http, private router:Router) {
}
ngOnInit() {
this.search();
}
viewDetails(id:number) {
this.router.navigate(['/person', id]);
}
getData() {
return this.http.get('app/data/people.json')
.toPromise()
.then(response => response.json());
}
search(): void {
this.searchQuery(this.query)
.then(results => this.memberList = results);
}
searchQuery(q:string) {
if (!q || q === '*') {
q = '';
} else {
q = q.toLowerCase();
}
return this.getData()
.then(data => {
let results:Array<Person> = [];
data.map(item => {
if (JSON.stringify(item).toLowerCase().includes(q)) {
results.push(item);
}
});
return results;
});
}
}
当有结果要显示时,search 组件模板包含搜索表单和搜索结果列表。
模板如下所示:
<h2>Members</h2>
<form>
<input type="search" [(ngModel)]="query" name="query" (keyup.enter)="search()">
<button type="button" (click)="search()">Search</button>
</form>
<table *ngIf="memberList" id="searchList">
<thead>
<tr>
<th>Name</th>
<th>Phone</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let member of memberList; let i=index">
<td><a href="javascript:void(0)" (click)="viewDetails(member.id)">{{member.name}}</a></td>
<td>{{member.phone}}</td>
</tr>
</tbody>
</table>
之前展示的 Angular 组件与之前章节中展示的类似。
我们正在使用来自 people.json 文件的模拟数据集,该数据集包含有关带地址的人的信息。我们希望将信息分成两部分,一部分为摘要信息,另一部分为地址详情。由于我们将使用此数据集,因此将很容易为该数据集创建一个对象模型。
摘要数据集将被定义为 Person 对象,地址详情将被定义为 Address。让我们在 app/members/person/person.component.ts 中创建一个人员对象,并将两个对象模型放在同一文件中。
Person 和 Address 的两个对象模型类如下所示:
export class Person {
id:number;
name:string;
phone:string;
address:Address;
constructor(obj?:any) {
this.id = obj && Number(obj.id) || null;
this.name = obj && obj.name || null;
this.phone = obj && obj.phone || null;
this.address = obj && obj.address || null;
}
}
export class Address {
street:string;
city:string;
state:string;
zip:string;
constructor(obj?:any) {
this.street = obj && obj.street || null;
this.city = obj && obj.city || null;
this.state = obj && obj.state || null;
this.zip = obj && obj.zip || null;
}
}
展示我搜索结果!
现在,搜索按钮已设置所需的功能,结果应仅包含基于搜索查询的数据,而不是所有内容。让我们看看用户规范。
给定一组搜索结果:
-
我们将根据搜索查询拥有成员列表
-
我们将点击任何成员的姓名,并导航到详情组件以获取详细信息
采用自顶向下的方法,第一步将是 Protractor 测试,然后是使应用程序完全功能所需的必要步骤。
测试搜索结果
根据规范,我们需要利用现有的搜索结果。我们不必从头创建测试,可以添加到现有的搜索查询测试中。从以下嵌入在搜索查询测试中的基本测试开始:
describe('Given should test the search result in details view', () => {
beforeEach(() => {
});
it('should be load the person details page', () => {
});
});
下一步是构建测试。
组装搜索结果测试
在这种情况下,搜索结果已从搜索查询测试中获取。我们不需要为测试添加任何更多设置步骤。
选择搜索结果
测试的对象是结果。测试是结果被选中,然后应用程序必须执行某些操作。在 Protractor 中编写此测试的步骤如下:
-
选择
resultItem。由于我们将使用路由来表示详情,我们将创建一个指向详情页面的链接并点击该链接。以下是创建链接的方法:在
resultItem内选择链接。这使用当前选定的元素,然后找到任何符合标准子元素。此代码如下所示:let resultItem = element(by.linkText('Demaryius Thomas')); -
现在,要选择链接,请添加以下代码:
resultItem.click();
确认搜索结果
现在搜索项已被选择,我们需要验证结果详情页面是否可见。目前最简单的解决方案是确保详情视图是可见的。这可以通过使用 Protractor 的 CSS 定位器来查找搜索详情视图来完成。以下是要添加以确认搜索结果的代码:
it('Should be load the person details page', () => {
var resultDetail = element(by.css('#personDetails'))
expect(resultDetail.isDisplayed()).toBeTruthy();
})
这里是完整的测试:
describe('Given should test the search result in details view', () => {
beforeEach(() => {
browser.get('members');
let searchButton = element(by.css('form button'));
let searchBox = element(by.css('form input'));
searchBox.sendKeys('Thomas');
searchButton.click();
let resultItem = element(by.linkText('Demaryius Thomas'));
resultItem.click();
});
it('should be load the person details page', () => {
var resultDetail = element(by.css('#personDetails'))
expect(resultDetail.isDisplayed()).toBeTruthy();
});
});
现在测试已经设置好了,我们可以继续到生命周期的下一阶段并运行它。
搜索结果组件
我们命名的 Person 搜索结果组件将路由到接受 params 路由中的人员 ID,并将根据该 ID 搜索数据。
搜索结果组件将被放置在 app/members/person/person.component.ts 中。在代码中,首先,我们必须导入所需的 Angular 服务,如下所示:
import { Component, OnInit } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Router, ActivatedRoute, Params } from '@angular/router';
我们已经在 members 组件中看到了一些这些 Angular 服务。在这里,我们将主要讨论 ActivatedRoute,因为它很新。这是一个 Angular 路由模块,用于与当前/激活的路由交互:当我们需要访问当前路由的 params 时,我们将通过它来访问。
正如我们讨论的那样,在初始化组件时,我们需要 ActivatedRoute;因此,我们在 ngOnInit() 方法中调用了 ActivatedRoute。它将为我们提供当前的路由参数,我们将得到预期的 ID,这将用于从演示成员数据集中检索特定的 Person,如下所示:
export class PersonComponent implements OnInit {
person: Person;
constructor(private http:Http, private route: ActivatedRoute,
private router: Router) {
}
ngOnInit() {
this.route.params.forEach((params: Params) => {
let id = +params['id'];
this.getPerson(id).then(person => {
this.person = person;
});
});
}
我们在 app/data/people.json 中有一些模拟数据。这是与 members 组件中使用的相同数据。我们将根据所选 ID 检索数据,如下所示:
getData() {
return this.http.get('app/data/people.json')
.toPromise()
.then(response => response.json());
}
getData() 方法将从 API 获取数据,并将返回一个承诺:
getPerson(id:number) {
return this.getData().then(data => data.find(member =>
member.id === id));
}
getPerson() 方法将解析返回的承诺,并根据所选 ID 返回 Person 对象。
关于 PersonComponent 的完整代码如下:
import { Component, OnInit } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Router, ActivatedRoute, Params } from '@angular/router';
import 'rxjs/add/operator/toPromise';
@Component({
selector: 'app-person',
moduleId: module.id,
templateUrl: 'person.component.html',
styleUrls: ['../members.component.css']
})
export class PersonComponent implements OnInit {
person: Person;
constructor(private http:Http, private route: ActivatedRoute, private router: Router) {
}
ngOnInit() {
this.route.params.forEach((params: Params) => {
let id = +params['id'];
this.getPerson(id).then(person => {
this.person = person;
});
});
}
getPerson(id:number) {
return this.getData().then(data => data.find(member => member.id === id));
}
getData() {
return this.http.get('app/data/people.json')
.toPromise()
.then(response => response.json());
}
}
当有搜索结果要显示时,search 组件模板包含搜索表单和搜索结果列表。
模板如下所示:
<h2>Member Details</h2>
<table *ngIf="person" id="personDetails">
<tbody>
<tr>
<td>Name :</td>
<td>{{person.name}}</td>
</tr>
<tr>
<td>Phone: </td>
<td>{{person.phone}}</td>
</tr>
<tr>
<td>Street: </td>
<td>{{person.address.street}}</td>
</tr>
<tr>
<td>City: </td>
<td>{{person.address.city}}</td>
</tr>
<tr>
<td>State: </td>
<td>{{person.address.state}}</td>
</tr>
<tr>
<td>Zip: </td>
<td>{{person.address.zip}}</td>
</tr>
</tbody>
</table>
路由中的搜索结果
我们已经有了搜索结果/Person 组件,但我们忘记将其包含在路由配置中。没有它,我们将遇到异常,因为我们无法在没有它在路由中的情况下从 members 列表中导航到 Person 组件。
因此,在我们的现有 app.routes.ts 文件中,我们将添加以下搜索路由:
export const rootRouterConfig: Routes = [
{
path: '/person/:id',
component: PersonComponent
}
...................
];
运行搜索轮
我们的应用程序经过重构、路由配置、端到端测试以及组件及其子组件的准备,我们将查看当前的项目文件结构和输出。
应用程序结构
我们的应用程序中有两个主要文件夹,一个是app目录,另一个是spec/test目录。
让我们看看当前app目录的结构:

这里是test目录:

让我们运行
我们搜索功能已经准备好运行。如果我们运行npm start,我们的应用程序将默认在端口3000上在浏览器中运行。让我们导航到成员以获取搜索功能的输出。搜索功能 URL 是http://localhost:3000/members。
当我们到达成员页面时,实际上会加载所有数据,因为搜索输入为空,这意味着没有搜索查询。输出应该如下所示:

现在,让我们检查带有搜索查询的成员页面。如果我们输入Thomas作为查询并搜索,它将只给我们一条数据行,如下所示:

数据列表中有一行。现在是我们查看数据详情的时候了。点击Thomas后,我们将看到关于 Thomas 的详细信息,包括地址,如下所示:

欢呼!完整的应用程序按预期在浏览器中运行。
现在端到端测试(e2e)怎么样了!
项目正在浏览器中运行,我们已经为每个组件进行了端到端测试。让我们看看当我们一起运行整个应用程序的端到端测试时,端到端测试是如何反应的。
让我们运行npm run e2e;输出如下所示:

自我测试问题
Q1. 在导航后加载组件时,使用哪种自定义占位符?
<router-output> </router-output>
<router-outlet> </router-outlet>
<router-link> </router-link>
Q2. 给定以下 Angular 组件,你会如何选择element并模拟点击?
<a href="#">Some Link</a>
$('a').click();.
element(by.css('li)).click();.
element(by.linkText('Some Link')).click();.
Q3. 在使用 Angular 的路由时,你需要安装@angular/router。
-
真的
-
假的
摘要
本章向我们展示了如何使用 TDD 构建 Angular 应用程序。到目前为止,这种方法一直侧重于从用户角度的规范,并采用自上而下的 TDD 方法。这种技术帮助我们为用户测试和完成可用的且小的组件。
随着应用程序的增长,它们的复杂性也在增加。在下一章中,我们将探讨自下而上的方法,并看看何时使用这种方法而不是自上而下的方法。
本章向我们展示了如何使用测试驱动开发(TDD)来开发一个通过路由器进行导航的组件化应用程序。路由使我们能够很好地分离我们的组件和视图。我们探讨了几个 Protractor 定位器的使用,从 CSS 到重复器、链接文本和内部定位器。除了使用 Protractor,我们还学习了如何配置 Karma 以使用无头浏览器,并看到了它的实际应用。
第八章。告诉世界
TDD 的构建主要关注基本组件,即生命周期和过程,使用逐步讲解。我们从底层研究了几个应用程序,理解了如何构建 Angular 应用程序并使用工具来测试它们。
是时候进一步深入 Angular 的深处并集成服务、EventEmitters 和路由了。
本章在几个方面将与其他章节略有不同:
-
我们将使用第七章中的搜索应用程序,而不是构建全新的应用程序,翻转
-
我们将为 Angular 路由和导航添加上一章中跳过的单元测试
-
我们将通过将常用操作分离到服务中来使现有的搜索应用程序更加现代化
-
我们将利用 Angular 的
EventEmitter类在不同的组件之间进行通信
准备进行通信
在本章中,我们将采取不同的方法,因为我们已经学习了 TDD 方法。我们在上一章开发了一个小型项目,我们的计划是使用这个项目并使其变得更好,以便向世界展示。
因此,在开始讲解之前,我们必须回顾并识别项目中存在的问题以及改进的范围。为此,我们必须对搜索应用程序的代码库有信心。
加载现有项目
首先,我们将从第七章,翻转复制项目,该项目最初来自github.com/angular/quickstart,并将其重命名为angular-member-search。
让我们继续前进,准备运行它:
$ cd angular-member-search
$ npm install
$ npm start
为了确认安装并运行项目,应用程序将自动在网页浏览器中运行它。
当我们运行项目时,我们应该得到以下输出:

哦!我们在项目中已经有了端到端测试。在我们进行更新之前,我们必须确保现有的 e2e 测试是成功的。
让我们在单独的控制台中运行e2e测试:
$ npm run e2e
是的,一切测试都成功通过:

单元测试
在上一章中,我们开始使用自顶向下的方法。目标是基于我们所学的内容详细阐述端到端测试。我们有明确用户场景,我们通过了测试,并且我们的场景通过了我们的实现。
在上一章中,我们只涵盖了端到端测试。因此,在本章中,我们将尽可能多地涵盖单元测试。
此外,在上一章中,我们主要关注 Angular 路由和导航。因此,现在作为一个逻辑上的扩展,我们将探讨如何测试 Angular 路由和导航。
测试组件
在我们进行组件测试之前,我们应该讨论一些关于测试 Angular 组件的观点。我们已经有了一个基本的概念:在 Angular 中,一切都是一些组件的组合。因此,深入了解 Angular 组件测试对我们来说将是非常有益的。
我们可以根据组件的行为和用例以各种方式测试组件。我们甚至可以为多个组件编写测试规范,当它们作为一个应用程序一起工作时。
让我们看看测试组件的一些方法。
隔离测试
隔离测试,也称为单独测试,之所以这样命名,是因为这种测试可以在不需要根据测试规范编译组件的情况下运行。如果它没有编译,它将不会在测试规范中有编译后的模板;只有组件类及其方法。这意味着如果组件的功能不太依赖于 DOM,它可以通过隔离的方式进行测试。
隔离测试主要用于复杂功能或计算测试,其中它只需初始化组件类并调用所有方法。
例如,看看第六章的单元测试,第一步,其中AppComponent负责添加评论和增加它们的点赞数:
beforeEach(() => {
comp = new AppComponent();
comp.add('a sample comment');
comp.like(comp.comments[0]);
});
it('First item in the item should match', () => {
expect(comp.comments[0].title).toBe('a sample
comment');
});
it('Number of likes should increase on like', () => {
expect(comp.comments[0].likes).toEqual(1);
});
浅层测试
隔离测试有时可以满足测试规范的要求,但并不总是如此。大多数时候,组件都有依赖于 DOM 的功能。在这种情况下,在测试规范中渲染组件的模板非常重要,这样我们就有编译后的模板在作用域内,并且测试规范能够与 DOM 交互。
例如,如果我们想为我们的AppComponent编写一个基本的单元测试,该组件主要依赖于 DOM,因为组件类中没有方法,那么我们只需要编译组件并检查它是否已定义。此外,如果组件的模板在<h1>元素内具有正确的文本,我们还可以有一个测试规范。
代码将如下所示:
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AppComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
de = fixture.debugElement.query(By.css('h1'));
});
it('should create and initiate the App component', () => {
expect(comp).toBeDefined();
});
it('should have expected test in <h1> element', () => {
fixture.detectChanges();
const h1 = de.nativeElement;
expect(h1.innerText).toMatch(/My First Angular 2 App/i,
'<h1> should say something about "Angular App"');
});
集成测试
下面是一些关于集成测试的关键点:
-
“集成测试”这个名字应该给我们一些关于它是哪种测试的线索。它与浅层测试类似,因为它也需要编译带有模板的组件并与 DOM 交互。
-
接下来,我们将查看我们的路由和导航测试套件,其中我们将集成
AppComponent、路由器和导航测试套件。 -
我们已经为
AppComponent准备好了测试套件,因为它包括navbar组件和router-outlet组件。所有这些一起工作以满足路由规范。 -
因此,为了获得对路由器的自信测试规范,我们应该选择集成测试。
在接下来的几节中,我们将通过一个详细的示例来查看路由器测试。
注意
集成测试和浅层测试之间的主要区别在于,集成测试适用于完整应用程序的测试套件或应用程序的小部分,其中多个组件协同工作以解决某些目的。它与端到端测试有一些相似之处,但采用不同的方法。
Karma 配置
在前面的章节中,使用了默认的 Karma 配置,但尚未对此默认配置进行解释。文件监视 是一个有用的默认行为,现在将对其进行审查。
文件监视
当使用 Karma init 命令时,默认启用文件监视。Karma 中的文件监视通过在 karma.conf.js 文件中的以下定义进行配置:
autoWatch: true,
文件监视功能按预期工作,监视配置中定义的 files 数组中的文件。当文件更新、更改或删除时,Karma 会通过重新运行测试来响应。从 TDD 的角度来看,这是一个很棒的功能,因为测试将在没有任何手动干预的情况下继续运行。
需要注意的主要点是文件的添加。如果被添加的文件不匹配 files 数组中的标准,autoWatch 参数不会对更改做出响应。例如,让我们考虑以下文件定义:
files : [ 'dir1/**/*.js']
如果是这样,监视器将找到所有以 .js 结尾的文件和子目录文件。如果新文件位于不同的目录中,而不是 dir1 目录中,那么监视器将无法响应新文件,因为它不在配置的目录中。
测试路由器和导航
我们在 第七章 翻转 中介绍了 Angular 路由器和导航,与一般组件一起。
既然我们已经讨论了 Angular 组件、路由器和导航的不同类型的测试,我们将查看集成测试。为此,我们将使用我们的应用程序组件测试,即我们的基础组件,然后我们将集成导航和 router-outlet 组件测试与应用程序组件一起测试路由器。
测试应用程序组件
在我们进行路由器测试之前,我们将为我们的应用程序组件测试做好准备。在应用程序组件测试中,我们将测试组件是否被正确定义和初始化,然后我们将通过选择 DOM 元素来测试页面标题。
我们在前面章节中学习了浅层测试;当我们与 DOM 元素交互时,我们需要浅层测试。这里也是一样:由于我们将不得不处理 DOM 元素,我们将使用浅层测试作为我们的应用程序组件测试。
对于浅层测试,我们需要依赖于 Angular 核心测试中的 TestBed Angular 测试 API,它将用于编译和初始化测试套件中的组件。除此之外,我们还需要依赖于核心测试中的 ComponentFixture 模块。我们还需要从 Angular 核心和平台 API 中获取两个额外的模块,名为 By 和 DebugElement,以与 DOM 元素交互。
我们的组件测试将位于 spec/unit/app.component.ts,其结构如下:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { AppComponent } from '../../app/app.component';
import { NavbarComponent } from '../../app/nav/navbar.component';
import { RouterOutlet } from '@angular/router';
describe('AppComponent test suite', function () {
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
let de: DebugElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AppComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
de = fixture.debugElement.query(By.css('h1'));
});
it('should create and initiate the App component', () => {
expect(comp).toBeDefined();
});
it('should have expected test in <h1> element', () => {
fixture.detectChanges();
const h1 = de.nativeElement;
expect(h1.innerText).toMatch(/My First Angular 2 App/i,
'<h1> should say something about "Angular App"');
});
});
如果我们运行这个测试,我们将看到以下结果:
Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 2 of 2 SUCCESS
(0 secs / 0.522 secs)
我们的应用程序组件测试现在已经准备好了;接下来,我们将执行一个路由测试,包括 router-outlet 和导航。
测试路由
Angular 路由不是 Angular 核心的一部分;它是一个单独的模块,在使用之前必须导入。它有一些指令,如 RouterOutlet 和 RouterLink,在执行路由活动时发挥着积极作用。为了测试路由,我们首先将测试这些指令,以便为测试完整路由准备平台。
小贴士
我们可以使用实际的路由模块来测试路由,但有时它会给整个路由系统带来一些复杂性。因此,测试规范可能会在没有提供准确错误的情况下失败。为了避免这种情况,建议创建路由占位符并使用这些占位符进行路由测试。
路由占位符
我从 Angular 的官方测试文档中得到了路由占位符的想法。我喜欢路由占位符的想法,并从 Angular 的 GitHub 仓库中的 angular.io/public/docs/_examples/testing/ts/testing/router-stubs.ts 复制了 router-stubs 文件。第一个路由占位符指令是 RouterStubLinksDirective,它负责托管元素或锚点链接 (<a>) 以执行指令的 onClick() 方法。绑定到 [routerLink] 属性的 URL 流向指令的 linkParams 属性。当锚点链接 (<a>) 被点击时,应该触发 onClick() 方法,并将其设置为暂定的 navigateTo 属性。
这个 router-stubs 文件依赖于 Angular 路由和相关指令,包括 RouterLink 和 RouterOutlet,因此我们需要导入这些。
因此,路由占位符将位于 spec/unit/stub/router-stub.ts,代码如下:
export {Router, NavigationExtras, RouterLink, RouterOutlet} from '@angular/router';
import { Component, Directive, Injectable, Input } from '@angular/core';
@Directive({
selector: '[routerLink]',
host: {
'(click)': 'onClick()'
}
})
export class RouterLinkStubDirective {
@Input('routerLink') linkParams: any;
navigatedTo: any = null;
onClick() {
this.navigatedTo = this.linkParams;
}
}
除了 RouterLinkStubDirective,这个占位符还应包含 RouterOutletStubComponent 以支持 router-outlet 指令,以及 RouterStub 以支持主路由模块:
@Component({selector: 'router-outlet', template: ''})
export class RouterOutletStubComponent { }
@Injectable()
export class RouterStub {
navigate(commands: any[], extras?: NavigationExtras) { }
}
路由出口和导航测试
如我们所知,router-outlet 和导航 (RouterLink) 菜单与应用程序的着陆页(即我们的应用程序组件)协同工作。测试机制将具有相同的形式。这意味着我们将使用应用程序组件测试这两个模块。
如前所述,我们将在这里使用集成测试进行router-outlet测试。我们已经有了应用程序组件测试套件;现在是时候集成router-outlet和导航(RouterLink),我们将拥有包含router-outlet和RouterLink的应用程序组件的集成测试套件。
我们有navbar组件,它基本上是一个包含导航RouterLink以通过路由进行导航的导航组件。我们必须将该组件导入到我们的测试套件中,以便它能够正确执行。除了实际的路由模块之外,我们还需要导入我们创建的RouterStub。再次强调,router-stubs包含RouterOutletStubComponent和RouterLinkStubDirective组件。
在导入所有必需的组件后,我们将在TestBed配置中声明它们。作为设置的一部分,我们将从测试套件的范围内获取所有navLinks以进行测试并将click事件绑定到linkParams。
测试套件的设置将如下所示:
import { NavbarComponent } from '../../app/nav/navbar.component';
import { AppComponent } from '../../app/app.component';
import { RouterOutletStubComponent, RouterLinkStubDirective } from './stub/router-stubs.js';
describe('AppComponent test suite', function () {
let navDestination:any;
let navLinks:any;
let fixture: ComponentFixture<AppComponent>;
let de: DebugElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AppComponent, NavbarComponent,
RouterLinkStubDirective,
RouterOutletStubComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture.detectChanges();
navDestination = fixture.debugElement
.queryAll(By.directive(RouterLinkStubDirective));
navLinks = navDestination
.map(de => de.injector.get(RouterLinkStubDirective) as
RouterLinkStubDirective);
});
对于测试规范,首先我们将测试导航菜单中的链接参数。我们有navLinks,我们将它们与navLinks的linkParams进行匹配。
然后,我们将测试点击导航菜单项时的预期导航。我们将使用navigatedTo方法进行测试。
我们的测试规范将如下所示:
it('can get RouterLinks from template', () => {
expect(navLinks.length).toBe(3, 'should have 3 links');
expect(navLinks[0].linkParams).toBe('/view1', '1st link should
go to View1');
expect(navLinks[1].linkParams).toBe('/view2', '1st link should
go to View2');
expect(navLinks[2].linkParams).toBe('/members', '1st link should
go to members search page');
});
it('can click nav items link in template and navigate
accordingly', () => {
navDestination[0].triggerEventHandler('click', null);
fixture.detectChanges();
expect(navLinks[0].navigatedTo).toBe('/view1');
navDestination[1].triggerEventHandler('click', null);
fixture.detectChanges();
expect(navLinks[1].navigatedTo).toBe('/view2');
navDestination[2].triggerEventHandler('click', null);
fixture.detectChanges();
expect(navLinks[2].navigatedTo).toBe('/members');
});
因此,我们可以说这将涵盖router-outlet和routerLink的测试,这将确认路由链接按预期工作,并且我们能够在点击导航菜单后通过预期的路由进行导航。
实施集成测试
我们的测试规范已准备就绪。我们一直在计划进行集成测试,现在我们可以进行一次测试。在这里,我们将应用程序组件和navbar组件以及router-outlet和routerLink结合起来,以测试路由和导航。我们必须借助浏览器平台 API 中的debugElement模块与 DOM 元素进行交互。
测试套件已准备就绪--现在是时候运行测试了。
让我们使用以下命令运行它:
npm test
所有测试规范都按预期通过。结果将如下所示:
Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 4 of 4 SUCCESS
(0 secs / 1.022 secs)
更多测试...
我们刚刚添加了一些测试,这些测试将涵盖我们迄今为止开发的一些功能,主要关注路由(router-outlet和routerLink)。
我们将为成员和搜索功能添加更多测试,但我们将更新现有搜索和成员列表功能的操作行为。除此之外,我们的当前代码库在组件功能之间没有适当的解耦,这将使得单独测试功能变得复杂。
我们已经有了端到端测试,它将验证我们从组件中期望的输出,但对于单元测试,我们需要重构代码并解耦它们。在更新行为和重构正确的代码库之后,我们将涵盖其余功能的测试。
应用程序行为概述
让我们快速概述一下搜索应用程序:
-
我们的搜索应用程序在 DOM 中调用
Members组件。它包含两个主要部分:搜索区域和结果区域。 -
在搜索区域,我们输入搜索查询并将其提交到结果区域以获取预期的结果。
-
结果区域根据搜索查询列出成员列表。我们可能已经意识到,我们在
Members组件的初始化期间获取了所有数据;这是因为我们使用ngOnInit()调用Members组件的search()方法,并且它返回所有数据,因为我们的逻辑已经设置为在没有设置搜索查询时返回所有数据。 -
通过点击成员的姓名,我们可以在详情页面上看到该成员的详细信息。
更新应用程序行为
根据之前的规范,似乎我们在搜索功能中存在一些不正确的行为。目前,我们在初始化搜索组件的成员时调用search()。这似乎有点不对;我们应该在输入搜索查询和/或点击搜索按钮后开始搜索。
预期的行为是它将首先加载所有成员数据,然后在开始搜索后,数据列表将根据搜索查询进行更新。
为了做到这一点,让我们更新members.component.ts中的ngOnInit()方法,并添加一个新的方法getMember(),以便在组件初始化时拥有整个数据列表。
预期的更改如下:
ngOnInit() {
this.getMembers();
}
getMembers() {
this.getData()
.then(data => {
data.map(item => {
this.memberList.push(item);
});
})
return this.memberList;
}
search() {
// Do Search
}
识别问题
基于现有代码,看起来我们在members.component.ts和person.component.ts中定义了两次getData()方法,因为在两个组件中我们都需要调用 JSON 数据源来获取成员数据集。
那么,这有什么问题吗?这是不好的做法,因为它重复了代码,而当应用程序变得庞大和复杂时,代码重复难以管理。
例如,现在我们有两个这样的方法:
getData() {
return this.http.get('app/data/people.json')
.toPromise()
.then(response => response.json());
}
如果我们必须更改数据源 URL 或 API,我们就必须在这两个地方更改这个方法。更改两次并不那么困难,但如果是 10-12 次,或者对于更大的应用程序来说更多呢?
是的,这是一个问题,需要解决方案。
寻找解决方案
我们已经确定了问题,即代码重复。我们知道解决方案:我们必须在公共位置编写该方法并在两个组件中使用它。简而言之,我们必须使此方法可重用,以便每个组件都可以共享它。
这看起来很简单,但我们必须以 Angular 的方式来做。我们不能只是将方法移动到单独的文件并导入。
Angular 为这种情况引入了服务。现在我们将通过示例查看一些这些服务。
Angular 服务
Angular 服务是为了在组件之间共享代码而引入的。所以如果我们需要许多组件的代码,建议创建一个单一的可重用服务,并且无论何时需要那段代码,我们都可以将其注入到组件中并按需使用其方法。
服务用于抽象应用程序逻辑。它们用于为特定操作提供单一责任。单一责任允许组件易于测试和更改。这是因为重点是单个组件,而不是所有内部依赖项。
通常,一个服务充当任何应用程序的数据源。每当我们需要一段代码与服务器通信以获取数据(通常是 JSON)时,我们就会使用一个服务。
这是因为大多数组件都需要访问数据,每个人都可以根据需要注入通用服务。因此,我们有一个常用的代码片段,这实际上是我们的应用程序的数据层。我们应该将这些部分移动到服务中,使我们的应用程序更智能,这样我们就可以告诉全世界我们不会以任何方式重复代码。
我们现在有服务了吗?
按照计划,我们已经将getData()方法从members.component.ts和person.component.ts组件移动到了一个新的文件中,这样我们就可以消除代码重复。
在app/services/members.service.ts创建一个新的文件,创建一个新的类以导出,命名为MembersService,并将getData()方法移到那里。除了移动方法外,我们还需要从 Angular HTTP 模块导入{ Http, Response },因为getData依赖于 HTTP。
观察以下代码示例:
import { Http, Response } from '@angular/http';
export class MembersService {
constructor(private http:Http) {
}
getAPIData() {
return this.http.get('app/data/people.json');
}
getData() {
return this.getAPIData()
.toPromise()
.then(response => response.json());
}
}
我们现在有服务了,我们可以开始使用它。让我们导入并使用它来在 Members 组件中。
等一下;在那之前,我们必须将服务导入到应用程序模块中以便识别它。只要它是服务,我们就必须将其标识为提供者;服务将充当服务提供者。
我们的app.module.ts文件将看起来像这样:
import {MembersService} from './services/members.service';
@NgModule({
declarations: [AppComponent, NavbarComponent, ....],
imports : [BrowserModule, FormsModule, ......],
providers : [MembersService],
bootstrap : [AppComponent]
})
现在,为了在组件中使用服务,我们必须导入并使用服务名MembersService将其注入到我们的 MembersComponents 中。只要我们将服务作为组件的构造函数注入,我们就可以在整个组件中使用该服务。要访问方法,我们需要调用它this.membersService.getData()。
因此,我们的 Members 组件将看起来像这样:
import { MembersService } from '../services/members.service';
@Component({
............
})
export class MembersComponent implements OnInit {
constructor(public membersService: MembersService, private router:Router) {
}
getMembers() {
this.membersService.getData()
.then(data => {
data.map(item => {
this.memberList.push(item);
});
})
return this.memberList;
}
是时候运行并查看输出来看看服务是如何与 Members 组件一起工作的。
让浏览器指向http://localhost:3000/members。
哎呀!发生了什么?我们在浏览器控制台中遇到了错误:
Error: (SystemJS) Can't resolve all parameters for MembersService: (?)
根据错误,我们犯了一个错误:SystemJS(用作加载的模块)不能注入MembersService,因为我们没有在服务中添加某些内容来使其完美。在 Angular 中,我们必须在每个服务中说明它是否可注入;如果不这样做,我们就无法将此服务注入到任何组件中。
为了做到这一点,我们必须使用 Angular 的Injectable装饰器。我们将简要地了解一下它。
可注入服务
Injectable 装饰器是 Angular 核心库的一部分,用于创建可注入服务。如果不将其定义为可注入的,就无法识别服务的依赖项。要将其定义为可注入的,我们将在类定义的顶部使用@Injectable()。
代码将看起来像这样:
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
@Injectable()
export class MembersService {
constructor(private http:Http) {
}
getData() {
return this.http.get('app/data/people.json')
.toPromise()
.then(response => response.json());
}
}
我们已经使服务可注入。现在,我们应该可以将其注入到Members组件中,并将浏览器指向http://localhost:3000/members。
哈喽!没有更多错误,我们正在获取预期的数据列表:

看起来我们的服务是可注入的并且运行良好。现在是时候将其实现到PersonComponent中,因为我们也需要在那个组件上使用数据服务。和Members组件一样,让我们使用服务名membersService将其导入并注入到PersonComponent中。再次,我们将不得不使用this.membersService.getData()来访问数据服务的方法。
我们的PersonComponent将看起来像这样:
import { MembersService } from '../../services/members.service';
@Component({
...........
})
export class PersonComponent implements OnInit {
constructor(public membersService: MembersService, private route: ActivatedRoute, private router: Router) {
}
....................
getPerson(id:number) {
return this.membersService.getData()
.then(data => data.find(member => member.id === id));
}
}
是时候运行并查看服务与Members组件一起工作的输出了。
我们有我们的端到端测试,它将确认新的更改是否一切顺利:
$ npm run e2e
是的,一切通过成功:

哈喽!我们的代码重构没有影响我们的预期行为。
服务将为您带来更多
为了获得服务的全部好处,我们将从Members和Person组件中移动两个更多的方法。在此之前,这些方法是组件特定的;现在,通过将它们添加到服务中,这些方法可以通过注入服务从任何组件中使用。
也许我们将来会从这次更改中受益,但想保持这些方法与组件解耦。
新增的代码将看起来像这样:
@Injectable()
export class MembersService {
constructor(private http:Http) {
}
............
searchQuery(q:string) {
if (!q || q === '*') {
q = '';
} else {
q = q.toLowerCase();
}
return this.getData()
.then(data => {
let results:any = [];
data.map(item => {
if (JSON.stringify(item).toLowerCase().includes(q)) {
results.push(item);
}
});
return results;
});
}
getPerson(id:number) {
return this.getData()
.then(data => data.find(member => member.id === id));
}
}
测试服务
代码解耦和分离背后的目标是使代码可测试。我们做到了,我们将数据检索部分从Members组件中分离出来,并创建了一个服务,这样它将很容易进行测试。服务是可注入的;除此之外,它和 Angular 组件类似。因此,为了执行单元测试,我们将测试服务包含的方法。
测试服务注入
和其他 Angular 组件一样,我们可以测试服务是否定义良好。但主要区别在于,只要服务是可注入的,我们就需要在测试规范中注入它以获取要测试的实例。
对于一个示例测试规范,我们可以这样设置:它会导入 TestBed 和 inject,然后使用 MembersService 作为提供者配置 TestingModule。然后,在测试规范中,我们将注入服务并检查服务是否按预期定义。
我们的示例测试套件将如下所示:
import { inject, TestBed } from '@angular/core/testing';
import { MembersService } from '../../app/services/members.service';
describe('Given service should be defined', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MembersService,
],
});
});
it('should initiate the member service', inject([MembersService], (membersService) => {
expect(membersService).toBeDefined();
}));
});
对于这个测试,预期的结果将是 true。
测试 HTTP 请求
为了进行 HTTP 请求的单元测试,我们必须使用异步技术来保持 HTTP 调用异步,在 Angular 测试中,我们将使用 fakeAsync 模块,这是一个用于模拟 HTTP 请求的异步模块。
等等,“模拟”?
嗯,是的;为了在 Angular 测试套件中测试 HTTP 请求,我们不需要进行实际的 HTTP 请求。为了达到 HTTP 请求的效果,我们可以模拟我们的 HTTP 服务;Angular 已经提供了一个名为 MockBackend 的模拟服务。
MockBackend 是一个可以被配置为提供 HTTP 模拟请求的模拟响应的类,并且它将像 HTTP 服务一样工作,但不会进行实际的网络请求。
在我们配置了 MockBackend 之后,它可以被注入到 HTTP 中。因此,从我们使用 http.get 的服务中,我们将得到预期的数据返回。
我们的带有 HTTP 请求的测试套件将如下所示:
import { fakeAsync, inject, TestBed } from '@angular/core/testing';
import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend, MockConnection } from '@angular/http/testing';
import { MembersService } from '../../app/services/members.service';
const mockData = {
"id": 2,
"name": "Demaryius Thomas",
"phone": "(720) 213-9876",
"address": {
"street": "5555 Marion Street",
"city": "Denver",
"state": "CO",
"zip": "80202"
}
};
describe('Given service should be defined and response HTTP request', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MembersService,
BaseRequestOptions,
MockBackend,
{
provide: Http,
useFactory: (backend, defaultOptions) => {
return new Http(backend, defaultOptions);
},
deps: [MockBackend, BaseRequestOptions],
},
],
});
});
});
在这里,首先,除了导入 MockBackend,我们还导入了 MockConnection,它用于订阅后端连接并提供连接数据给下一步。然后,我们配置 MockBackend,它将返回 HTTP 对象。
接下来,我们将通过注入 MockBackend 和 MembersService 准备我们的测试规范:
it('should return response when subscribed to getUsers', fakeAsync(
inject([MockBackend, MembersService], (backend, membersService) => {
backend.connections.subscribe(
(c: MockConnection) => {
c.mockRespond(
new Response(
new ResponseOptions({ body: mockData })
));
});
membersService.getAPIData().subscribe(res => {
expect(res.json()).toEqual(mockData);
});
})));
});
在测试规范中,我们注入了 MockBackend,除了 MembersService。MockBackend 将使用 MockConnection 对象订阅 backend 服务。MockConnection 将创建一个新的 ResponseOptions 对象,其中,我们可以使用 ResponseOptions 对象来配置我们的响应属性。
在这里,我们只设置了响应对象的 body 属性,并将 body 值设置为预定义的 mockData 对象。
服务模拟
我们可以使用模拟数据测试服务。例如,我们可以创建一个名为 MembersServiceSpy 的模拟 MembersService 版本,它将模拟该服务所需的所有必要功能。
这个模拟服务将返回一个带有模拟数据的已解析 Promise,因此我们可以直接使用这个模拟方法进行测试。它将为服务中的所有方法创建一个间谍,并为每个单独的方法返回一个单独的 Promise。
模拟服务将位于 spec/unit/stub/members.service.stub.ts,如下所示:
import { Component, Directive, Injectable, Input } from '@angular/core';
export class MembersServiceSpy {
members = {
"id": 2,
"name": "Demaryius Thomas",
"phone": "(720) 213-9876",
"address": {
"street": "5555 Marion Street",
"city": "Denver",
"state": "CO",
"zip": "80202"
}
};
getData = jasmine.createSpy('getData').and.callFake(
() => Promise
.resolve(true)
.then(() => Object.assign({}, this.members))
);
getPerson = jasmine.createSpy('getPerson').and.callFake(
() => Promise
.resolve(true)
.then(() => Object.assign({}, this.members))
);
searchQuery = jasmine.createSpy('searchQuery').and.callFake(
() => Promise
.resolve(true)
.then(() => Object.assign({}, this.members))
);
}
使用模拟数据的 Service 测试
在这里,我们将使用模拟数据测试 MembersService。为此,我们需要导入模拟服务。并且使用 TestBed 配置,我们必须提供 MemberServiceSpy 作为服务而不是实际的成员服务。
MembersService 测试套件的代码将如下所示:
import { MembersServiceSpy } from './stub/members.service.stub.js';
import { MembersService } from '../../app/services/members.service';
const mockData = {
"id": 2,
"name": "Demaryius Thomas",
"phone": "(720) 213-9876",
"address": {
"street": "5555 Marion Street",
"city": "Denver",
"state": "CO",
"zip": "80202"
}
};
describe('Given service will response for every method', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: MembersService, useClass: MembersServiceSpy }]
});
});
it('should return data', fakeAsync(inject(
[MembersService], (service) => {
service.getData();
expect(service.members).toEqual(mockData);
})));
it('should return data', fakeAsync(inject(
[MembersService], (service) => {
service.searchQuery('Thomas');
expect(service.members.name).toBe('Demaryius Thomas');
})));
it('should return data', fakeAsync(inject(
[MembersService], (service) => {
service.getPerson(2);
expect(service.members.id).toBe(2);
})));
});
组合和运行服务的测试
我们在这里有两个成员服务的测试套件。我们可以将它们合并在一起并运行测试。
完整测试套件的代码将如下代码片段所示:
import { fakeAsync, inject, TestBed } from '@angular/core/testing';
import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend, MockConnection } from '@angular/http/testing';
import { MembersServiceSpy } from './stub/members.service.stub.js';
import { MembersService } from '../../app/services/members.service';
const mockData = {
"id": 2,
"name": "Demaryius Thomas",
"phone": "(720) 213-9876",
"address": {
"street": "5555 Marion Street",
"city": "Denver",
"state": "CO",
"zip": "80202"
}
};
describe('Given service should be defined and response HTTP request', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MembersService,
BaseRequestOptions,
MockBackend,
{
provide: Http,
useFactory: (backend, defaultOptions) => {
return new Http(backend, defaultOptions);
},
deps: [MockBackend, BaseRequestOptions],
},
],
});
});
it('should initiate the member service', inject([MembersService], (membersService) => {
expect(membersService).toBeDefined();
}));
it('should return response when send HTTP request', fakeAsync(
inject([MockBackend, MembersService], (backend, membersService) => {
backend.connections.subscribe(
(c: MockConnection) => {
c.mockRespond(
new Response(
new ResponseOptions({ body: mockData })
));
});
membersService.getAPIData().subscribe(res => {
expect(res.json()).toEqual(mockData);
});
})));
});
describe('Given service will response for every method', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: MembersService, useClass: MembersServiceSpy }]
});
});
it('should return data', fakeAsync(inject(
[MembersService], (service) => {
service.getData();
expect(service.members).toEqual(mockData);
})));
it('should return data', fakeAsync(inject(
[MembersService], (service) => {
service.searchQuery('Thomas');
expect(service.members.name).toBe('Demaryius Thomas');
})));
it('should return data', fakeAsync(inject(
[MembersService], (service) => {
service.getPerson(2);
expect(service.members.id).toBe(2);
})));
});
成员服务的测试套件已准备好运行。让我们用这个命令运行它:
npm test
所有测试规范都按预期通过。结果将如下所示:
Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 9 of 9 SUCCESS
(0 secs / 4.542 secs)
通过事件的力量进行通信
与 Angular 1.x 相比,Angular 具有更强大的事件处理能力。Angular 1.x 具有双向数据绑定,而 Angular 不推荐这样做。Angular 通过事件的力量处理数据和模板之间的通信。
Angular 项目建立在一些组件的组合之上。为了运行,这些组件需要相互通信以共享数据和事件。通常,当组件具有父子关系时,它们需要通信。Angular 在父组件和子组件之间通信的方式有几种。其中最好的方式是通过处理自定义事件。我们将查看自定义事件的详细信息,并了解它们如何与我们的搜索应用程序一起工作。
Angular 事件
如我们所知,Angular 推荐单向数据绑定,这意味着只有从组件到 DOM 元素。这是一种单向数据流,这也是 Angular 的工作方式。那么当我们需要从 DOM 元素到组件的数据流时怎么办?这样做取决于不同的事件,如点击、按键、鼠标悬停和触摸。这些事件将与 DOM 元素绑定,以监听用户操作并将该操作传递给组件。
事件绑定语法由目标事件组成,目标事件位于等号左侧的括号内。组件将目标事件作为方法包含,因此每当事件触发时,它将调用组件中的方法。让我们看看搜索表单中的事件:
<button type="button" (click)="search()">Search</button>
任何元素的任何事件都是常见的目标,但在 Angular 中略有不同,因为 Angular 首先检查目标名称是否与任何已知指令或组件的事件属性匹配。
Angular 中的自定义事件
自定义事件由 Angular EventEmitter的指令或组件引发。指令创建一个EventEmitter对象,并通过@Output装饰器将其自身作为属性公开。我们将接下来查看@Output装饰器的详细信息。在将EventEmitter对象公开为属性之后,指令将调用EventEmitter.emit(value)来触发事件并将值传递给父级指令。
自定义指令/组件类将如下定义自定义事件:
@Output() someCustomEvent: EventEmitter<any> = new EventEmitter();
this.someCustomEvent.emit(value);
父级指令将通过绑定到这个属性来监听事件,并通过$event对象接收值。
父级指令/组件将包含自定义指令,如下所示,其中它将包含自定义事件someCustomEvent,这将触发父级指令的doSomething()方法:
<custom-component (someCustomEvent)="doSomething($event)"></custom-component>
父指令/组件将包含doSomething()方法,如下所示:
doSomething(someValue) {
this.value = someValue;
}
输出和EventEmitter API
输出是 Angular 核心中的一个装饰器类,用于从子组件传递自定义事件到父组件。要使用它,我们需要从@angular/core导入它。
当我们将自定义事件设置为@Output时,该事件将在父组件中可用以进行监听。此装饰器将放置在类内部,如下所示:
export class SearchComponent {
@Output() someCustomEvent: EventEmitter<any> = new EventEmitter();
}
EventEmitter也是 Angular 的一个核心类。当我们需要使用它时,我们必须从@angular/core导入它。EventEmitter API 用于在子组件中的值发生变化时通过调用EventEmitter.emit(value)来通知父组件。正如我们所知,父组件始终监听自定义事件。
进一步规划改进
我们目前拥有的搜索应用程序是一个简单的搜索应用程序。但我们可以通过保持其简单性来使其变得更好。我的意思是,我们可以以最佳方式做到这一点,就像我们试图通过将可重用代码分离到新服务中来解耦数据逻辑一样。
我们还有一些其他的事情要改进。看起来我们的应用程序还没有完全解耦。我们的组件没有像预期的那样解耦。我们正在谈论包含搜索功能和成员列表功能的MembersComponent。
我们将遵循单一职责原则,这意味着每个组件都应该有一个单一职责。在这里,MembersComponent有两个。因此,我们应该将这个组件分解为两个单独的组件。
让我们将它分解为两个单独的组件,分别称为MembersComponent和SearchComponent。实际上,我们只是为新组件SearchComponent制定了一个计划,并将搜索功能从成员组件中迁移到那里。
现在,让我们为两个组件预期的行为制定一个计划:
-
搜索组件将负责接收用户输入作为搜索查询,并使用我们拥有的服务获取预期的搜索结果。
-
然后,我们将搜索结果传递给成员组件
-
成员组件将从搜索组件获取搜索结果,并将数据列表绑定到 DOM
-
这两个组件将通过事件进行通信和交换数据
计划是通过遵循最佳实践和使用 Angular 的内置功能来使这个简单的应用程序完美。
搜索组件
如计划所示,我们必须将搜索功能从成员组件中分离出来。为此,让我们在app/search/search.component.ts中创建一个新的组件SearchComponent,并创建搜索组件的模板文件。模板文件将简单地包含搜索表单。
搜索组件文件将需要导入和注入MembersService,因为这将用于根据搜索查询执行搜索。组件将包含搜索查询,并将请求服务进行搜索并获取搜索结果。
搜索组件的代码将看起来像这样:
import { Component } from '@angular/core';
import { MembersService, Person } from '../services/members.service';
@Component({
selector: 'app-search',
moduleId: module.id,
templateUrl: 'search.component.html'
})
export class SearchComponent {
query: string;
memberList: Array<Person> = [];
constructor(public membersService: MembersService) {
}
search() {
this.doSearch();
}
doSearch(): void {
this.membersService.searchQuery(this.query)
.then(results => {
this.memberList = results;
});
}
}
搜索组件的模板将看起来像这样:
<form>
<input type="search" [(ngModel)]="query" name="query" (keyup.enter)="search()">
<button type="button" (click)="search()">Search</button>
</form>
只要我们的应用程序输出没有中断,我们就必须将搜索组件绑定到成员列表页面,就像之前一样。所以,我们必须将搜索组件添加到成员组件的模板中。在这种情况下,它将成为成员组件的子组件。
成员组件的模板将看起来像这样:
<h2>Members</h2>
<app-search></app-search>
<table *ngIf="memberList" id="searchList">
......
</table>
启用组件间的共享
现在我们有两个独立的组件,搜索和成员组件。搜索组件已经被添加到成员组件中,但搜索结果在成员组件中不可用。
搜索和成员是独立的组件,它们之间没有桥梁。两者都有一个独立的范围来包含它们的元素和变量。
要在组件间共享数据,我们需要启用它们之间的通信。如前所述,Angular 事件将帮助我们启用搜索和成员组件之间的通信。从搜索组件出发,我们需要使用 Angular 自定义事件与其父组件MembersComponent通信。
与父组件通信
搜索组件是成员组件的子组件。它们需要相互通信以共享数据。我们将需要使用 Angular 的EventEmitter API 来帮助使用自定义事件,这样我们就可以在得到结果后从搜索组件中发出搜索结果。除此之外,我们还需要使用@OutPut装饰器将搜索结果设置为输出,以便用于父组件。
要使用这两个组件,我们需要从 Angular core 中导入它们。然后,我们需要将@Output的searchResult设置为一个新的EventEmitter实例。这个@Output装饰器使得searchResult属性可以作为事件绑定使用。
当搜索组件更新搜索结果时,我们希望通知父组件searchResult事件已经发生。为此,我们需要调用emit(data),其中searchResult是我们已声明的带有@Output装饰器的 Emitter 对象。emit()方法用于在每次通过自定义事件传递结果时通知。
现在,成员组件可以获取$event对象,因为我们已经通过(searchRessult)="anyMethod($event);"将其传递到模板中。
在更新了EventEmitter之后,搜索组件将看起来像这样:
import { Component, Output, EventEmitter } from '@angular/core';
@Component({
...................
})
export class SearchComponent {
.............
@Output() searchResult: EventEmitter<any> = new EventEmitter();
doSearch(): void {
this.membersService.searchQuery(this.query)
.then(results => {
this.memberList = results;
this.searchResult.emit(this.memberList));
});
}
}
现在是时候与MembersComponent通信了。让我们在成员组件中声明onSearch()方法,它将接受事件作为参数。
成员组件将变为以下形式:
export class MembersComponent implements OnInit {
ngOnInit() {
this.getMembers();
}
onSearch(searchResult) {
this.memberList = searchResult;
}
getMembers() {
this.membersService.getData()
.then(data => {
data.map(item => {
this.memberList.push(item);
});
})
return this.memberList;
}
}
由于我们是从成员模板中添加搜索组件,所以让我们将onSearch函数连接到搜索组件标签。我们将用括号包围它,命名为(searchResult)来告诉 Angular 这是一个事件绑定。
搜索组件的模板将如下所示:
<h2>Members</h2>
<app-search (searchResult)="onSearch($event)" ></app-search>
<table *ngIf="memberList" id="searchList">
......
</table>
检查重构后的输出
搜索应用程序将被重新命名为商店应用程序,而不是重写已经编写好的搜索功能。为了利用现有的搜索项目,它将被复制到一个新的项目文件中。然后,新项目将使用测试来驱动开发更改和重构。重构步骤已被省略,但代码审查将显示代码和测试是如何被修改以创建产品应用程序的。
是时候运行它并查看服务如何与 Members 组件一起工作了。让我们将浏览器指向 http://localhost:3000/members。

我们有端到端测试,它将确认新更改一切正常:
$ npm run e2e
是的,我们可以看到一切都成功通过:

是的!我们的代码重构没有影响我们预期的行为。
当前项目目录
我们已经更新和重构了代码,为此我们有一些新的组件、服务等等。现在,我们将有一个新的项目结构,将逻辑分离并解耦组件。
我们当前的目录结构如下所示:

接下来
在这本书中,我试图将主题覆盖到一定水平,以便任何人都可以从基于 Angular 的测试驱动开发开始。但是,我们有很多东西都跳过了,最重要的是 rxJS。
rxJS 是基于响应式编程的一个独立模块。因此,我们需要熟悉响应式编程才能理解它。
可观察对象
默认情况下,Angular 中的 HTTP 请求返回可观察对象作为响应,而不是解析的承诺。由于我们没有在这里查看 rxJS,我们跳过了可观察对象并将响应转换为承诺。但我们应该学习可观察对象如何在 Angular 中工作。
发布和订阅
发布和订阅消息是一个强大的工具,但就像任何事物一样,如果使用不当,可能会导致混乱。
消息可以通过两种方式发布:emit 或 broadcast。了解它们之间的区别很重要,因为它们的工作方式略有不同,可能会影响我们应用程序的性能。
自我测试问题
Q1. 回调函数是指在异步函数完成后被调用的函数。
-
正确
-
错误
Q2. 异步函数总是按照它们被调用的顺序完成。
-
正确
-
错误
Q3. 有一个名为 MockBackend 的模块可以在 Angular 中模拟 HTTP 调用以进行单元测试。
-
正确
-
错误
Q4. 在 Angular 中,EventEmitter API 用于组件通信。
-
正确
-
错误
摘要
在本章中,我们探讨了 Angular 中的服务和事件的力量。我们还看到了一些使用服务和事件分离代码的例子。
此外,我们研究了 Angular 组件的不同测试类型,并为 Angular 路由编写了单元测试,并将其与应用程序组件和导航集成。我们还进一步探索了 Karma 的配置,以便使用其功能。
现在我们已经到达了本书的结尾,是时候将我们在现实世界中的知识付诸实践了。在离开之前,让我们快速回顾一下我们已经学到的内容。我们学习了测试驱动开发(TDD),TDD 如何与 JavaScript 上下文协同工作,以及可用的测试工具、技术和框架。我们还通过实际的 Angular 项目学习了 Karma 和 Protractor。现在我们知道了如何为 Angular 项目编写单元测试和端到端测试。
本书向您展示了实践 TDD 的路径;现在,你的任务是继续学习,提高这方面的知识,并通过更复杂的项目进行更多实践,以便对 TDD 更加自信。


浙公网安备 33010602011771号