Angular-测试驱动开发第二版-三-

48 阅读42分钟

Angular 测试驱动开发第二版(三)

原文:zh.annas-archive.org/md5/2587abd8d5ac1ecccf1401601540e791

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章。翻转

到目前为止,我们应该对使用 TDD 进行 Angular 应用程序的初始实现有信心。我们还应该熟悉使用测试优先方法。测试优先方法在学习阶段非常好,但有时当我们遇到很多错误时,它可能会浪费时间。对于简单和已知的行为,可能不是采用测试优先方法的最佳选择。

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

在本章中,我们将继续扩展我们应用 TDD(但不是测试优先方法)与 Angular 的知识。在这里,我们不会讨论基本 Angular 组件生态系统的细节;相反,我们将更进一步,包括更多 Angular 功能。我们将通过以下主题进一步扩展我们的知识:

  • Angular 路由

  • 导航到路由

  • 与路由参数数据通信

  • 使用 CSS 和 HTML 元素进行 Protractor 位置引用

TDD 的基础

在本章中,我们将逐步介绍将 TDD 应用于搜索应用的路线和导航。在开始逐步介绍之前,我们需要了解本章中将使用的一些技术、配置和函数,包括以下内容:

  • Protractor 定位器

  • 无头浏览器测试

在回顾了这些概念之后,我们可以继续进行逐步介绍。

Protractor 定位器

Protractor 定位器是每个人都应该花时间学习的关键组件。在之前的 Protractor 章节中,我们通过一些工作示例了解了一些常用定位器。在这里,我们将提供一些 Protractor Locator 的示例。

Protractor 定位器允许我们在 HTML 页面中查找元素。在本章中,我们将看到 CSS、HTML 和 Angular 特定定位器的实际应用。定位器被传递给 element 函数。element 函数将找到并返回页面上的元素。通用的定位器语法如下:

element(by.<LOCATOR>); 

在前面的代码中,<LOCATOR> 是一个占位符。以下几节将描述这些定位器中的几个。

CSS 定位器

CSS 用于向 HTML 页面添加布局、颜色、格式化和样式。从端到端测试的角度来看,一个元素的看起来和样式可能是一个规范的一部分。例如,考虑以下 HTML 片段:

<div class="anyClass" id="anyId"></div> 
// ... 
var e1 = element(by.css('.anyClass')); 
var e2 = element(by.css('#anyId')); 
var e3 = element(by.css('div')); 
var e4 = $('div'); 

所有这四个选择都将选择 div 元素。

按钮和链接定位器

除了能够选择和解释某物的外观方式外,也很重要能够找到页面中的按钮和链接。这将允许测试轻松地与网站交互。以下是一些示例:

  • buttonText 定位器:
        <button>anyButton</button> 
        // ... 
        var b1 = element(by.buttonText('anyButton')); 

  • linkText 定位器:
        <a href="#">anyLink</a> 
        // ... 
        var a1 = element(by.linkText('anyLink')); 

URL 位置引用

当测试 Angular 路由时,我们需要能够测试我们测试的 URL。通过在 URL 和位置周围添加测试,我们必须确保应用程序与特定的路由一起工作。这很重要,因为路由为我们提供了应用程序的接口。以下是如何在 Protractor 测试中获得 URL 引用的方法:

var location = browser.getLocationAbsUrl(); 

现在我们已经看到了如何使用不同的定位器,是时候将知识付诸实践了。

准备 Angular 项目

获得快速设置项目的过程和方法很重要。你花在思考目录结构和所需工具上的时间越少,你可以在开发上花的时间就越多!

因此,在前面的章节中,我们探讨了如何获取作为 quickstart 项目开发的简单现有 Angular 项目 github.com/angular/quickstart

然而,有些人使用 angular2-seed github.com/mgechev/angular2-seed 项目、Yeoman 或创建自定义模板。尽管这些技术很有用,并且有其优点,但在 Angular 的入门阶段,理解从头开始构建应用程序所需的东西是至关重要的。通过自己构建目录结构和安装工具,我们将更好地理解 Angular。

你将能够根据你特定的应用和需求做出布局决策,而不是将它们适应到某个其他模块中。随着你的成长和成为更好的 Angular 开发者,这一步可能不再需要,并且会变得自然而然。

加载现有项目

首先,我们将从 Angular 的 quickstart 项目 github.com/angular/quickstart 克隆项目,将其重命名为 angular-flip-flop,我们的项目文件夹结构将如下所示:

加载现有项目

在前面的章节中,我们讨论了如何设置项目,理解了涉及的不同组件,并走过了整个过程。我们将跳过这些细节,并假设你可以回忆起如何执行必要的安装。

准备项目

这个 quickstart 项目没有在项目的着陆页(index.html)中包含基本 href。为了完美地进行路由,我们需要这个 href,所以让我们在 index.html<head> 部分添加一行(base href):

<base href="/"> 

在这里,我们的引导组件位于应用程序组件中,HTML 模板位于组件本身中。在继续之前,我们应该将模板分离到一个新文件中。

为了这个,我们将更新我们的应用程序组件(app/app.component.ts),如下所示:

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

@Component({ 
  moduleId: module.id, 
  selector: 'my-app', 
  templateUrl: 'app.component.html' 
}) 
export class AppComponent { 

}; 

让我们在 app/app.component.html 创建我们的单独模板文件。代码将如下所示:

<h1>My First Angular 2 App</h1> 

运行项目

让我们继续进行,并准备好使用以下命令运行:

$ cd angular-flip-flop
$ npm install // To install the required node modules. 
$ npm run // To build and run the project in http server. 

为了确认安装并运行项目,应用程序将自动在网页浏览器中运行。

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

运行项目

重构项目

让我们稍微改变一下项目结构,但不是很多。默认情况下,它已经将单元测试包含在组件文件相同的目录中,并将端到端测试文件分离到app/文件夹外的e2e/文件夹中。

然而,我们将保持所有测试在同一位置,即app之外;我们将保持所有测试在spec/e2espec/unit

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

因此,我们当前的项目结构将看起来像这样:

重构项目

注意

只要我们更改了单元和端到端测试的路径,我们就需要在 Karma 配置文件和 Protractor 配置文件中更改路径。

为 Karma 设置无头浏览器测试

在前面的章节中,我们使用默认配置运行 Karma。默认的 Chrome 配置会在每次测试时启动 Chrome。针对应用程序将运行的实际代码和浏览器进行测试是一个强大的工具。然而,在启动时,浏览器可能并不总是知道你希望它如何表现。从单元测试的角度来看,你可能不希望浏览器在窗口中启动。原因可能是测试可能需要很长时间才能运行,或者你可能并不总是安装了浏览器。

幸运的是,Karma 配备了轻松配置 PhantomJS(一个无头浏览器)的能力。无头浏览器在后台运行,不会在 UI 中显示网页。PhantomJS 无头浏览器是用于测试的真正出色的工具。它甚至可以设置来对测试进行截图!有关如何进行此操作以及 PhantomJS 网站上使用的 WebKit 的更多信息,请参阅phantomjs.org/。以下设置配置将展示如何使用 Karma 设置 PhantomJS 进行无头浏览器测试。

预配置

当 Karma 安装时,它将自动包含 PhantomJS 浏览器插件。有关进一步参考,插件位于github.com/karma-runner/karma-phantomjs-launcher。不应需要更多的安装或配置。

然而,如果你的设置表明它缺少karma-phantomjs-launcher,你可以很容易地使用npm安装它,如下所示:

$ npm install karma-phantomjs-launcher --save -dev

配置

PhantomJS 已在 Karma 配置的browsers部分进行配置。打开karma.conf.js文件,并使用以下详细信息更新它:

browsers: ['PhantomJS'], 

同样在plugins选项中进行设置:

plugins: [ 
        ......... 
        require('karma-phantomjs-launcher'), 
    ], 

现在项目已经初始化并配置了无头浏览器测试,你可以通过以下步骤查看其运行情况。

Angular 路由和导航的概述

本指南将利用 Angular 路由。路由是 Angular 的一个极其有用的功能,就像在 Angular 1.x 中一样,但更强大。它们允许我们使用不同的组件来控制应用程序的某些方面。

本指南将切换组件以展示如何使用 TDD(测试驱动开发)来构建路由。以下是一些规范。将有一个导航菜单,其中包含两个菜单项,View1View2

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

  • 内容区域(路由出口)将加载/切换View1内容

接下来是第二部分:

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

  • 内容区域(路由出口)将加载/切换View2内容

实质上,这将是一个在两个视图之间切换的应用程序。

设置 Angular 路由

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

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

$ npm install @angular/router --save

定义方向

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

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

路由模块

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

import {RouterModule} from "@angular/router"; 

这将使router模块在应用程序系统中可用,但我们将必须有一个路由配置来定义整个应用程序中所有可能的路由,然后通过应用程序模块将此配置导入到应用程序生态系统中。

配置路由

路由在没有配置之前是无用的,为此,我们首先需要导入router组件。配置将主要包含一个数组列表,其中路由路径和相关组件作为键值对存在。我们可以将配置数组添加到应用程序模块中,或者我们可以创建一个单独的配置文件并将应用程序模块包含在内。我们将选择第二种方法,以便将路由配置与应用程序模块分离。

让我们在应用程序根目录中创建一个名为app/app.routes.ts的路由配置文件。在那里,首先,我们需要从 Angular 服务中导入 Angular 的Routes,如下所示:

import {Routes} from '@angular/router';

从路由配置文件中,我们需要导出配置数组,如下所示:

export const rootRouterConfig: Routes = [ 
 // List of routes will come here 
]; 

