Angular-测试驱动开发-二-

28 阅读1小时+

Angular 测试驱动开发(二)

原文:zh.annas-archive.org/md5/60F96C36D64CD0F22F8885CC69A834D2

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:Protractor,更进一步

端到端测试真的很有趣,只要直接与浏览器交互,但是一个好的开发者应该了解 Protractor 的高级功能,以进行大规模的应用程序测试。此外,在端到端测试中调试是一种挑战,因为它取决于浏览器的 DOM 元素。

Protractor 有一些用于调试的 API。本章将主要涵盖这些 API 和功能,包括以下内容:

  • 设置和配置 Protractor

  • 一些高级的 Protractor API,比如 browser,locator 和 action

  • 使用browser.pause()browser.debug()API 来调试 Protractor

高级设置和配置

在上一章中,我们看到了 Protractor 的基本和常用的设置和配置。在这里,我们将看一些高级配置,使安装更简单和更强大。

全局安装 Protractor

以下是全局安装 Protractor 的步骤:

  1. 一旦 Node.js 被安装并在命令提示符中可用,输入以下命令在系统上全局安装 Protractor:
**$ npm install -g protractor**

上一条命令使用了 Node 的npm命令全局安装 Protractor,这样我们就可以只用protractor命令来使用 Protractor 了。

  1. 测试 Protractor 版本是否可以如下确定:
**$ protractor --version**

高级配置

在本节中,我们将使用以下步骤对 Protractor 进行更详细的配置:

  1. 更新 protractor 的config文件以支持单个测试套件中的多个浏览器。multiCapabilities参数是一个数组,可以为任何测试套件传递多个browserName对象,如下所示:
        exports.config = {  
          //...  
        multiCapabilities: [{
         'browserName': 'firefox' 
        }, { 
         'browserName': 'chrome' 
        }]
        //... };
  1. 我们可以在capabilities参数中为浏览器设置高级设置;例如,对于chrome,我们可以传递额外的参数作为chromeOptions,如下所示:
        exports.config = {  
          //...  
          capabilities: { 
            'browserName': 'chrome'
            'chromeOptions': {
              'args': ['show-fps-counter=true']
            }}]
        //... };
  1. 有时,我们可能需要直接运行 Protractor 而不使用 Selenium 或 WebDriver。这可以通过在config.js文件中传递一个参数来实现。该参数是配置对象中的directConnect: true,如下所示:
        exports.config = { 
          //... 
          directConnect: true, 
          //... 
        }; 

太棒了!我们已经配置了 Protractor 更进一步。

Protractor API

端到端测试任何网页的主要活动是获取该页面的 DOM 元素,与它们交互,为它们分配一个动作,并与它们共享信息;然后,用户可以获取网站的当前状态。为了使我们能够执行所有这些操作,Protractor 提供了各种各样的 API(其中一些来自 web driver)。在本章中,我们将看一些常用的 API。

在上一章中,我们看到了 Protractor 如何与 Angular 项目一起工作,我们需要与 UI 元素进行交互。为此,我们使用了一些 Protractor API,比如element.allby.cssfirstlastgetText。然而,我们没有深入了解这些 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 

要获取当前页面的网址,使用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; 

Elements

提示

Protractor 本身暴露了一些全局函数,element就是其中之一。这个函数接受一个定位器(一种选择器--我们将在下一步中讨论),并返回一个ElementFinder。这个函数基本上是根据定位器找到单个元素,但它支持多个元素的选择,以及链式调用另一个方法element.all,它也接受一个定位器并返回一个ElementFinderArray。它们都支持链式方法进行下一步操作。

element.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

element类返回ElementFinder,这意味着元素数组中的单个元素,它也支持链接方法以进行下一个操作。在前面的示例中,我们看到了如何从元素数组中获取单个选择的元素,以便所有链接方法也适用于该单个元素。有许多用于操作单个元素的链接方法,我们将看一些最常用的方法。

通过将特定的定位器作为参数传递给element方法,我们可以选择单个 DOM 元素,如下所示:

element(Locator); 
var elementObj = element(by.css('.selector'));  // return the ElementFinder based on locator  

获取特定的单个元素后,我们可能需要找到该元素的子元素,然后使用element.all方法与重新运行的elementFinder对象链接。为此,将特定的定位器传递给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)); 

