Angular 测试驱动开发第二版(二)
原文:
zh.annas-archive.org/md5/2587abd8d5ac1ecccf1401601540e791译者:飞龙
第四章。使用 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 路由。