应用程序中的路由器

我们已经将 router 模块导入到位于 app/app.module.ts 的应用程序模块中。

然后,我们需要将路由配置文件 (rootRouterConfig) 导入到这个应用程序模块文件中,如下所示:

import {rootRouterConfig} from "./app.routes";

在应用程序模块中,我们知道 NgModule 将可选模块导入到应用程序生态系统中,同样地,为了将路由包含在应用程序中,RouterModule 有一个名为 RouterModule.forRoot(RouterConfig) 的函数,它接受 routerConfiguration 以在整个应用程序中实现路由。

应用程序模块 (app/app.module.ts) 将如下导入 RouterModule

@NgModule({ 
  declarations: [AppComponent, ........], 
  imports     : [........., RouterModule.forRoot(rootRouterConfig)], 
  bootstrap   : [AppComponent] 
}) 
export class AppModule { 
} 

配置中的路由

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

数组对象中的第一个元素包含 path,第二个元素包含为该 path 加载的相关 component

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

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

在这里,定义了两个路由 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链接;我将在稍后的部分告诉你它是什么。

让我们在/app/nav/navbar.component.css中创建导航组件的基本 CSS 样式,以便更好地查看,如下所示:

:host { 
  border-color: #e1e1e1; 
  border-style: solid; 
  border-width: 0 0 1px; 
  display: block; 
  height: 48px; 
  padding: 0 16px; 
} 

nav a { 
  color: #8f8f8f; 
  font-size: 14px; 
  font-weight: 500; 
  margin-right: 20px; 
  text-decoration: none; 
  vertical-align: middle; 
} 

nav a.router-link-active { 
  color: #106cc8; 
} 

我们有一个导航组件。现在我们将需要将其绑定到我们的应用程序组件,即我们的应用程序着陆页。

为了做到这一点,我们必须将以下内容添加到位于/app/app.component.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 { } 

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

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

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

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

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

组装翻转/切换测试

按照三个 A 中的第一个 A,组装,这些步骤将向我们展示如何组装测试:

  1. 从 Protractor 基础模板开始,如下所示:

            describe('Given views should flip through navigation         
            interaction', () => { 
              beforeEach( () => { 
                // ..... 
            }); 
    
            it('Should fliped to the next view', () => { 
               // ....  
            }); 
            }); 
    
    
  2. 使用以下代码导航到应用程序的根目录:

            browser.get('view1'); 
    
    
  3. beforeEach方法需要确认显示的是正确的组件视图。这可以通过使用 CSS 定位器查找view1div标签来实现。期望结果如下所示:

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

            var view2 = element(by.css('#view2')); 
            expect(view2.isPresent()).toBeFalsy(); 
    
    
  5. 然后,通过获取view1组件的整个文本来进一步确认:

            var view1 = element(by.css('#view1')); 
            expect(view1.getText()).toEqual('I am view one component'); 
    
    

切换到下一个视图

前面的测试需要确认当在导航中点击view2链接时,view2组件的内容将加载。为了进行测试,我们可以使用by.linkText定位器。它将看起来像这样:

var view2Link = element(by.linkText('View2')); 
view2Link.click(); 

beforeEach函数现在已完成,如下所示:

var view1 = element(by.css('#view1')); 
var view2 = element(by.css('#view2')); 
beforeEach(() => { 
    browser.get('view1'); 
    expect(view1.isPresent()).toBeTruthy(); 
    var view2Link = element(by.linkText('View2')); 
    view2Link.click(); 
}) 

接下来,我们将添加断言。

断言翻转

断言将再次使用 Protractor 的 CSS 定位器,如下所示,以查找view2是否可用:

it('Should fliped to View2 and view2 should visible', () => { 
  expect(view2.isPresent()).toBeTruthy(); 
}); 

我们还需要确认view1不再可用。添加期望,view1不应存在,如下所示:

it('Should fliped to View2 and view1 should not visible', () => { 
  expect(view1.isPresent()).toBeFalsy(); 
}); 

为了确保无误,我们还可以检查view2的内容是否已加载,如下所示:

it('Should fliped to View2 and should have body content as expected',  () => { 
    expect(view2.getText()).toEqual('I am view two component'); 
}); 

由于我们即将通过点击导航中的view2链接来切换测试从view1组件到view2组件,让我们通过点击导航中的view1链接回到view1组件,希望一切按预期工作:

it('Should flipped to View1 again and should visible', () => { 
    var view1Link = element(by.linkText('View1')); 
    view1Link.click(); 
    expect(view1.isPresent()).toBeTruthy(); 
    expect(view2.isPresent()).toBeFalsy(); 
  }); 

测试现在已经组装完成。

运行翻转/翻转测试

我们的测试规范已经准备好,现在是时候运行它并查看结果了。

首先,我们必须通过 HTTP 服务器保持我们的项目运行,使用以下命令:

$ npm start

然后,我们必须运行 Protractor。确保运行中的应用程序的端口号和 Protractor 配置文件正确;为了确保无误,更新配置中的运行服务器端口号。要运行 Protractor,请使用以下命令:

$ npm run e2e

结果应该如下所示:

Suite: Given views should flip through navigation in 
    passed - View1 should have body content as expected 
    passed - Should flipped to View2 and view2 should visible 
    passed - Should flipped to View2 and should have body content
    as expected 
    passed - Should flipped to View1 again and should visible 
        Suite passed: Given views should flip through navigation in 

根据我们的预期,Protractor 测试已经通过。现在我们可以查看浏览器,检查事情是否与 e2e 测试结果一样工作。

在浏览器中打开应用

只要我们为 e2e 测试运行了npm start命令,我们的应用程序就可以在本地主机的特定端口3000上运行。默认情况下,它将在浏览器中打开。

预期输出显示在以下屏幕截图:

在浏览器中打开应用

以 TDD 方式搜索

这个流程将展示我们如何构建一个简单的搜索应用程序。它有两个组件:第一个讨论搜索查询组件,第二个使用路由来显示搜索结果详情。

搜索查询的流程

正在构建的应用程序是一个搜索应用程序。第一步是设置搜索区域和搜索结果。想象一下我正在进行搜索。在这种情况下,以下操作将会发生:

  • 输入搜索查询

  • 结果显示在搜索框的底部

这部分应用与我们在第六章中看到的测试、布局和方法的相似性非常高,第一步。应用需要使用输入,响应用户点击,并确认结果数据。由于测试和代码使用与上一个示例相同的函数,因此没有必要提供完整的搜索功能流程。相反,以下小节将展示所需的代码和一些解释。

搜索查询测试

以下代码表示搜索查询功能的测试:

describe('Given should test the search feature', () => { 
    let searchBox, searchButton, searchResult; 

    beforeEach(() => { 

    //ASSEMBLE  
    browser.get(''); 
    element(by.linkText('Search')).click(); 
    searchResult = element.all(by.css('#searchList tbody tr')); 
    expect(searchResult.count()).toBe(3); 

    //ACT 
    searchButton = element(by.css('form button')); 
    searchBox = element(by.css('form input')); 
    searchBox.sendKeys('Thomas'); 
    searchButton.click(); 
    }); 

    //Assert 
    it('There should be one item in search result', () => { 
    searchResult = element.all(by.css('#searchList tbody tr')); 
    expect(searchResult.count()).toBe(1); 
    }); 
}); 

我们应该注意到与之前的测试有相似之处。功能被编写来模拟用户在搜索框中输入的行为。测试找到输入字段,输入一个值,然后选择显示搜索的按钮。断言确认结果包含单个值。

搜索应用程序

要执行搜索操作,我们需要创建一个包含用于接受用户输入(搜索查询)的输入字段和用于执行用户动作的点击事件的按钮的搜索组件。除此之外,它可能还有一个占位符来包含搜索结果。

只要我们的应用程序已经包含了路由器,我们就可以为特定的路由放置搜索组件。

注意,我们已将我们的搜索组件命名为MembersComponent,因为我们已经在搜索组件中处理了一些成员数据。并且路由也将根据这一点进行配置。

因此,在我们的现有app.routes.ts文件中,我们将添加以下搜索路由:

export const rootRouterConfig: Routes = [ 
  { 
    path: '/members', 
    component: MembersComponent 
  } 
................... 
]; 

搜索组件

搜索组件(MembersComponent)将是此处搜索功能的主要类。它将执行搜索并返回搜索结果。

在搜索组件的初始加载过程中,它将没有任何搜索查询,因此我们已将行为设置为返回所有数据。然后,在搜索触发后,它将根据搜索查询返回数据。

搜索组件将被放置在app/members/members.compoennt.ts。在代码中,一开始,我们不得不导入所需的 Angular 服务,如下所示:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router } from '@angular/router'; 

我们将使用Http服务进行 AJAX 调用,默认情况下,在 Angular 中,Http服务返回一个可观察对象。然而,处理一个承诺比处理一个可观察对象更容易。因此,我们将这个可观察对象转换为承诺。Angular 建议使用rxjs模块,它包含用于将可观察对象转换为承诺的toPromise方法。因此,我们将导入rxjs模块,如下所示:

import 'rxjs/add/operator/toPromise'; 

Angular 引入了ngOnInit()方法,在初始化组件时执行,类似于任何类中的构造函数方法,但它有助于运行测试规范。为此,我们已从 Angular 核心导入OnInit接口,Component类将实现OnInit接口以获取ngOnInit方法。

此外,Component类应该注入所需的模块,例如HttpRouter,如下所示:

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

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