我们可以通过将模型名称定义为ng-model传递给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 测试-事后分析

调试 e2e 测试有点困难,因为它们依赖于应用程序的整个生态系统。有时它们依赖于先前的操作,比如登录,有时它们依赖于权限。调试 e2e 的另一个主要障碍是它依赖于 WebDriver。由于它在不同的操作系统和浏览器上的行为不同,这使得调试 e2e 变得困难。除此之外,它生成了很长的错误消息,这使得很难区分与浏览器相关的问题和测试过程中的错误。

尽管如此,我们将尝试调试所有的 e2e 测试,看看对我们的情况有何作用。

失败类型

测试套件失败可能有各种原因,因为它依赖于 WebDriver 和系统中的各个部分。

让我们看看一些已知的失败类型:

  • WebDrive 失败:当命令无法完成时,WebDriver 会抛出错误。例如,浏览器无法获取定义的地址来帮助它导航,或者可能找不到预期的元素。

  • WebDriver 意外失败:有时,WebDriver 会因无法更新 Web 驱动程序管理器而失败并报错。这是一个与浏览器和操作系统相关的问题,尽管不常见。

  • Angular 的 Protractor 失败:当 Protractor 在库中找不到预期的 Angular 时,Protractor 会失败,因为 Protractor 测试依赖于 Angular 本身。

  • Protractor Angular2 失败:当配置中缺少useAllAngular2AppRoots参数时,Protractor 将在 Angular 项目的测试规范中失败,因为没有这个参数,测试过程将只查看一个单一的根元素,而期望在过程中有更多的元素。

  • 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: 使用定位器未找到元素:By (css 选择器, *[id="my_id"])

现在,让我们看看当element将被找到时,交互模式如何为有效的定位器工作:

> element.all(by.css('li')).first().getText() 

结果测试

使用调试器

使用 browser.debugger() 命令进行调试比使用 browser.pause() 更复杂和更高级。使用 browser.pause() 命令,我们可以暂停测试的控制流,并将自定义辅助函数注入到浏览器中,以便调试的方式与我们在浏览器控制台中调试的方式相同。

这种调试应该在节点调试模式下进行,就像在 Protractor 调试中一样。这种调试对于不擅长节点调试的人来说并不有用。

这是一个例子:

要在测试规范中使用 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 是一个浏览器自动化框架。

Q2. 使用 browser.debugger() 是调试 Protractor 的一种简单方法。

Q3. by.css()by.id()by.buttonText() 被称为什么?

  • 元素

  • 定位器

  • 操作

  • 浏览器

摘要

Protractor 有各种类型的 API。在本章中,我们试图了解一些最常用的 API,并提供了一些示例。我们还详细介绍了 API 类型(如浏览器、元素、定位器和操作),以及它们如何相互链接。

在本章中介绍了调试,并尝试学习了一种简单的调试方法,使用 browser.pause(),然后我们转向了一种复杂的方法(browser.debugger()),并了解到复杂的开发人员需要节点调试器经验。

在下一章中,我们将深入研究更多的现实项目;此外,我们将学习自上而下和自下而上的方法,并学会它们。

第六章:第一步

第一步总是最困难的。本章提供了如何使用 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项目已经将测试规范放在默认文件夹中,但我们将有一个新的结构,并将我们的测试文件放在新的结构中。

让我们开始设置项目目录:

  1. 导航到项目的根文件夹:
        **cd angular-project**

  1. 初始化测试(spec)目录:
        **mkdir spec**

  1. 初始化unit测试目录:
        **mkdir spec/unit**

  1. 初始化端到端(e2e)测试目录:
        **mkdir spec/e2e**

初始化完成后,我们的文件夹结构应如下所示:

设置目录

设置 Karma

Karma 的详细信息可以在第三章中找到,Karma 之道。在这里,我们将主要看一下 Karma 配置文件。

在这个quickstart项目中,我们已经安装并配置了 Karma,并且在项目目录中有karma.conf.js文件。

为了确认系统中有 Karma,让我们使用以下命令在全局安装它:

**npm install -g karma**