export class MembersComponent implements OnInit { 
 ngOnInit() { 
    this.search(); 
  } 

在这里,我们将对成员列表应用search功能,为此,我们在app/data/people.json中有些示例数据。我们将从这里检索数据并在数据上执行搜索操作。让我们看看如何:

  • getData()方法将从 API 检索数据,并将返回一个承诺。
        getData() { 
            return this.http.get('app/data/people.json') 
            .toPromise() 
            .then(response => response.json()); 
        } 

  • searchQuery() 方法将解析返回的承诺,并根据搜索查询创建一个数据数组。如果没有提供搜索查询,它将返回完整的数据集作为数组:
        searchQuery(q:string) { 
            if (!q || q === '*') { 
              q = ''; 
            } else { 
              q = q.toLowerCase(); 
            } 
            return this.getData() 
              .then(data => { 
              let results:Array<Person> = []; 
              data.map(item => { 
                if (JSON.stringify(item).toLowerCase().includes(q)) { 
                  results.push(item); 
                } 
              }); 
              return results; 
            }); 
        } 

  • search() 方法将为前端绑定模板准备数据集:
        search(): void { 
          this.searchQuery(this.query) 
          .then(results => this.memberList = results); 
        } 

在这里,我们还有一个可选的方法,用于导航到成员详情组件。我们将其称为 person 组件。在这里,viewDetails() 方法将传递成员 ID,router.navigate() 方法将使用 ID 作为参数将应用程序导航到 person 组件,如下所示:

viewDetails(id:number) { 
    this.router.navigate(['/person', id]); 
  } 

MembersComponent 的完整代码如下:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router } from '@angular/router'; 
import 'rxjs/add/operator/toPromise'; 
import { Person } from './person/person.component'; 

@Component({ 
  selector: 'app-member', 
  moduleId: module.id, 
  templateUrl: 'members.component.html', 
  styleUrls: ['members.component.css'] 
}) 
export class MembersComponent implements OnInit { 
  memberList: Array<Person> = []; 
  query: string; 

  constructor(private http:Http, private router:Router) { 
  } 

  ngOnInit() { 
    this.search(); 
  } 

  viewDetails(id:number) { 
    this.router.navigate(['/person', id]); 
  } 

  getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 

  search(): void { 
    this.searchQuery(this.query) 
    .then(results => this.memberList = results); 
  } 

  searchQuery(q:string) { 
    if (!q || q === '*') { 
      q = ''; 
    } else { 
      q = q.toLowerCase(); 
    } 
    return this.getData() 
      .then(data => { 
      let results:Array<Person> = []; 
      data.map(item => { 
        if (JSON.stringify(item).toLowerCase().includes(q)) { 
          results.push(item); 
        } 
      }); 
      return results; 
    }); 
  } 
} 

当有结果要显示时,search 组件模板包含搜索表单和搜索结果列表。

模板如下所示:

<h2>Members</h2> 

<form> 
  <input type="search" [(ngModel)]="query" name="query" (keyup.enter)="search()"> 
  <button type="button" (click)="search()">Search</button> 
</form> 

<table *ngIf="memberList" id="searchList"> 
  <thead> 
  <tr> 
    <th>Name</th> 
    <th>Phone</th> 
  </tr> 
  </thead> 
  <tbody> 
  <tr *ngFor="let member of memberList; let i=index"> 
    <td><a href="javascript:void(0)" (click)="viewDetails(member.id)">{{member.name}}</a></td> 
    <td>{{member.phone}}</td> 
  </tr> 
  </tbody> 
</table> 

之前展示的 Angular 组件与之前章节中展示的类似。

我们正在使用来自 people.json 文件的模拟数据集,该数据集包含有关带地址的人的信息。我们希望将信息分成两部分,一部分为摘要信息,另一部分为地址详情。由于我们将使用此数据集,因此将很容易为该数据集创建一个对象模型。

摘要数据集将被定义为 Person 对象,地址详情将被定义为 Address。让我们在 app/members/person/person.component.ts 中创建一个人员对象,并将两个对象模型放在同一文件中。

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

下一步是构建测试。

组装搜索结果测试

在这种情况下,搜索结果已从搜索查询测试中获取。我们不需要为测试添加任何更多设置步骤。

选择搜索结果

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

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

    resultItem 内选择链接。这使用当前选定的元素,然后找到任何符合标准子元素。此代码如下所示:

            let resultItem = element(by.linkText('Demaryius Thomas')); 
    
    
  2. 现在,要选择链接,请添加以下代码:

            resultItem.click(); 
    
    

确认搜索结果

现在搜索项已被选择,我们需要验证结果详情页面是否可见。目前最简单的解决方案是确保详情视图是可见的。这可以通过使用 Protractor 的 CSS 定位器来查找搜索详情视图来完成。以下是要添加以确认搜索结果的代码:

it('Should be load the person details page', () => { 
    var resultDetail = element(by.css('#personDetails')) 
    expect(resultDetail.isDisplayed()).toBeTruthy(); 
}) 

这里是完整的测试:

describe('Given should test the search result in details view', () => { 

  beforeEach(() => { 
    browser.get('members'); 
    let searchButton = element(by.css('form button')); 
    let searchBox = element(by.css('form input')); 
    searchBox.sendKeys('Thomas'); 
    searchButton.click(); 
    let resultItem = element(by.linkText('Demaryius Thomas')); 
    resultItem.click(); 
  }); 

  it('should be load the person details page', () => { 
    var resultDetail = element(by.css('#personDetails')) 
    expect(resultDetail.isDisplayed()).toBeTruthy(); 
  }); 

}); 

现在测试已经设置好了,我们可以继续到生命周期的下一阶段并运行它。

搜索结果组件

我们命名的 Person 搜索结果组件将路由到接受 params 路由中的人员 ID,并将根据该 ID 搜索数据。

搜索结果组件将被放置在 app/members/person/person.component.ts 中。在代码中,首先,我们必须导入所需的 Angular 服务,如下所示:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router, ActivatedRoute, Params } from '@angular/router'; 

我们已经在 members 组件中看到了一些这些 Angular 服务。在这里,我们将主要讨论 ActivatedRoute,因为它很新。这是一个 Angular 路由模块,用于与当前/激活的路由交互:当我们需要访问当前路由的 params 时,我们将通过它来访问。

正如我们讨论的那样,在初始化组件时,我们需要 ActivatedRoute;因此,我们在 ngOnInit() 方法中调用了 ActivatedRoute。它将为我们提供当前的路由参数,我们将得到预期的 ID,这将用于从演示成员数据集中检索特定的 Person,如下所示:

export class PersonComponent implements OnInit { 
  person: Person; 
  constructor(private http:Http, private route: ActivatedRoute, 
  private router: Router) { 
  } 

  ngOnInit() { 
    this.route.params.forEach((params: Params) => { 
       let id = +params['id']; 
       this.getPerson(id).then(person => { 
         this.person = person; 
       }); 
     }); 
  } 

我们在 app/data/people.json 中有一些模拟数据。这是与 members 组件中使用的相同数据。我们将根据所选 ID 检索数据,如下所示:

getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 

getData() 方法将从 API 获取数据,并将返回一个承诺:

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

getPerson() 方法将解析返回的承诺,并根据所选 ID 返回 Person 对象。

关于 PersonComponent 的完整代码如下:

import { Component, OnInit } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 
import { Router, ActivatedRoute, Params } from '@angular/router'; 
import 'rxjs/add/operator/toPromise'; 

@Component({ 
  selector: 'app-person', 
  moduleId: module.id, 
  templateUrl: 'person.component.html', 
  styleUrls: ['../members.component.css'] 
}) 
export class PersonComponent implements OnInit { 
  person: Person; 
  constructor(private http:Http, private route: ActivatedRoute, private router: Router) { 
  } 

  ngOnInit() { 
    this.route.params.forEach((params: Params) => { 
       let id = +params['id']; 
       this.getPerson(id).then(person => { 
         this.person = person; 
       }); 
     }); 
  } 

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

  getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 
} 

当有搜索结果要显示时,search 组件模板包含搜索表单和搜索结果列表。

模板如下所示:

<h2>Member Details</h2> 

<table *ngIf="person" id="personDetails"> 
  <tbody> 
  <tr> 
    <td>Name :</td> 
    <td>{{person.name}}</td> 
  </tr> 
    <tr> 
      <td>Phone: </td> 
      <td>{{person.phone}}</td> 
    </tr> 
    <tr> 
      <td>Street: </td> 
      <td>{{person.address.street}}</td> 
    </tr> 
    <tr> 
      <td>City: </td> 
      <td>{{person.address.city}}</td> 
    </tr> 
    <tr> 
      <td>State: </td> 
      <td>{{person.address.state}}</td> 
    </tr> 
    <tr> 
      <td>Zip: </td> 
      <td>{{person.address.zip}}</td> 
  </tr> 
  </tbody> 
</table> 

路由中的搜索结果

我们已经有了搜索结果/Person 组件,但我们忘记将其包含在路由配置中。没有它,我们将遇到异常,因为我们无法在没有它在路由中的情况下从 members 列表中导航到 Person 组件。

因此,在我们的现有 app.routes.ts 文件中,我们将添加以下搜索路由:

export const rootRouterConfig: Routes = [ 
  { 
    path: '/person/:id', 
    component: PersonComponent 
  } 
................... 
]; 

运行搜索轮

我们的应用程序经过重构、路由配置、端到端测试以及组件及其子组件的准备,我们将查看当前的项目文件结构和输出。

应用程序结构

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

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

应用结构

这里是test目录:

应用结构

让我们运行

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

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

让我们运行

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

让我们运行

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

让我们运行

欢呼!完整的应用程序按预期在浏览器中运行。

现在端到端测试(e2e)怎么样了!

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

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

现在端到端测试怎么样了!

自我测试问题

Q1. 在导航后加载组件时,使用哪种自定义占位符?

<router-output> </router-output> 

<router-outlet> </router-outlet> 

<router-link> </router-link> 

Q2. 给定以下 Angular 组件,你会如何选择element并模拟点击?

<a href="#">Some Link</a> 
$('a').click();. 
element(by.css('li)).click();. 
element(by.linkText('Some Link')).click();. 

Q3. 在使用 Angular 的路由时,你需要安装@angular/router

  • 真的

  • 假的

摘要

本章向我们展示了如何使用 TDD 构建 Angular 应用程序。到目前为止,这种方法一直侧重于从用户角度的规范,并采用自上而下的 TDD 方法。这种技术帮助我们为用户测试和完成可用的且小的组件。

随着应用程序的增长,它们的复杂性也在增加。在下一章中,我们将探讨自下而上的方法,并看看何时使用这种方法而不是自上而下的方法。

本章向我们展示了如何使用测试驱动开发(TDD)来开发一个通过路由器进行导航的组件化应用程序。路由使我们能够很好地分离我们的组件和视图。我们探讨了几个 Protractor 定位器的使用,从 CSS 到重复器、链接文本和内部定位器。除了使用 Protractor,我们还学习了如何配置 Karma 以使用无头浏览器,并看到了它的实际应用。

第八章。告诉世界

TDD 的构建主要关注基本组件,即生命周期和过程,使用逐步讲解。我们从底层研究了几个应用程序,理解了如何构建 Angular 应用程序并使用工具来测试它们。

是时候进一步深入 Angular 的深处并集成服务、EventEmitters 和路由了。

本章在几个方面将与其他章节略有不同:

  • 我们将使用第七章中的搜索应用程序,而不是构建全新的应用程序,翻转

  • 我们将为 Angular 路由和导航添加上一章中跳过的单元测试

  • 我们将通过将常用操作分离到服务中来使现有的搜索应用程序更加现代化

  • 我们将利用 Angular 的EventEmitter类在不同的组件之间进行通信

准备进行通信

在本章中,我们将采取不同的方法,因为我们已经学习了 TDD 方法。我们在上一章开发了一个小型项目,我们的计划是使用这个项目并使其变得更好,以便向世界展示。

因此,在开始讲解之前,我们必须回顾并识别项目中存在的问题以及改进的范围。为此,我们必须对搜索应用程序的代码库有信心。

加载现有项目

首先,我们将从第七章,翻转复制项目,该项目最初来自github.com/angular/quickstart,并将其重命名为angular-member-search

让我们继续前进,准备运行它:

$ cd angular-member-search
$ npm install 
$ npm start

为了确认安装并运行项目,应用程序将自动在网页浏览器中运行它。

当我们运行项目时,我们应该得到以下输出:

加载现有项目

哦!我们在项目中已经有了端到端测试。在我们进行更新之前,我们必须确保现有的 e2e 测试是成功的。

让我们在单独的控制台中运行e2e测试:

$ npm run e2e

是的,一切测试都成功通过:

加载现有项目

单元测试

在上一章中,我们开始使用自顶向下的方法。目标是基于我们所学的内容详细阐述端到端测试。我们有明确用户场景,我们通过了测试,并且我们的场景通过了我们的实现。

在上一章中,我们只涵盖了端到端测试。因此,在本章中,我们将尽可能多地涵盖单元测试。

此外,在上一章中,我们主要关注 Angular 路由和导航。因此,现在作为一个逻辑上的扩展,我们将探讨如何测试 Angular 路由和导航。

测试组件

在我们进行组件测试之前,我们应该讨论一些关于测试 Angular 组件的观点。我们已经有了一个基本的概念:在 Angular 中,一切都是一些组件的组合。因此,深入了解 Angular 组件测试对我们来说将是非常有益的。

我们可以根据组件的行为和用例以各种方式测试组件。我们甚至可以为多个组件编写测试规范,当它们作为一个应用程序一起工作时。

让我们看看测试组件的一些方法。

隔离测试

隔离测试,也称为单独测试,之所以这样命名,是因为这种测试可以在不需要根据测试规范编译组件的情况下运行。如果它没有编译,它将不会在测试规范中有编译后的模板;只有组件类及其方法。这意味着如果组件的功能不太依赖于 DOM,它可以通过隔离的方式进行测试。

隔离测试主要用于复杂功能或计算测试,其中它只需初始化组件类并调用所有方法。

例如,看看第六章的单元测试,第一步,其中AppComponent负责添加评论和增加它们的点赞数:

beforeEach(() => { 
    comp = new AppComponent(); 
    comp.add('a sample comment'); 
    comp.like(comp.comments[0]); 
}); 

    it('First item in the item should match', () => { 
        expect(comp.comments[0].title).toBe('a sample 
        comment'); 
    }); 

    it('Number of likes should increase on like', () => { 
        expect(comp.comments[0].likes).toEqual(1); 
    }); 

浅层测试

隔离测试有时可以满足测试规范的要求,但并不总是如此。大多数时候,组件都有依赖于 DOM 的功能。在这种情况下,在测试规范中渲染组件的模板非常重要,这样我们就有编译后的模板在作用域内,并且测试规范能够与 DOM 交互。

例如,如果我们想为我们的AppComponent编写一个基本的单元测试,该组件主要依赖于 DOM,因为组件类中没有方法,那么我们只需要编译组件并检查它是否已定义。此外,如果组件的模板在<h1>元素内具有正确的文本,我们还可以有一个测试规范。

代码将如下所示:

beforeEach(async(() => { 
    TestBed.configureTestingModule({ 
        declarations: [ AppComponent ]
    }) 
    .compileComponents(); 
})); 

beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 
    de = fixture.debugElement.query(By.css('h1')); 
}); 

it('should create and initiate the App component', () => { 
    expect(comp).toBeDefined(); 
}); 

it('should have expected test in <h1> element', () => { 
    fixture.detectChanges(); 
    const h1 = de.nativeElement; 
    expect(h1.innerText).toMatch(/My First Angular 2 App/i, 
    '<h1> should say something about "Angular App"'); 
}); 

集成测试

下面是一些关于集成测试的关键点:

  • “集成测试”这个名字应该给我们一些关于它是哪种测试的线索。它与浅层测试类似,因为它也需要编译带有模板的组件并与 DOM 交互。

  • 接下来,我们将查看我们的路由和导航测试套件,其中我们将集成AppComponent、路由器和导航测试套件。

  • 我们已经为AppComponent准备好了测试套件,因为它包括navbar组件和router-outlet组件。所有这些一起工作以满足路由规范。

  • 因此,为了获得对路由器的自信测试规范,我们应该选择集成测试。

在接下来的几节中,我们将通过一个详细的示例来查看路由器测试。

注意

集成测试和浅层测试之间的主要区别在于,集成测试适用于完整应用程序的测试套件或应用程序的小部分,其中多个组件协同工作以解决某些目的。它与端到端测试有一些相似之处,但采用不同的方法。

Karma 配置

在前面的章节中,使用了默认的 Karma 配置,但尚未对此默认配置进行解释。文件监视 是一个有用的默认行为,现在将对其进行审查。

文件监视

当使用 Karma init 命令时,默认启用文件监视。Karma 中的文件监视通过在 karma.conf.js 文件中的以下定义进行配置:

autoWatch: true, 

文件监视功能按预期工作,监视配置中定义的 files 数组中的文件。当文件更新、更改或删除时,Karma 会通过重新运行测试来响应。从 TDD 的角度来看,这是一个很棒的功能,因为测试将在没有任何手动干预的情况下继续运行。

需要注意的主要点是文件的添加。如果被添加的文件不匹配 files 数组中的标准,autoWatch 参数不会对更改做出响应。例如,让我们考虑以下文件定义:

files : [ 'dir1/**/*.js'] 

如果是这样,监视器将找到所有以 .js 结尾的文件和子目录文件。如果新文件位于不同的目录中,而不是 dir1 目录中,那么监视器将无法响应新文件,因为它不在配置的目录中。

测试路由器和导航

我们在 第七章 翻转 中介绍了 Angular 路由器和导航,与一般组件一起。

既然我们已经讨论了 Angular 组件、路由器和导航的不同类型的测试,我们将查看集成测试。为此,我们将使用我们的应用程序组件测试,即我们的基础组件,然后我们将集成导航和 router-outlet 组件测试与应用程序组件一起测试路由器。

测试应用程序组件

在我们进行路由器测试之前,我们将为我们的应用程序组件测试做好准备。在应用程序组件测试中,我们将测试组件是否被正确定义和初始化,然后我们将通过选择 DOM 元素来测试页面标题。

我们在前面章节中学习了浅层测试;当我们与 DOM 元素交互时,我们需要浅层测试。这里也是一样:由于我们将不得不处理 DOM 元素,我们将使用浅层测试作为我们的应用程序组件测试。

对于浅层测试,我们需要依赖于 Angular 核心测试中的 TestBed Angular 测试 API,它将用于编译和初始化测试套件中的组件。除此之外,我们还需要依赖于核心测试中的 ComponentFixture 模块。我们还需要从 Angular 核心和平台 API 中获取两个额外的模块,名为 ByDebugElement,以与 DOM 元素交互。

我们的组件测试将位于 spec/unit/app.component.ts,其结构如下:

import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 
import { By }           from '@angular/platform-browser'; 
import { DebugElement } from '@angular/core'; 

import { AppComponent } from '../../app/app.component'; 
import { NavbarComponent } from '../../app/nav/navbar.component';
import { RouterOutlet } from '@angular/router';

describe('AppComponent test suite', function () { 
  let comp: AppComponent; 
  let fixture: ComponentFixture<AppComponent>; 
  let de: DebugElement; 

  beforeEach(async(() => { 
   TestBed.configureTestingModule({ 
      declarations: [ AppComponent ] 
    }) 
    .compileComponents(); 
  })); 

  beforeEach(() => { 
    fixture = TestBed.createComponent(AppComponent); 
    comp = fixture.componentInstance; 
    de = fixture.debugElement.query(By.css('h1')); 
  }); 

  it('should create and initiate the App component', () => { 
    expect(comp).toBeDefined(); 
  }); 

  it('should have expected test in <h1> element', () => { 
    fixture.detectChanges(); 
    const h1 = de.nativeElement; 
    expect(h1.innerText).toMatch(/My First Angular 2 App/i, 
      '<h1> should say something about "Angular App"'); 
  }); 
}); 

如果我们运行这个测试,我们将看到以下结果:

   Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 2 of 2 SUCCESS
   (0 secs / 0.522 secs)

我们的应用程序组件测试现在已经准备好了;接下来,我们将执行一个路由测试,包括 router-outlet 和导航。

测试路由

Angular 路由不是 Angular 核心的一部分;它是一个单独的模块,在使用之前必须导入。它有一些指令,如 RouterOutletRouterLink,在执行路由活动时发挥着积极作用。为了测试路由,我们首先将测试这些指令,以便为测试完整路由准备平台。

小贴士

我们可以使用实际的路由模块来测试路由,但有时它会给整个路由系统带来一些复杂性。因此,测试规范可能会在没有提供准确错误的情况下失败。为了避免这种情况,建议创建路由占位符并使用这些占位符进行路由测试。

路由占位符

我从 Angular 的官方测试文档中得到了路由占位符的想法。我喜欢路由占位符的想法,并从 Angular 的 GitHub 仓库中的 angular.io/public/docs/_examples/testing/ts/testing/router-stubs.ts 复制了 router-stubs 文件。第一个路由占位符指令是 RouterStubLinksDirective,它负责托管元素或锚点链接 (<a>) 以执行指令的 onClick() 方法。绑定到 [routerLink] 属性的 URL 流向指令的 linkParams 属性。当锚点链接 (<a>) 被点击时,应该触发 onClick() 方法,并将其设置为暂定的 navigateTo 属性。

这个 router-stubs 文件依赖于 Angular 路由和相关指令,包括 RouterLinkRouterOutlet,因此我们需要导入这些。

因此,路由占位符将位于 spec/unit/stub/router-stub.ts,代码如下:

export  {Router, NavigationExtras, RouterLink, RouterOutlet} from '@angular/router'; 

import { Component, Directive, Injectable, Input } from '@angular/core'; 

@Directive({ 
  selector: '[routerLink]', 
  host: { 
    '(click)': 'onClick()' 
  } 
}) 
export class RouterLinkStubDirective { 
  @Input('routerLink') linkParams: any; 
  navigatedTo: any = null; 

  onClick() { 
    this.navigatedTo = this.linkParams; 
  } 
} 

除了 RouterLinkStubDirective,这个占位符还应包含 RouterOutletStubComponent 以支持 router-outlet 指令,以及 RouterStub 以支持主路由模块:

@Component({selector: 'router-outlet', template: ''}) 
export class RouterOutletStubComponent { } 

@Injectable() 
export class RouterStub { 
  navigate(commands: any[], extras?: NavigationExtras) { } 
} 

路由出口和导航测试

如我们所知,router-outlet 和导航 (RouterLink) 菜单与应用程序的着陆页(即我们的应用程序组件)协同工作。测试机制将具有相同的形式。这意味着我们将使用应用程序组件测试这两个模块。

如前所述,我们将在这里使用集成测试进行router-outlet测试。我们已经有了应用程序组件测试套件;现在是时候集成router-outlet和导航(RouterLink),我们将拥有包含router-outletRouterLink的应用程序组件的集成测试套件。

我们有navbar组件,它基本上是一个包含导航RouterLink以通过路由进行导航的导航组件。我们必须将该组件导入到我们的测试套件中,以便它能够正确执行。除了实际的路由模块之外,我们还需要导入我们创建的RouterStub。再次强调,router-stubs包含RouterOutletStubComponentRouterLinkStubDirective组件。

在导入所有必需的组件后,我们将在TestBed配置中声明它们。作为设置的一部分,我们将从测试套件的范围内获取所有navLinks以进行测试并将click事件绑定到linkParams

测试套件的设置将如下所示:

import { NavbarComponent } from '../../app/nav/navbar.component'; 
import { AppComponent } from '../../app/app.component';
import { RouterOutletStubComponent, RouterLinkStubDirective } from './stub/router-stubs.js'; 

describe('AppComponent test suite', function () { 
  let navDestination:any; 
  let navLinks:any; 
  let fixture: ComponentFixture<AppComponent>; 
  let de: DebugElement;  

  beforeEach(async(() => { 
   TestBed.configureTestingModule({ 
      declarations: [ AppComponent, NavbarComponent, 
                     RouterLinkStubDirective, 
                     RouterOutletStubComponent ] 
    }) 
    .compileComponents(); 
  })); 

  beforeEach(() => { 
    fixture.detectChanges(); 

    navDestination = fixture.debugElement 
      .queryAll(By.directive(RouterLinkStubDirective)); 

    navLinks = navDestination 
      .map(de => de.injector.get(RouterLinkStubDirective) as 
      RouterLinkStubDirective); 
  });

对于测试规范,首先我们将测试导航菜单中的链接参数。我们有navLinks,我们将它们与navLinkslinkParams进行匹配。

然后,我们将测试点击导航菜单项时的预期导航。我们将使用navigatedTo方法进行测试。

我们的测试规范将如下所示:

  it('can get RouterLinks from template', () => { 
    expect(navLinks.length).toBe(3, 'should have 3 links'); 
    expect(navLinks[0].linkParams).toBe('/view1', '1st link should
    go to View1'); 
    expect(navLinks[1].linkParams).toBe('/view2', '1st link should
    go to View2'); 
    expect(navLinks[2].linkParams).toBe('/members', '1st link should
    go to members search page'); 
  }); 

  it('can click nav items link in template and navigate 
  accordingly', () => { 
    navDestination[0].triggerEventHandler('click', null); 
    fixture.detectChanges(); 
    expect(navLinks[0].navigatedTo).toBe('/view1'); 

    navDestination[1].triggerEventHandler('click', null); 
    fixture.detectChanges(); 
    expect(navLinks[1].navigatedTo).toBe('/view2'); 

    navDestination[2].triggerEventHandler('click', null); 
    fixture.detectChanges(); 
    expect(navLinks[2].navigatedTo).toBe('/members'); 
  }); 

因此,我们可以说这将涵盖router-outletrouterLink的测试,这将确认路由链接按预期工作,并且我们能够在点击导航菜单后通过预期的路由进行导航。

实施集成测试

我们的测试规范已准备就绪。我们一直在计划进行集成测试,现在我们可以进行一次测试。在这里,我们将应用程序组件和navbar组件以及router-outletrouterLink结合起来,以测试路由和导航。我们必须借助浏览器平台 API 中的debugElement模块与 DOM 元素进行交互。

测试套件已准备就绪--现在是时候运行测试了。

让我们使用以下命令运行它:

npm test 

所有测试规范都按预期通过。结果将如下所示:

   Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 4 of 4 SUCCESS
   (0 secs / 1.022 secs) 

更多测试...

我们刚刚添加了一些测试,这些测试将涵盖我们迄今为止开发的一些功能,主要关注路由(router-outletrouterLink)。

我们将为成员和搜索功能添加更多测试,但我们将更新现有搜索和成员列表功能的操作行为。除此之外,我们的当前代码库在组件功能之间没有适当的解耦,这将使得单独测试功能变得复杂。

我们已经有了端到端测试,它将验证我们从组件中期望的输出,但对于单元测试,我们需要重构代码并解耦它们。在更新行为和重构正确的代码库之后,我们将涵盖其余功能的测试。

应用程序行为概述

让我们快速概述一下搜索应用程序:

  • 我们的搜索应用程序在 DOM 中调用Members组件。它包含两个主要部分:搜索区域和结果区域。

  • 在搜索区域,我们输入搜索查询并将其提交到结果区域以获取预期的结果。

  • 结果区域根据搜索查询列出成员列表。我们可能已经意识到,我们在Members组件的初始化期间获取了所有数据;这是因为我们使用ngOnInit()调用Members组件的search()方法,并且它返回所有数据,因为我们的逻辑已经设置为在没有设置搜索查询时返回所有数据。

  • 通过点击成员的姓名,我们可以在详情页面上看到该成员的详细信息。

更新应用程序行为

根据之前的规范,似乎我们在搜索功能中存在一些不正确的行为。目前,我们在初始化搜索组件的成员时调用search()。这似乎有点不对;我们应该在输入搜索查询和/或点击搜索按钮后开始搜索。

预期的行为是它将首先加载所有成员数据,然后在开始搜索后,数据列表将根据搜索查询进行更新。

为了做到这一点,让我们更新members.component.ts中的ngOnInit()方法,并添加一个新的方法getMember(),以便在组件初始化时拥有整个数据列表。

预期的更改如下:

ngOnInit() { 
    this.getMembers(); 
  } 

  getMembers() { 
    this.getData() 
    .then(data => { 
      data.map(item => { 
        this.memberList.push(item); 
      }); 
    }) 
    return this.memberList; 
  } 

search() { 
    // Do Search 
  } 

识别问题

基于现有代码,看起来我们在members.component.tsperson.component.ts中定义了两次getData()方法,因为在两个组件中我们都需要调用 JSON 数据源来获取成员数据集。

那么,这有什么问题吗?这是不好的做法,因为它重复了代码,而当应用程序变得庞大和复杂时,代码重复难以管理。

例如,现在我们有两个这样的方法:

getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 

如果我们必须更改数据源 URL 或 API,我们就必须在这两个地方更改这个方法。更改两次并不那么困难,但如果是 10-12 次,或者对于更大的应用程序来说更多呢?

是的,这是一个问题,需要解决方案。

寻找解决方案

我们已经确定了问题,即代码重复。我们知道解决方案:我们必须在公共位置编写该方法并在两个组件中使用它。简而言之,我们必须使此方法可重用,以便每个组件都可以共享它。

这看起来很简单,但我们必须以 Angular 的方式来做。我们不能只是将方法移动到单独的文件并导入。

Angular 为这种情况引入了服务。现在我们将通过示例查看一些这些服务。

Angular 服务

Angular 服务是为了在组件之间共享代码而引入的。所以如果我们需要许多组件的代码,建议创建一个单一的可重用服务,并且无论何时需要那段代码,我们都可以将其注入到组件中并按需使用其方法。

服务用于抽象应用程序逻辑。它们用于为特定操作提供单一责任。单一责任允许组件易于测试和更改。这是因为重点是单个组件,而不是所有内部依赖项。

通常,一个服务充当任何应用程序的数据源。每当我们需要一段代码与服务器通信以获取数据(通常是 JSON)时,我们就会使用一个服务。

这是因为大多数组件都需要访问数据,每个人都可以根据需要注入通用服务。因此,我们有一个常用的代码片段,这实际上是我们的应用程序的数据层。我们应该将这些部分移动到服务中,使我们的应用程序更智能,这样我们就可以告诉全世界我们不会以任何方式重复代码。

我们现在有服务了吗?

按照计划,我们已经将getData()方法从members.component.tsperson.component.ts组件移动到了一个新的文件中,这样我们就可以消除代码重复。

app/services/members.service.ts创建一个新的文件,创建一个新的类以导出,命名为MembersService,并将getData()方法移到那里。除了移动方法外,我们还需要从 Angular HTTP 模块导入{ Http, Response },因为getData依赖于 HTTP。

观察以下代码示例:

import { Http, Response } from '@angular/http'; 

export class MembersService { 
  constructor(private http:Http) { 

  } 

  getAPIData() { 
    return this.http.get('app/data/people.json'); 
  } 

  getData() { 
    return this.getAPIData() 
      .toPromise() 
      .then(response => response.json()); 
  } 

} 

我们现在有服务了,我们可以开始使用它。让我们导入并使用它来在 Members 组件中。

等一下;在那之前,我们必须将服务导入到应用程序模块中以便识别它。只要它是服务,我们就必须将其标识为提供者;服务将充当服务提供者。

我们的app.module.ts文件将看起来像这样:

import {MembersService} from './services/members.service'; 

@NgModule({ 
  declarations: [AppComponent, NavbarComponent, ....], 
  imports     : [BrowserModule, FormsModule, ......], 
  providers   : [MembersService], 
  bootstrap   : [AppComponent] 
}) 

现在,为了在组件中使用服务,我们必须导入并使用服务名MembersService将其注入到我们的 MembersComponents 中。只要我们将服务作为组件的构造函数注入,我们就可以在整个组件中使用该服务。要访问方法,我们需要调用它this.membersService.getData()

因此,我们的 Members 组件将看起来像这样:

import { MembersService } from '../services/members.service'; 
@Component({ 
   ............ 
}) 
export class MembersComponent implements OnInit { 
 constructor(public membersService: MembersService, private router:Router) { 

  } 

  getMembers() { 
    this.membersService.getData() 
    .then(data => { 
      data.map(item => { 
        this.memberList.push(item); 
      }); 
    }) 
    return this.memberList; 
  } 

是时候运行并查看输出来看看服务是如何与 Members 组件一起工作的。

让浏览器指向http://localhost:3000/members

哎呀!发生了什么?我们在浏览器控制台中遇到了错误:

Error: (SystemJS) Can't resolve all parameters for MembersService: (?) 

根据错误,我们犯了一个错误:SystemJS(用作加载的模块)不能注入MembersService,因为我们没有在服务中添加某些内容来使其完美。在 Angular 中,我们必须在每个服务中说明它是否可注入;如果不这样做,我们就无法将此服务注入到任何组件中。

为了做到这一点,我们必须使用 Angular 的Injectable装饰器。我们将简要地了解一下它。

可注入服务

Injectable 装饰器是 Angular 核心库的一部分,用于创建可注入服务。如果不将其定义为可注入的,就无法识别服务的依赖项。要将其定义为可注入的,我们将在类定义的顶部使用@Injectable()

代码将看起来像这样:

import { Injectable } from '@angular/core'; 
import { Http, Response } from '@angular/http'; 

@Injectable() 
export class MembersService { 
  constructor(private http:Http) { 

  } 

  getData() { 
    return this.http.get('app/data/people.json') 
      .toPromise() 
      .then(response => response.json()); 
  } 
} 

我们已经使服务可注入。现在,我们应该可以将其注入到Members组件中,并将浏览器指向http://localhost:3000/members

哈喽!没有更多错误,我们正在获取预期的数据列表:

可注入服务

看起来我们的服务是可注入的并且运行良好。现在是时候将其实现到PersonComponent中,因为我们也需要在那个组件上使用数据服务。和Members组件一样,让我们使用服务名membersService将其导入并注入到PersonComponent中。再次,我们将不得不使用this.membersService.getData()来访问数据服务的方法。

我们的PersonComponent将看起来像这样:

import { MembersService } from '../../services/members.service'; 

@Component({ 
  ........... 
}) 
export class PersonComponent implements OnInit { 
  constructor(public membersService: MembersService, private route: ActivatedRoute, private router: Router) { 

  } 

.................... 

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

是时候运行并查看服务与Members组件一起工作的输出了。

我们有我们的端到端测试,它将确认新的更改是否一切顺利:

$ npm run e2e

是的,一切通过成功:

可注入服务

哈喽!我们的代码重构没有影响我们的预期行为。

服务将为您带来更多

为了获得服务的全部好处,我们将从MembersPerson组件中移动两个更多的方法。在此之前,这些方法是组件特定的;现在,通过将它们添加到服务中,这些方法可以通过注入服务从任何组件中使用。

也许我们将来会从这次更改中受益,但想保持这些方法与组件解耦。

新增的代码将看起来像这样:

@Injectable() 
export class MembersService { 
  constructor(private http:Http) { 

  } 

  ............ 

  searchQuery(q:string) { 
    if (!q || q === '*') { 
      q = ''; 
    } else { 
      q = q.toLowerCase(); 
    } 
    return this.getData() 
      .then(data => { 
      let results:any = []; 
      data.map(item => { 
        if (JSON.stringify(item).toLowerCase().includes(q)) { 
          results.push(item); 
        } 
      }); 
      return results; 
    }); 
  } 

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

测试服务

代码解耦和分离背后的目标是使代码可测试。我们做到了,我们将数据检索部分从Members组件中分离出来,并创建了一个服务,这样它将很容易进行测试。服务是可注入的;除此之外,它和 Angular 组件类似。因此,为了执行单元测试,我们将测试服务包含的方法。

测试服务注入

和其他 Angular 组件一样,我们可以测试服务是否定义良好。但主要区别在于,只要服务是可注入的,我们就需要在测试规范中注入它以获取要测试的实例。

对于一个示例测试规范,我们可以这样设置:它会导入 TestBedinject,然后使用 MembersService 作为提供者配置 TestingModule。然后,在测试规范中,我们将注入服务并检查服务是否按预期定义。

我们的示例测试套件将如下所示:

import { inject, TestBed } from '@angular/core/testing'; 
import { MembersService } from '../../app/services/members.service'; 

describe('Given service should be defined', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [ 
        MembersService, 
      ],  
    }); 
  }); 

 it('should initiate the member service', inject([MembersService], (membersService) => { 
    expect(membersService).toBeDefined(); 
  })); 

}); 

对于这个测试,预期的结果将是 true。

测试 HTTP 请求

为了进行 HTTP 请求的单元测试,我们必须使用异步技术来保持 HTTP 调用异步,在 Angular 测试中,我们将使用 fakeAsync 模块,这是一个用于模拟 HTTP 请求的异步模块。

等等,“模拟”?

嗯,是的;为了在 Angular 测试套件中测试 HTTP 请求,我们不需要进行实际的 HTTP 请求。为了达到 HTTP 请求的效果,我们可以模拟我们的 HTTP 服务;Angular 已经提供了一个名为 MockBackend 的模拟服务。

MockBackend 是一个可以被配置为提供 HTTP 模拟请求的模拟响应的类,并且它将像 HTTP 服务一样工作,但不会进行实际的网络请求。

在我们配置了 MockBackend 之后,它可以被注入到 HTTP 中。因此,从我们使用 http.get 的服务中,我们将得到预期的数据返回。

我们的带有 HTTP 请求的测试套件将如下所示:

import { fakeAsync, inject, TestBed } from '@angular/core/testing'; 

import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; 
import { MockBackend, MockConnection } from '@angular/http/testing'; 

import { MembersService } from '../../app/services/members.service'; 

const mockData = { 
  "id": 2, 
  "name": "Demaryius Thomas", 
  "phone": "(720) 213-9876", 
  "address": { 
    "street": "5555 Marion Street", 
    "city": "Denver", 
    "state": "CO", 
    "zip": "80202" 
  } 
}; 

describe('Given service should be defined and response HTTP request', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [ 
        MembersService, 
        BaseRequestOptions, 
        MockBackend, 
        { 
          provide: Http, 
          useFactory: (backend, defaultOptions) => { 
            return new Http(backend, defaultOptions); 
          }, 
          deps: [MockBackend, BaseRequestOptions], 
        }, 
      ], 
    }); 
  });
}); 