如前所述,我们已经在这个项目中配置了 Karma 作为quickstart项目的一部分,并且我们在项目目录中有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。因此,我们只需要查看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', () => { 
            // ... 
        }); 
    }); 
}); 

遵循 3A 原则(组装、行动、断言),我们将把用户场景放入模板中。

组装

浏览器将需要指向应用程序的第一个页面。由于基本 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'); 
  }); 
    }); 
}); 

使其运行

基于测试的错误和输出,我们将在构建应用程序的过程中进行。

使用以下命令启动 Web 服务器:

**$ npm start**

运行 Protractor 测试以查看第一个错误:

**$ protractor**

或者,我们可以运行这个:

**$ npm run e2e // run via npm** 

我们的第一个错误可能是没有得到定位器期望的元素:

**$ Error: Failed: No element found using locator: 
    By(css selector, input)**

错误的原因很简单:它没有按照定位器中定义的元素获取。我们可以看到当前的应用程序以及为什么它没有获取到元素。

总结当前应用程序

只要我们将示例 Angularquickstart项目克隆为我们要测试的应用程序,它就具有一个准备好的 Angular 环境。它使用一个简单的应用程序组件定义了“我的第一个 Angular 2 应用程序”作为输出来引导 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); 

测试找不到我们的输入定位器。我们需要将输入添加到页面,并且我们需要通过组件的模板来做到这一点。

添加输入

以下是我们需要遵循的步骤来将输入添加到页面:

  1. 我们将不得不在应用程序组件的模板中添加一个简单的input标签,如下所示:
        template: ` 
        <input type='text' />` 

  1. 再次运行测试后,似乎与输入定位器相关的错误已经没有了,但是出现了一个新的错误,即button标签丢失:
        **$ Error: Failed: No element found using locator: 
        by.buttonText('Submit')**

  1. 就像之前的错误一样,我们需要在模板中添加一个button,并附上适当的文本:
        template: ` ...........  
        <button type='button'>Submit</button>` 

  1. 再次运行测试后,似乎没有与button定位器相关的错误,但是又出现了新的错误,如下所示,重复器定位器丢失:
        **$ Error: Failed: No element found using locator: By
        (css selector, li)**

这似乎是我们假设提交的评论将通过*ngFor在页面上可用的结果。为了将其添加到页面上,我们将在组件类中使用一个方法来为重复器提供数据。

组件

如前所述,错误是因为没有comments对象。为了添加comments对象,我们将使用具有comments数组的组件类。

执行以下步骤将comments对象添加到作用域中:

  1. 由于我们已经在组件中有AppComponent作为一个类,我们需要定义评论数组,我们可以在重复器中使用:
        export class AppComponent { 
            comments:Array<string>; 
        } 

  1. 然后,我们将在模板中为评论添加一个重复器,如下所示:
        template: `..........  
            <ul> 
              <li *ngFor="let comment of comments">{{comment}}</li> 
            </ul>` 

  1. 让我们运行 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更改为a sample 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 生命周期(先测试,使其运行,然后使其更好)。

配置卡尔玛

我们在第三章中为待办事项列表应用程序做了非常类似的事情,“卡尔玛方式”。我们不会花太多时间深入到代码中,所以请查看以前的章节,以深入讨论一些属性。

以下是我们需要遵循的配置卡尔玛的步骤:

  1. 使用添加的文件更新files部分:
        files: [ 
            ... 
            // Application files 
            {pattern: 'app/**/*.js', included: false, watched: 
            true} 

            // Unit Test spec files 
            {pattern: 'spec/unit/**/*.spec.js', included: false,
            watched: true} 
            ... 
        ], 

  1. 启动卡尔玛:
        **$ karma start**

  1. 确认卡尔玛正在运行:
        **$ 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('', () => { 
      }); 
    }); 

根据规范,当单击“提交”按钮时,需要添加评论。我们需要填写测试的三个组成部分(组装、行动和断言)的空白。

组装

行为需要成为前端组件的一部分来使用。在这种情况下,测试的对象是组件的范围。我们需要将这一点添加到这个测试的组装中。就像我们在第三章中所做的那样,“卡尔玛方式”,我们将在以下代码中做同样的事情:

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; 

    }); 
}); 

现在,component对象及其成员在测试套件中可用,并将如预期般进行测试。

行动