在这里,首先,除了导入 MockBackend,我们还导入了 MockConnection,它用于订阅后端连接并提供连接数据给下一步。然后,我们配置 MockBackend,它将返回 HTTP 对象。

接下来,我们将通过注入 MockBackendMembersService 准备我们的测试规范:

  it('should return response when subscribed to getUsers', fakeAsync( 
    inject([MockBackend, MembersService], (backend, membersService) => { 
      backend.connections.subscribe( 
        (c: MockConnection) => { 
          c.mockRespond( 
            new Response( 
              new ResponseOptions({ body: mockData }) 
            )); 
          }); 

        membersService.getAPIData().subscribe(res => { 
          expect(res.json()).toEqual(mockData); 
        }); 
  }))); 

}); 

在测试规范中,我们注入了 MockBackend,除了 MembersService。MockBackend 将使用 MockConnection 对象订阅 backend 服务。MockConnection 将创建一个新的 ResponseOptions 对象,其中,我们可以使用 ResponseOptions 对象来配置我们的响应属性。

在这里,我们只设置了响应对象的 body 属性,并将 body 值设置为预定义的 mockData 对象。

服务模拟

我们可以使用模拟数据测试服务。例如,我们可以创建一个名为 MembersServiceSpy 的模拟 MembersService 版本,它将模拟该服务所需的所有必要功能。

这个模拟服务将返回一个带有模拟数据的已解析 Promise,因此我们可以直接使用这个模拟方法进行测试。它将为服务中的所有方法创建一个间谍,并为每个单独的方法返回一个单独的 Promise

模拟服务将位于 spec/unit/stub/members.service.stub.ts,如下所示:

import { Component, Directive, Injectable, Input } from '@angular/core'; 

export class MembersServiceSpy { 
  members = { 
    "id": 2, 
    "name": "Demaryius Thomas", 
    "phone": "(720) 213-9876", 
    "address": { 
      "street": "5555 Marion Street", 
      "city": "Denver", 
      "state": "CO", 
      "zip": "80202" 
    } 
  }; 

  getData = jasmine.createSpy('getData').and.callFake( 
    () => Promise 
      .resolve(true) 
      .then(() => Object.assign({}, this.members)) 
  ); 

  getPerson = jasmine.createSpy('getPerson').and.callFake( 
    () => Promise 
      .resolve(true) 
      .then(() => Object.assign({}, this.members)) 
  ); 

  searchQuery = jasmine.createSpy('searchQuery').and.callFake( 
    () => Promise 
      .resolve(true) 
      .then(() => Object.assign({}, this.members)) 
  ); 

} 

使用模拟数据的 Service 测试