规范确定我们需要在组件对象中调用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函数。让我们继续按照以下步骤将add函数放入控制器的scope对象中:

  1. 打开控制器范围并创建一个名为add的函数:
        export class AppComponent { 
            ............. 
            add() { 
            // .... 
            } 
        } 

  1. 检查 Karma 的输出,让我们看看我们的进展:
        **$ Expected 'First comment' to be 'a sample comment'.**

  1. 现在,我们已经达到了期望。记住要考虑最小的改变来使其工作。修改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.tsadd函数,以接受一个参数并使用该参数添加到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)事件。添加行为到提交按钮的步骤如下:

  1. 打开app.component.ts文件并进行以下更新:
        @Component({ 
           template: `....... 
            <button type="button" (click)="add('a sample      
            comment')">Submit</button> 
            ...........` 
        }) 

等等!这个值是硬编码的吗?好吧,我们再次希望做出最小的更改,并确保测试仍然通过。我们将不断进行重构,直到代码达到我们想要的状态,但我们不想采取大爆炸的方式,而是希望进行小的、增量的改变。

  1. 现在,让我们重新运行 Protractor 测试,并确保它仍然通过。输出显示它通过了,我们没问题。硬编码的值没有从注释中删除。让我们继续并立即删除它。

  2. AppComponent 类文件现在应该如下所示:

        constructor() { 
            this.comments = []; 
        } 

  1. 运行测试,看到我们仍然得到一个通过的测试。

我们需要清理的最后一件事是 (click) 中的硬编码值。添加的评论应该由评论输入文本中的输入确定。

绑定输入

以下是我们需要遵循的绑定输入的步骤:

  1. 为了能够将输入绑定到有意义的东西,将 ngModel 属性添加到 input 标签中:
        @Component({ 
            template: `............. 
            <input type="text" [(ngModel)]="newComment"> 
            ...........` 
        }) 

  1. 然后,在 (click) 属性中,简单地使用 newComment 模型作为输入:
        @Component({ 
           template: `....... 
            <button type="button" (click)="add(newComment)">
            Submit</button> 
            ...........` 
        }) 

  1. 我们将不得不在应用程序模块(app.module.ts)中导入表单模块,因为它是 ngModel 的依赖项:
        import { FormsModule }   from '@angular/forms'; 
        @NgModule({ 
        imports: [ BrowserModule, FormsModule ], 
        }) 

  1. 运行 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', () => { 
    ... 
    }); 
}); 

行动

我们测试的用户规范是Like按钮对特定评论执行操作。以下是需要的步骤和执行它们所需的代码(请注意,以下步骤将添加到 beforeEach 文本中):

  1. 存储第一条评论,以便在测试中使用:
        var firstComment = null; 
        beforeEach(() => { 
            ... 
        } 

  1. 找到第一条评论的 likeButton
        var firstComment = element.all(by.css('li').first(); 
        var likeButton = firstComment.element(by.buttonText('like')); 

  1. 当点击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); 
  }); 
}); 

让它运行

测试已经准备就绪,迫不及待地要运行。我们现在将运行它并修复代码,直到测试通过。以下步骤将详细说明需要进行的错误和修复循环,以使测试路径:

  1. 运行 Protractor。

  2. 在命令行中查看错误消息:

**$ Error: No element found using locator: by.buttonText("like")**

  1. 正如错误所述,没有like按钮。继续添加按钮:
        @Component({ 
              template: `........ 
              <ul> 
              <li *ngFor="let comment of comments"> 
              {{comment}} 
            <button type="button">like</button> 
              </li> 
              </ul>` 
          }); 

  1. 运行 Protractor。

  2. 查看下一个错误消息:

**$ Expected 'a sample comment like' to be 'a sample comment'.**

  1. 通过添加like按钮,我们导致其他测试失败。原因是我们使用了getText()方法。Protractor 的getText()方法获取内部文本,包括内部元素。

  2. 为了解决这个问题,我们需要更新先前的测试,将like作为测试的一部分包括进去:

        it('Should then add the comment', () => { 
          var comments = element.all(by.css('li')).first(); 
          expect(comments.getText()).toBe('a sample comment like'); 
        }); 

  1. 运行 Protractor。

  2. 查看下一个错误消息:

**$ Error: No element found using locator: by.css("#likes")**

  1. 现在是添加likes绑定的时候了。这个稍微复杂一些。likes需要绑定到一个评论。我们需要改变组件中保存评论的方式。评论需要保存comment标题和点赞数。评论应该是这样的一个对象:
        {title:'',likes:0} 

  1. 再次强调,这一步的重点只是让测试通过。下一步是更新组件的add函数,以根据我们在前面步骤中描述的对象创建评论。

  2. 打开app.component.ts并编辑add函数,如下所示:

        export class AppComponent { 
            ...... 
              add(comment) { 
                  var commentObj = {title: comment, likes: 0}; 
                  this.comments.unshift(commentObj); 
              } 
        } 

  1. 更新页面以使用评论的值:
        @Component({ 
            template: `........... 
            <ul> 
              <li *ngFor="let comment of comments"> 
          {{comment.title}} 
            </li> 
            </ul>` 
        }) 

  1. 在重新运行 Protractor 测试之前,我们需要将新的comment.likes绑定添加到 HTML 页面中:
        @Component({ 
            template: `........... 
            <ul> 
              <li *ngFor="let comment of comments"> 
          {{comment.title}} 
          ............. 
          <span id="likes">{{comment.likes}}</span> 
              </li> 
          </ul>` 
        }) 

  1. 现在重新运行 Protractor 测试,让我们看看错误在哪里:
**$ Expected 'a sample comment like 0' to be 'a sample
        comment like'**

  1. 由于评论的内部文本已更改,我们需要更改测试的期望:
        it('Should then add the comment',() => { 
        ... 
          expect(comments.getText()).toBe('a sample comment like 0'); 
        }); 

  1. 运行 Protractor:
**$ Expected '0' to be '1'.**

  1. 最后,我们来到了测试的期望。为了使这个测试通过,最小的更改将是使like按钮更新comment数组上的点赞数。第一步是在控制器中添加一个like方法,它将更新点赞数:
        export class AppComponent { 
            ...... 
              like(comment) { 
                  comment.like++; 
              } 
        } 

  1. like方法与 HTML 页面链接,使用按钮上的(click)属性,如下所示:
        @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>` 
          }); 

  1. 运行 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. 卡尔玛需要 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 测试工具和技术。

这种自上而下的方法与第三章中讨论的自下而上的方法不同,卡尔玛方式,以及第四章中讨论的自下而上的方法,使用 Protractor 进行端到端测试。在自下而上的方法中,规范用于构建单元测试,然后在其上构建 UI 层。在本章中,展示了一种自上而下的方法,重点放在用户行为上。

自上而下的方法测试 UI,然后通过其他层过滤开发。这两种方法都有其优点。在应用 TDD 时,了解如何同时使用两者是至关重要的。除了介绍不同的 TDD 方法之外,我们还看到了 Angular 的一些核心测试组件,例如以下内容:

  • 从端到端和单元角度测试一个组件

  • 将组件类导入测试套件并为单元测试启动它

  • Protractor 绑定到ngModel,向输入列发送按键,并通过其内部 HTML 代码和所有子元素获取元素的文本的能力

下一章将基于此处使用的技术,并研究无头浏览器测试、Protractor 的高级技术以及如何测试 Angular 路由。

第七章:翻转

在这一点上,我们应该对使用 TDD 进行 Angular 应用程序的初始实现感到自信。此外,我们应该熟悉使用先测试的方法。先测试的方法在学习阶段非常好,但有时当我们遇到很多错误时会耗费时间。对于简单和已知的行为,可能不适合采用先测试的方法。

我们已经看到了先测试的方法是如何工作的,所以我们可以通过检查任何功能来跳过这些步骤,而不创建这些组件。除此之外,我们可以更进一步,让我们更有信心更快地编写我们的组件。我们可以准备好我们的组件,然后编写端到端的测试规范来测试预期的行为。如果端到端测试失败,我们可以在 Protractor 调试器中触发错误。

在本章中,我们将继续扩展我们对 Angular 应用 TDD(但不是先测试的方法)的知识。我们不会在这里讨论基本的 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 项目

重要的是要有一个快速设置项目的过程和方法。您花在思考目录结构和所需工具的时间越少,您就可以花更多时间开发!

因此,在之前的章节中,我们看了如何获取 Angular 的简单现有项目,开发为 quickstart 项目 (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。我们需要这样做才能完美地进行路由,因此让我们在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.** 

要确认安装并运行项目,应用程序将自动在 Web 浏览器中运行。

在运行项目后,预期的输出如下:

运行项目

重构项目

让我们稍微改变项目结构,不过不多。默认情况下,它在相同位置包括了单元测试和组件文件,并将 e2e 测试文件分离到app/文件夹之外的e2e/文件夹中。

然而,我们将保持所有测试在相同的位置,也就是在app之外;我们将把所有测试保存在spec/e2espec/unit中。

目标是将测试规范与组件分开。这样,我们可以将我们的单元测试文件保存在spec/unit文件夹之外。

因此,我们当前的文件夹结构将如下所示:

重构项目

注意

只要我们已经改变了单元测试和 e2e 测试的路径,我们就必须在 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 来构建路由。以下是规格说明。将有一个导航菜单,其中有两个菜单项,View1View2

  • 在导航菜单中,点击标签View1

  • 内容区域(路由器出口)将加载/翻转View1内容

以下是第二部分:

  • 在导航菜单中,单击标签View2

  • 内容区域(路由器出口)将加载/翻转View2内容

基本上,这将是一个在两个视图之间进行翻转的应用程序。

设置 Angular 路由

路由器是 Angular 中的可选服务,因此它不包含在 Angular 核心中。如果我们需要使用路由器,我们将需要在应用程序中安装 Angular router服务。

只要我们从quickstart克隆了我们的项目,我们应该没问题,因为它最近已将 Angular 路由器添加到其依赖项中,但我们应该检查并确认。如果在package.json中的依赖项中没有@angular/router,我们可以使用npm安装 Angular 路由器,如下所示:

**$ npm install @angular/router --save**

定义方向

路由指定了位置并期望结果。从 Angular 的角度来看,路由必须首先指定,然后与某些组件关联。

要在我们的应用程序中实现路由器,我们需要在应用程序模块中导入路由器模块,其中它将在应用程序中注册路由器。之后,我们将需要配置所有路由并将该配置传递给应用程序模块。

路由器模块

要在应用程序中实现路由器,我们需要在应用程序模块中导入RouterModule,位于app/app.module.ts,如下所示:

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 { 
} 

配置中的路由

现在,让我们向位于app/app.routes.tsRoutes配置数组中添加一些路由。路由配置数组包含一些对象作为键值对,每个对象中大多有两到三个元素。

数组对象中的第一个元素包含“路径”,第二个元素包含与该“路径”对应的要加载的“组件”。

让我们向配置数组中添加两个路由,如下所示:

export const rootRouterConfig: Routes = [ 
  { 
    path: 'view1',  
    component: View1Component 
  }, 
  { 
    path: 'view2',  
    component: View2Component 
  } 
]; 

在这里,定义了两个路由,view1view2,并分配了两个组件以加载该路由。

在某些情况下,我们可能需要从一个路由重定向到另一个路由。例如,对于应用程序的根路径(''),我们可能计划重定向到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 
  } 
]; 

我在这里应该提到,我们必须导入view1view2组件,因为我们在路由配置中使用了它们。

要详细了解 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链接;我会在后面的部分告诉你它是什么。

让我们为导航组件创建基本的 CSS 样式,以便更好地查看/app/nav/navbar.component.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.htmlappComponent模板中:

<h1>My First Angular 2 App</h1> 
<app-navbar></app-navbar> 
<router-outlet></router-outlet> 

准备组件

对于每个定义的路由,我们需要创建一个单独的组件,因为每个路由都将与一个组件相关联。

在这里,我们有两个定义的路由,我们需要创建两个单独的组件来处理路由导航。根据我们的需求,我们将创建View1ComponentView2Component

/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 { } 

View 2组件创建另一个组件文件(/app/view/view2.component.ts):

import {Component} from '@angular/core'; 

@Component({ 
  selector: 'app-view2', 
  template: '<div id="view2">I am view two component</div>' 
}) 
export class View2Component { } 

我们已经准备好了我们的路由和相关组件(导航View1View2)。希望一切都按预期工作,我们可以在浏览器中看到应用程序的输出。

在查看浏览器中的预期输出之前,让我们通过端到端测试来测试预期结果。现在我们知道了预期的行为,我们将根据我们的期望编写端到端测试规范。一旦我们准备好了端到端测试规范,我们将看到它如何满足我们的期望。

组装翻转/翻转测试

在 3A 中的第一个Aassemble之后,这些步骤将向我们展示如何组装测试:

  1. 从 Protractor 基本模板开始,如下所示:
        describe('Given views should flip through navigation         
        interaction', () => { 
          beforeEach( () => { 
            // ..... 
        }); 

        it('Should fliped to the next view', () => { 
           // ....  
        }); 
        }); 

  1. 使用以下代码导航到应用程序的根目录:
        browser.get('view1'); 

  1. beforeEach方法需要确认正确的组件视图正在显示。这可以通过使用 CSS 定位器来查找view1div标签来完成。期望将如下所示:
        var view1 = element(by.css('#view1')); 
        expect(view1.isPresent()).toBeTruthy(); 

  1. 然后,添加一个期望,即view2不可见:
        var view2 = element(by.css('#view2')); 
        expect(view2.isPresent()).toBeFalsy(); 

  1. 然后通过获取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组件,让我们通过点击导航中的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 测试已经通过。现在我们可以查看浏览器,检查事情是否与端到端测试结果一样。

在浏览器中打开应用程序

只要我们已经运行了用于端到端测试的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类应注入所需的模块,例如HttpRouter,如下所示:

export class MembersComponent implements OnInit { 
    constructor(private http:Http, private router:Router) { 
  } 
} 

如前所述,我们将使用ngOnInit()方法,并从中初始化搜索机制,如下所示:

export class MembersComponent implements OnInit { 
 ngOnInit() { 
    this.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中创建一个人员对象,并将两个对象模型放在同一个文件中。

PersonAddress两个对象模型类如下:

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', () => { 
  }); 
}); 

下一步是构建测试。

组装搜索结果测试

在这种情况下,搜索结果已经可以从搜索查询测试中获得。我们不必为测试添加任何其他设置步骤。

选择搜索结果

正在测试的对象是结果。测试是结果被选择后,应用程序必须执行某些操作。编写这个测试的步骤如下:

  1. 选择resultItem。由于我们将使用路由来表示详细信息,我们将创建一个指向详细页面的链接并点击该链接。以下是创建链接的方法:

选择resultItem内的链接。这使用当前选择的元素,然后找到符合条件的任何子元素。代码如下:

        let resultItem = element(by.linkText('Demaryius Thomas')); 

  1. 现在,要选择链接,请添加以下代码:
        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 路由的 person 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。它将为我们提供当前路由参数,并且我们将使用它来从演示成员数据集中检索特定的 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 中检索数据并返回一个 promise:

getPerson(id:number) { 
    return this.getData().then(data => data.find(member => 
    member.id === id)); 
  } 

getPerson() 方法将解析返回的 promise,并根据所选的 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 
  } 
................... 
]; 

运行搜索轮

我们的应用程序已经准备好进行重构、路由配置、e2e 测试以及它们的子组件。我们将查看当前文件结构和项目的输出。

应用结构

我们的应用程序有两个主要的文件夹,一个是app目录,另一个是spec/test目录。

让我们看看我们app目录的当前结构:

应用结构

这是test目录:

应用结构

让我们运行

我们的搜索功能已经准备就绪。如果我们运行npm start,我们的应用程序将在默认端口3000上在浏览器中运行。让我们导航到成员以获取搜索功能的输出。搜索功能的 URL 是http://localhost:3000/members

当我们登陆成员页面时,实际上会加载所有数据,因为搜索输入为空,这意味着没有搜索查询。输出应该如下所示:

让我们运行

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

让我们运行

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

让我们运行

万岁!完整的应用程序如预期般在浏览器中运行。

现在 e2e 怎么样!

项目在浏览器中运行,我们已经为每个组件进行了 e2e 测试。让我们看看当我们一起运行整个应用程序的 e2e 测试时,e2e 测试的反应如何。

让我们运行npm run e2e;输出如下所示:

现在 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 与无头浏览器,并看到它的实际应用。