在这里,我们将使用模拟数据测试 MembersService。为此,我们需要导入模拟服务。并且使用 TestBed 配置,我们必须提供 MemberServiceSpy 作为服务而不是实际的成员服务。

MembersService 测试套件的代码将如下所示:

import { MembersServiceSpy } from './stub/members.service.stub.js'; 
import { MembersService } from '../../app/services/members.service'; 

const mockData = { 
  "id": 2, 
  "name": "Demaryius Thomas", 
  "phone": "(720) 213-9876", 
  "address": { 
    "street": "5555 Marion Street", 
    "city": "Denver", 
    "state": "CO", 
    "zip": "80202" 
  } 
}; 

describe('Given service will response for every method', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [{ provide: MembersService, useClass: MembersServiceSpy }] 
    }); 
  }); 

  it('should return data', fakeAsync(inject( 
    [MembersService], (service) => { 
      service.getData(); 
      expect(service.members).toEqual(mockData); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.searchQuery('Thomas'); 
        expect(service.members.name).toBe('Demaryius Thomas'); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.getPerson(2); 
        expect(service.members.id).toBe(2); 
    }))); 

}); 

组合和运行服务的测试

我们在这里有两个成员服务的测试套件。我们可以将它们合并在一起并运行测试。

完整测试套件的代码将如下代码片段所示:

import { fakeAsync, inject, TestBed } from '@angular/core/testing'; 

import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; 
import { MockBackend, MockConnection } from '@angular/http/testing'; 

import { MembersServiceSpy } from './stub/members.service.stub.js'; 
import { MembersService } from '../../app/services/members.service'; 

const mockData = { 
  "id": 2, 
  "name": "Demaryius Thomas", 
  "phone": "(720) 213-9876", 
  "address": { 
    "street": "5555 Marion Street", 
    "city": "Denver", 
    "state": "CO", 
    "zip": "80202" 
  } 
}; 

describe('Given service should be defined and response HTTP request', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [ 
        MembersService, 
        BaseRequestOptions, 
        MockBackend, 
        { 
          provide: Http, 
          useFactory: (backend, defaultOptions) => { 
            return new Http(backend, defaultOptions); 
          }, 
          deps: [MockBackend, BaseRequestOptions], 
        }, 
      ], 
    }); 
  }); 

  it('should initiate the member service', inject([MembersService], (membersService) => { 
    expect(membersService).toBeDefined(); 
  })); 

  it('should return response when send HTTP request', fakeAsync( 
    inject([MockBackend, MembersService], (backend, membersService) => { 
      backend.connections.subscribe( 
        (c: MockConnection) => { 
          c.mockRespond( 
            new Response( 
              new ResponseOptions({ body: mockData }) 
            )); 
          }); 

        membersService.getAPIData().subscribe(res => { 
          expect(res.json()).toEqual(mockData); 
        }); 
  }))); 

}); 

describe('Given service will response for every method', () => { 

  beforeEach(() => { 
    TestBed.configureTestingModule({ 
      providers: [{ provide: MembersService, useClass: MembersServiceSpy }] 
    }); 
  }); 

  it('should return data', fakeAsync(inject( 
    [MembersService], (service) => { 
      service.getData(); 
      expect(service.members).toEqual(mockData); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.searchQuery('Thomas'); 
        expect(service.members.name).toBe('Demaryius Thomas'); 
    }))); 

    it('should return data', fakeAsync(inject( 
      [MembersService], (service) => { 
        service.getPerson(2); 
        expect(service.members.id).toBe(2); 
    }))); 

}); 

成员服务的测试套件已准备好运行。让我们用这个命令运行它:

npm test 

所有测试规范都按预期通过。结果将如下所示:

   Chrome 54.0.2840 (Mac OS X 10.10.5): Executed 9 of 9 SUCCESS 
   (0 secs / 4.542 secs) 

通过事件的力量进行通信

与 Angular 1.x 相比,Angular 具有更强大的事件处理能力。Angular 1.x 具有双向数据绑定,而 Angular 不推荐这样做。Angular 通过事件的力量处理数据和模板之间的通信。

Angular 项目建立在一些组件的组合之上。为了运行,这些组件需要相互通信以共享数据和事件。通常,当组件具有父子关系时,它们需要通信。Angular 在父组件和子组件之间通信的方式有几种。其中最好的方式是通过处理自定义事件。我们将查看自定义事件的详细信息,并了解它们如何与我们的搜索应用程序一起工作。

Angular 事件

如我们所知,Angular 推荐单向数据绑定,这意味着只有从组件到 DOM 元素。这是一种单向数据流,这也是 Angular 的工作方式。那么当我们需要从 DOM 元素到组件的数据流时怎么办?这样做取决于不同的事件,如点击、按键、鼠标悬停和触摸。这些事件将与 DOM 元素绑定,以监听用户操作并将该操作传递给组件。

事件绑定语法由目标事件组成,目标事件位于等号左侧的括号内。组件将目标事件作为方法包含,因此每当事件触发时,它将调用组件中的方法。让我们看看搜索表单中的事件:

<button type="button" (click)="search()">Search</button>

任何元素的任何事件都是常见的目标,但在 Angular 中略有不同,因为 Angular 首先检查目标名称是否与任何已知指令或组件的事件属性匹配。

Angular 中的自定义事件

自定义事件由 Angular EventEmitter的指令或组件引发。指令创建一个EventEmitter对象,并通过@Output装饰器将其自身作为属性公开。我们将接下来查看@Output装饰器的详细信息。在将EventEmitter对象公开为属性之后,指令将调用EventEmitter.emit(value)来触发事件并将值传递给父级指令。

自定义指令/组件类将如下定义自定义事件:

  @Output() someCustomEvent: EventEmitter<any> = new EventEmitter(); 

    this.someCustomEvent.emit(value);

父级指令将通过绑定到这个属性来监听事件,并通过$event对象接收值。

父级指令/组件将包含自定义指令,如下所示,其中它将包含自定义事件someCustomEvent,这将触发父级指令的doSomething()方法:

<custom-component (someCustomEvent)="doSomething($event)"></custom-component> 

父指令/组件将包含doSomething()方法,如下所示:

doSomething(someValue) { 
    this.value = someValue; 
} 

输出和EventEmitter API

输出是 Angular 核心中的一个装饰器类,用于从子组件传递自定义事件到父组件。要使用它,我们需要从@angular/core导入它。

当我们将自定义事件设置为@Output时,该事件将在父组件中可用以进行监听。此装饰器将放置在类内部,如下所示:

export class SearchComponent { 
  @Output() someCustomEvent: EventEmitter<any> = new EventEmitter(); 
}

EventEmitter也是 Angular 的一个核心类。当我们需要使用它时,我们必须从@angular/core导入它。EventEmitter API 用于在子组件中的值发生变化时通过调用EventEmitter.emit(value)来通知父组件。正如我们所知,父组件始终监听自定义事件。

进一步规划改进

我们目前拥有的搜索应用程序是一个简单的搜索应用程序。但我们可以通过保持其简单性来使其变得更好。我的意思是,我们可以以最佳方式做到这一点,就像我们试图通过将可重用代码分离到新服务中来解耦数据逻辑一样。

我们还有一些其他的事情要改进。看起来我们的应用程序还没有完全解耦。我们的组件没有像预期的那样解耦。我们正在谈论包含搜索功能和成员列表功能的MembersComponent

我们将遵循单一职责原则,这意味着每个组件都应该有一个单一职责。在这里,MembersComponent有两个。因此,我们应该将这个组件分解为两个单独的组件。

让我们将它分解为两个单独的组件,分别称为MembersComponentSearchComponent。实际上,我们只是为新组件SearchComponent制定了一个计划,并将搜索功能从成员组件中迁移到那里。

现在,让我们为两个组件预期的行为制定一个计划:

  • 搜索组件将负责接收用户输入作为搜索查询,并使用我们拥有的服务获取预期的搜索结果。

  • 然后,我们将搜索结果传递给成员组件

  • 成员组件将从搜索组件获取搜索结果,并将数据列表绑定到 DOM

  • 这两个组件将通过事件进行通信和交换数据

计划是通过遵循最佳实践和使用 Angular 的内置功能来使这个简单的应用程序完美。

搜索组件

如计划所示,我们必须将搜索功能从成员组件中分离出来。为此,让我们在app/search/search.component.ts中创建一个新的组件SearchComponent,并创建搜索组件的模板文件。模板文件将简单地包含搜索表单。

搜索组件文件将需要导入和注入MembersService,因为这将用于根据搜索查询执行搜索。组件将包含搜索查询,并将请求服务进行搜索并获取搜索结果。

搜索组件的代码将看起来像这样:

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

import { MembersService, Person } from '../services/members.service'; 

@Component({ 
  selector: 'app-search', 
  moduleId: module.id, 
  templateUrl: 'search.component.html' 
}) 
export class SearchComponent { 
  query: string; 
  memberList: Array<Person> = []; 

  constructor(public membersService: MembersService) { 

  } 

  search() { 
    this.doSearch(); 
  } 

  doSearch(): void { 
    this.membersService.searchQuery(this.query) 
    .then(results => { 
      this.memberList = results; 
    }); 
  } 

} 

搜索组件的模板将看起来像这样:

<form> 
  <input type="search" [(ngModel)]="query" name="query" (keyup.enter)="search()"> 
  <button type="button" (click)="search()">Search</button> 
</form> 

只要我们的应用程序输出没有中断,我们就必须将搜索组件绑定到成员列表页面,就像之前一样。所以,我们必须将搜索组件添加到成员组件的模板中。在这种情况下,它将成为成员组件的子组件。

成员组件的模板将看起来像这样:

<h2>Members</h2> 
<app-search></app-search> 
<table *ngIf="memberList" id="searchList"> 
  ...... 
</table> 

启用组件间的共享

现在我们有两个独立的组件,搜索和成员组件。搜索组件已经被添加到成员组件中,但搜索结果在成员组件中不可用。

搜索和成员是独立的组件,它们之间没有桥梁。两者都有一个独立的范围来包含它们的元素和变量。

要在组件间共享数据,我们需要启用它们之间的通信。如前所述,Angular 事件将帮助我们启用搜索和成员组件之间的通信。从搜索组件出发,我们需要使用 Angular 自定义事件与其父组件MembersComponent通信。

与父组件通信

搜索组件是成员组件的子组件。它们需要相互通信以共享数据。我们将需要使用 Angular 的EventEmitter API 来帮助使用自定义事件,这样我们就可以在得到结果后从搜索组件中发出搜索结果。除此之外,我们还需要使用@OutPut装饰器将搜索结果设置为输出,以便用于父组件。

要使用这两个组件,我们需要从 Angular core 中导入它们。然后,我们需要将@OutputsearchResult设置为一个新的EventEmitter实例。这个@Output装饰器使得searchResult属性可以作为事件绑定使用。

当搜索组件更新搜索结果时,我们希望通知父组件searchResult事件已经发生。为此,我们需要调用emit(data),其中searchResult是我们已声明的带有@Output装饰器的 Emitter 对象。emit()方法用于在每次通过自定义事件传递结果时通知。

现在,成员组件可以获取$event对象,因为我们已经通过(searchRessult)="anyMethod($event);"将其传递到模板中。

在更新了EventEmitter之后,搜索组件将看起来像这样:

import { Component, Output, EventEmitter } from '@angular/core'; 

@Component({ 
  ................... 
}) 
export class SearchComponent { 
  ............. 
  @Output() searchResult: EventEmitter<any> = new EventEmitter(); 

  doSearch(): void { 
    this.membersService.searchQuery(this.query) 
    .then(results => { 
      this.memberList = results; 
      this.searchResult.emit(this.memberList)); 
    }); 
  } 

} 

现在是时候与MembersComponent通信了。让我们在成员组件中声明onSearch()方法,它将接受事件作为参数。

成员组件将变为以下形式:

export class MembersComponent implements OnInit { 

  ngOnInit() { 
    this.getMembers(); 
  } 

  onSearch(searchResult) { 
    this.memberList = searchResult; 
  } 

  getMembers() { 
    this.membersService.getData() 
    .then(data => { 
      data.map(item => { 
        this.memberList.push(item); 
      }); 
    }) 
    return this.memberList; 
  } 

} 

由于我们是从成员模板中添加搜索组件,所以让我们将onSearch函数连接到搜索组件标签。我们将用括号包围它,命名为(searchResult)来告诉 Angular 这是一个事件绑定。

搜索组件的模板将如下所示:

<h2>Members</h2> 
<app-search (searchResult)="onSearch($event)" ></app-search> 
<table *ngIf="memberList" id="searchList"> 
  ...... 
</table> 

检查重构后的输出

搜索应用程序将被重新命名为商店应用程序,而不是重写已经编写好的搜索功能。为了利用现有的搜索项目,它将被复制到一个新的项目文件中。然后,新项目将使用测试来驱动开发更改和重构。重构步骤已被省略,但代码审查将显示代码和测试是如何被修改以创建产品应用程序的。

是时候运行它并查看服务如何与 Members 组件一起工作了。让我们将浏览器指向 http://localhost:3000/members

重构后的输出检查

我们有端到端测试,它将确认新更改一切正常:

 $ npm run e2e

是的,我们可以看到一切都成功通过:

重构后的输出检查

是的!我们的代码重构没有影响我们预期的行为。

当前项目目录

我们已经更新和重构了代码,为此我们有一些新的组件、服务等等。现在,我们将有一个新的项目结构,将逻辑分离并解耦组件。

我们当前的目录结构如下所示:

重构后的输出检查

接下来

在这本书中,我试图将主题覆盖到一定水平,以便任何人都可以从基于 Angular 的测试驱动开发开始。但是,我们有很多东西都跳过了,最重要的是 rxJS。

rxJS 是基于响应式编程的一个独立模块。因此,我们需要熟悉响应式编程才能理解它。

可观察对象

默认情况下,Angular 中的 HTTP 请求返回可观察对象作为响应,而不是解析的承诺。由于我们没有在这里查看 rxJS,我们跳过了可观察对象并将响应转换为承诺。但我们应该学习可观察对象如何在 Angular 中工作。

发布和订阅

发布和订阅消息是一个强大的工具,但就像任何事物一样,如果使用不当,可能会导致混乱。

消息可以通过两种方式发布:emit 或 broadcast。了解它们之间的区别很重要,因为它们的工作方式略有不同,可能会影响我们应用程序的性能。

自我测试问题

Q1. 回调函数是指在异步函数完成后被调用的函数。

  • 正确

  • 错误

Q2. 异步函数总是按照它们被调用的顺序完成。

  • 正确

  • 错误

Q3. 有一个名为 MockBackend 的模块可以在 Angular 中模拟 HTTP 调用以进行单元测试。

  • 正确

  • 错误

Q4. 在 Angular 中,EventEmitter API 用于组件通信。

  • 正确

  • 错误

摘要

在本章中,我们探讨了 Angular 中的服务和事件的力量。我们还看到了一些使用服务和事件分离代码的例子。

此外,我们研究了 Angular 组件的不同测试类型,并为 Angular 路由编写了单元测试,并将其与应用程序组件和导航集成。我们还进一步探索了 Karma 的配置,以便使用其功能。

现在我们已经到达了本书的结尾,是时候将我们在现实世界中的知识付诸实践了。在离开之前,让我们快速回顾一下我们已经学到的内容。我们学习了测试驱动开发(TDD),TDD 如何与 JavaScript 上下文协同工作,以及可用的测试工具、技术和框架。我们还通过实际的 Angular 项目学习了 Karma 和 Protractor。现在我们知道了如何为 Angular 项目编写单元测试和端到端测试。

本书向您展示了实践 TDD 的路径;现在,你的任务是继续学习,提高这方面的知识,并通过更复杂的项目进行更多实践,以便对 TDD 更加自信。