测试

本章提供了一些测试Angular应用的提示和技巧。虽然这里讲述了一些常规测试理念和技巧,但是其重点是测试用Angular编写的应用。

目录

Contents

以上主题繁多。幸运的是,你可以慢慢地阅读并立刻应用每一个主题。

在线例子

这篇指南会展示一个范例应用的所有测试,这个范例应用和《英雄指南》教程非常像。 本章中的这个范例应用及其所有测试都有在线例子,以供查看、试验和下载。

回到顶部

Angular测试入门

本章教你如何编写测试程序来探索和确认应用的行为。测试的作用有:

  1. 测试守护由于代码变化而打破已有代码(“回归”)的情况。

  2. 不管代码被正确使用还是错误使用,测试程序起到澄清代码的作用。

  3. 测试程序暴露设计和实现可能出现的错误。测试程序从很多角度为代码亮出警报灯。当应用程序很难被测试时, 其根本原因一般都是设计缺陷,这种缺陷最好立刻被修正,不要等到它变得很难被修复的时候才行动。

本章假设你熟悉测试。但是如果你不熟悉也没有关系。有很多书本和在线资源可以帮助你。

工具与技术

你可以用多种工具和技术来编写和运行Angular测试程序。本章介绍了一些大家已经知道能良好工作的选择。

技术

目的

Jasmine

Jasmine测试框架提供了所有编写基本测试的工具。 它自带HTML测试运行器,用来在浏览器中执行测试程序。

Angular测试工具

Angular测试工具为被测试的Angular应用代码创建测试环境。在应用代码与Angular环境互动时,使用Angular测试工具来限制和控制应用的部分代码。

Karma

karma测试运行器是在开发应用的过程中 编写和运行单元测试的理想工具。 它能成为项目开发和连续一体化进程的不可分割的一部分。本章讲述了如何用Karma设置和运行测试程序。

Protractor

使用Protractor来编写和运行端对端(e2e)测试程序。端对端测试程序像用户体验应用程序那样探索它。 在端对端测试中,一条进程运行真正的应用,另一条进程运行Protractor测试程序,模拟用户行为,判断应用在浏览器中的反应是否正确。

环境设置

要开始单元测试,有两条捷径:

  1. 遵循环境设置中给出的步骤开始一个新项目。

  2. 使用Angular CLI创建新的项目。

以上两种方法都安装在各自的模式下为应用预先配置的npm包、文件和脚本。它们的文件和规程有一点不同,但是它们的核心部分是一样的,并且在测试代码方面没有任何区别。

本章中,该应用及其测试都是基于环境设置步骤的。 对单元测试的环境设置文件的讨论,参见后面

独立单元测试 vs. Angular测试工具集

独立单元测试用于测试那些完全不依赖Angular或不需要注入值的类实例。 测试程序是所有new创建该类的实例,为构造函数参数提供所需的测试替身,然后测试该实例的API接口。

我们应该为管道和服务书写独立单元测试。

我们也同样可以对组件写独立单元测试。 不过,独立单元测试无法体现组件与Angular的交互。 具体来说,就是不能发现组件类如何与它的模板或其它组件交互。

这时你需要Angular测试工具集。 Angular测试工具集包括TestBed类和一些来自@angular/core/testing的助手函数。 本章将会重点讲解它们,通过第一个组件测试来讲解。 本章稍后的部分将展示Angular测试工具集的全貌。

但首先,我们要先随便写一个测试来验证测试环境是否已经就绪了,并掌握一些基础的测试技术。

回到顶部

第一个karma测试

编写简单的测试程序,来确认以上的配置是否工作正常。

在应用的根目录app/创建新文件,名叫1st.spec.ts

用Jasmine编写的测试程序都被叫做specs文件名后缀必须是.spec.ts,这是karma.conf.js和其它工具所坚持和遵守的规约。

将测试程序spec放到app/文件夹下的任何位置。 karma.conf.js告诉Karma在这个文件夹中寻找测试程序spec文件,原因在 这里 有所解释。

添加下面的代码到app/1st.spec.ts

src/app/1st.spec.ts

describe('1st tests', () => {
  it('true is true', () => expect(true).toBe(true));
});

运行Karma

使用下面的命令从命令行中编译并在Karma中运行上面的测试程序。

npm test

该命令编译应用及其测试代码,并启动Karma。 两个进程都监视相关文件,往控制台输入信息和检测到变化时自动重新运行。

《快速起步》在npm的package.json中的scripts里定义了test命令。 Angular CLI使用不同的命令来做同样的事情。对不同的环境采取不同的方案。

等一小段时间后,Karma便打开浏览器并开始向控制台输出。

Karma browser

隐藏(不要关闭)浏览器,查看控制台的输出,应该是这样的:

> npm test
...
[0] 1:37:03 PM - Compilation complete. Watching for file changes.
...
[1] Chrome 51.0.2704: Executed 0 of 0 SUCCESS
    Chrome 51.0.2704: Executed 1 of 1 SUCCESS
SUCCESS (0.005 secs / 0.005 secs)

编译器和Karma都会持续运行。编译器的输入信息前面有[0]Karma的输出前面有[1]

将期望从true变换为false

编译器监视器检测到这个变化并重新编译。

[0] 1:49:21 PM - File change detected. Starting incremental compilation...
[0] 1:49:25 PM - Compilation complete. Watching for file changes.

Karma监视器检测到编译器输出的变化,并重新运行测试。

[1] Chrome 51.0.2704 1st tests true is true FAILED
[1] Expected false to equal true.
[1] Chrome 51.0.2704: Executed 1 of 1 (1 FAILED) (0.005 secs / 0.005 secs)

正如所料,测试结果是失败

将期望从false恢复为true。两个进程都检测到这个变化,自动重新运行,Karma报告测试成功。

控制台的日志可能会非常长。注意最后一样。当一切正常时,它会显示SUCCESS

调试测试程序

在浏览器中,像调试应用一样调试测试程序spec。

  1. 显示Karma的浏览器窗口(之前被隐藏了)。

  2. 点击“DEBUG”按钮;它打开一页新浏览器标签并重新开始运行测试程序

  3. 打开浏览器的“Developer Tools”(Windows上的Ctrl-Shift-I或者OSX上的`Command-Option-I)。

  4. 选择“sources”页

  5. 打开1st.spec.ts测试文件(Control/Command-P, 然后输入文件名字)。

  6. 在测试程序中设置断点。

  7. 刷新浏览器...然后它就会停在断点上。

Karma debugging

试试这个在线例子

你还可以在plunker的在线例子 / 可下载的例子中试运行这个测试。 本章的所有测试都有相应的在线例子

回到顶部

测试一个组件

大多数开发人员首先要测试的就是Angular组件。 src/app/banner-inline.component.ts中的BannerComponent是这个应用中最简单的组件,也是一个好的起点。 它所表示的是屏幕顶部<h1>标签中的应用标题。

src/app/banner-inline.component.ts

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

@Component({
  selector: 'app-banner',
  template: '<h1>{{title}}</h1>'
})
export class BannerComponent {
  title = 'Test Tour of Heroes';
}

这个版本的BannerComponent有一个内联模板和一个插值表达式绑定。 这个组件可能太简单,以至于在真实的项目中都不值得测试,但它却是首次接触Angular测试工具集时的完美例子。

组件对应的src/app/banner-inline.component.spec.ts文件与该组件位于同一个目录中,原因详见FAQ中的 为什么要把测试规约文件放在被测试对象旁边?

在测试文件中,我们先用ES6的import语句来引入测试所需的符号。

src/app/banner-inline.component.spec.ts (imports)

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

import { BannerComponent } from './banner-inline.component';

测试前面的describebeforeEach如下:

src/app/banner-inline.component.spec.ts (beforeEach)

describe('BannerComponent (inline template)', () => {

  let comp:    BannerComponent;
  let fixture: ComponentFixture<BannerComponent>;
  let de:      DebugElement;
  let el:      HTMLElement;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ BannerComponent ], // declare the test component
    });

    fixture = TestBed.createComponent(BannerComponent);

    comp = fixture.componentInstance; // BannerComponent test instance

    // query for the title <h1> by CSS element selector
    de = fixture.debugElement.query(By.css('h1'));
    el = de.nativeElement;
  });
});

TestBed

TestBed(测试台)是Angular测试工具集中的首要概念。 它创建Angular测试模块(一个@NgModule类),我们可以通过调用它的configureTestingModule方法来为要测试的类生成模块环境。 其效果是,你可以把被测试的组件从原有的应用模块中剥离出来,把它附加到一个动态生成的Angular测试模块上,而该测试模块可以为这些测试进行特殊裁剪。

configureTestingModule方法接受一个类似@NgModule的元数据对象。这个元数据对象具有标准Angular模块的大多数属性。

这里的元数据对象只是声明了要测试的组件BannerComponent。 这个元数据中没有imports属性,这是因为:(a) 默认的测试模块配置中已经有了BannerComponent所需的一切,(b) BannerComponent不需要与任何其它组件交互。

beforeEach中调用configureTestingModule,以便TestBed可以在运行每个测试之前都把自己重置回它的基础状态。

基础状态中包含一个默认的测试模块配置,它包含每个测试都需要的那些声明(组件、指令和管道)以及服务提供商(有些是Mock版)。

之前提到的测试垫片初始化测试模块配置到一个模块,这个模块和@angular/platform-browser中的BrowserModule类似。

这个默认的配置只是测试的基础性工作。稍后我们会调用TestBed.configureTestingModule来传入更多元数据,这些元数据定义了额外的 importsdeclarationsproviders和试用于这些测试的概要(Schema)。 可选的override方法可以微调配置的各个方面。

createComponent

在配置好TestBed之后,我们可以告诉它创建一个待测组件的实例。 在这个例子中,TestBed.createComponent创建了一个BannerComponent的实例,并返回一个组件测试夹具

在调用了createComponent之后就不要再重新配置TestBed了。

createComponent方法封闭了当前的TestBed实例,以免将来再配置它。 我们不能再调用任何TestBed的方法修改配置:不能调用configureTestingModule或任何override...方法。如果这么做,TestBed就会抛出错误。

ComponentFixtureDebugElementquery(By.css)

createComponent方法返回ComponentFixture,用来控制和访问已创建的组件所在的测试环境。 这个fixture提供了对组件实例自身的访问,同时还提供了用来访问组件的DOM元素的DebugElement对象。

title属性被插值到DOM的<h1>标签中。 用CSS选择器从fixture的DebugElementquery``<h1>元素。

query方法接受predicate函数,并搜索fixture的整个DOM树,试图寻找第一个满足predicate函数的元素。

queryAll方法返回一列数组,包含所有DebugElement中满足predicate的元素。

predicate是返回布尔值的函数。 predicate查询接受DebugElement参数,如果元素符合选择条件便返回true

By类是Angular测试工具之一,它生成有用的predicate。 它的By.css静态方法产生标准CSS选择器 predicate,与JQuery选择器相同的方式过滤。

最后,这个配置把DebugElement中的nativeElementDOM元素赋值给el属性。 测试程序将判断el是否包含期待的标题文本。

测试程序

再每个测试程序之前,Jasmin都一次运行beforeEach函数:

src/app/banner-inline.component.spec.ts (tests)

it('should display original title', () => {
  fixture.detectChanges();
  expect(el.textContent).toContain(comp.title);
});

it('should display a different test title', () => {
  comp.title = 'Test Title';
  fixture.detectChanges();
  expect(el.textContent).toContain('Test Title');
});

这些测试程序向DebugElement获取原生HTML元素,来满足自己的期望。

detectChanges:在测试中的Angular变更检测

每个测试程序都通过调用fixture.detectChanges()来通知Angular执行变更检测。第一个测试程序立刻这么做,触发数据绑定和并将title属性发送到DOM元素中。

第二个测试程序在更改组件的title属性之后才调用fixture.detectChanges()。新值出现在DOM元素中。

在产品阶段,当Angular创建组件、用户输入或者异步动作(比如AJAX)完成时,自动触发变更检测。

TestBed.createComponent不会触发变更检测。该工具不会自动将组件的title属性值推送到数据绑定的元素,下面的测试程序展示了这个事实:

src/app/banner-inline.component.spec.ts (no detectChanges)

it('no title in the DOM until manually call `detectChanges`', () => {
  expect(el.textContent).toEqual('');
});

这种行为(或者缺乏的行为)是有意为之。在Angular初始化数据绑定或者调用生命周期钩子之前,它给测试者机会来查看或者改变组件的状态。

试试在线例子

花点时间来浏览一下该组件的规约,比如在线例子 / 可下载的例子,深入理解组件单元测试的这些基本原理。

自动变更检测

BannerComponent的测试频繁调用detectChanges。 有些测试人员更希望Angular的测试环境自动进行变更检测。 这可以通过为TestBed配置上ComponentFixtureAutoDetect提供商来做到。首先从测试工具库中导入它:

src/app/banner.component.detect-changes.spec.ts (import)

import { ComponentFixtureAutoDetect } from '@angular/core/testing';

然后把它添加到测试模块配置的providers数组中:

src/app/banner.component.detect-changes.spec.ts (AutoDetect)

TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
})

下列测试阐明了自动变更检测的工作原理。

src/app/banner.component.detect-changes.spec.ts (AutoDetect Tests)

it('should display original title', () => {
  // Hooray! No `fixture.detectChanges()` needed
  expect(el.textContent).toContain(comp.title);
});

it('should still see original title after comp.title change', () => {
  const oldTitle = comp.title;
  comp.title = 'Test Title';
  // Displayed title is old because Angular didn't hear the change :(
  expect(el.textContent).toContain(oldTitle);
});

it('should display updated title after detectChanges', () => {
  comp.title = 'Test Title';
  fixture.detectChanges(); // detect changes explicitly
  expect(el.textContent).toContain(comp.title);
});

第一个测试程序展示了自动检测的好处。

第二和第三个测试程序显示了一个重要的局限性。 Angular测试环境不会知道测试程序改变了组件的title属性。 自动检测只对异步行为比如承诺的解析、计时器和DOM时间作出反应。 但是直接修改组件属性值的这种同步更新是不会触发自动检测的。 测试程序必须手动调用fixture.detectChange(),来触发新一轮的变更检测周期。

与其怀疑测试工具会不会执行变更检测,本章中的例子总是显式调用detectChanges()。 即使是在不需要的时候,频繁调用detectChanges()没有任何什么坏处。

回到顶部

测试带有外部模板的组件

在实际应用中,BannerComponent的行为和刚才的版本相同,但是实现方式不同。 它有一个外部模板和CSS文件,通过templateUrlstyleUrls属性来指定。

src/app/banner.component.ts

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

@Component({
  selector: 'app-banner',
  templateUrl: './banner.component.html',
  styleUrls:  ['./banner.component.css']
})
export class BannerComponent {
  title = 'Test Tour of Heroes';
}

这些测试有一个问题。 TestBed.createComponent方法是同步的。 但是Angular模板编译器必须在创建组件实例之前先从文件系统中读取这些值,而这是异步的。 以前测试内联模板时使用的设置方式不适用于外部模板。

第一个异步的beforeEach

BannerComponent测试的设置方式必须给Angular模板编译器一些时间来读取文件。 以前放在beforeEach中的逻辑被拆分成了两个beforeEach调用。 第一个beforeEach处理异步编译工作。

src/app/banner.component.spec.ts (first beforeEach)

// async beforeEach
beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [ BannerComponent ], // declare the test component
  })
  .compileComponents();  // compile template and css
}));

注意async函数被用作调用beforeEach的参数。 async函数是Angular测试工具集的一部分,这里必须引入它。

import { async } from '@angular/core/testing';

它接收一个无参数的函数,并返回一个函数,这个函数会作为实参传给beforeEach

async参数的内容看起来非常像同步版beforeEach的函数体。 它并不能很明显的看出来这是异步函数。 比如它不返回承诺(Promise),并且也没有标准Jasmine异步测试时常用的done函数作为参数。 内部实现上,async会把beforeEach的函数体放进一个特殊的异步测试区(async test zone),它隐藏了异步执行的内部机制。

这就是为了调用异步的TestBed.compileComponents方法所要做的一切。

compileComponents

TestBed.configureTestingModule方法返回TestBed类,以便你可以链式调用TestBed的其它静态方法,比如compileComponents

TestBed.compileComponents方法会异步编译这个测试模块中配置的所有组件。 在这个例子中,BannerComponent是唯一要编译的组件。 当compileComponents完成时,外部组件和css文件会被“内联”,而TestBed.createComponent会用同步的方式创建一个BannerComponent的新实例。

WebPack用户不用调用compileComponents,因为它会在构建过程中自动内联模板和css,然后执行测试

在这个例子中,TestBed.compileComponents只会编译BannerComponent。 本章稍后的测试中会声明多个组件,并且少量规约中会导入包含多个组件的应用模块。所有这些组件都可能含有外部模板和css文件。 TestBed.compileComponents会同时异步编译所有这些声明的组件。

调用了compileComponents之后就不能再配置TestBed了。 务必确保compileComponents是调用TestBed.createComponent来实例化待测组件之前的最后一步。

compileComponents方法封闭了当前的TestBed实例,以免将来再配置它。 我们不能再调用任何TestBed的方法修改配置:不能调用configureTestingModule或任何override...方法。如果这么做,TestBed就会抛出错误。

第二个同步beforeEach

这个同步的beforeEach包含异步beforeEach之后的其余步骤。

src/app/banner.component.spec.ts (second beforeEach)

// synchronous beforeEach
beforeEach(() => {
  fixture = TestBed.createComponent(BannerComponent);

  comp = fixture.componentInstance; // BannerComponent test instance

  // query for the title <h1> by CSS element selector
  de = fixture.debugElement.query(By.css('h1'));
  el = de.nativeElement;
});

这些步骤和原来的beforeEach中相同。 包括创建BannerComponent实例和查询要审查的元素。

测试运行器(runner)会先等待第一个异步beforeEach函数执行完再调用第二个。

等待compileComponents

compileComponents方法返回一个承诺,来让我们可以在它完成之后立即执行额外的任务。 比如,我们可以把第二个beforeEach中的同步代码移到一个compileComponents().then(...)回调中,从而只需要写一个beforeEach

大多数开发人员会觉得这样不易读,因此,更多采用的还是写两个beforeEach调用的方式。

试试在线例子

稍微花点时间,在在线例子 / 可下载的例子中看看该组件的规约。

“快速起步” 种子工程为其AppComponent提供了简单的测试,在在线例子 / 可下载的例子中可以看到。 它也调用了compileComponents,不过它并不是必须这么做,因为AppComponent的模板是内联的。

这样做也没坏处,如果你将来可能会把模板重构到独立的文件中去,那就可以调用compileComponents。 不过本章中的这些测试只会在必要时才调用compileComponents

回到顶部

测试有依赖的组件

组件经常依赖其他服务。

WelcomeComponent为登陆的用户显示一条欢迎信息。它从注入的UserService的属性得知用户的身份:

src/app/welcome.component.ts

import { Component, OnInit } from '@angular/core';
import { UserService }       from './model';

@Component({
  selector: 'app-welcome',
  template: '<h3 class="welcome" ><i>{{welcome}}</i></h3>'
})
export class WelcomeComponent  implements OnInit {
  welcome = '-- not initialized yet --';
  constructor(private userService: UserService) { }

  ngOnInit(): void {
    this.welcome = this.userService.isLoggedIn ?
      'Welcome, ' + this.userService.user.name :
      'Please log in.';
  }
}

WelcomeComponent有与服务进行交互的决策逻辑,这样的逻辑让这个组件值得测试。下面是spec文件的测试模块配置,src/app/welcome.component.spec.ts

src/app/welcome.component.spec.ts

    TestBed.configureTestingModule({
       declarations: [ WelcomeComponent ],
    // providers:    [ UserService ]  // NO! Don't provide the real service!
                                      // Provide a test-double instead
       providers:    [ {provide: UserService, useValue: userServiceStub } ]
    });

这次,在测试配置里不但声明了被测试的组件,而且在providers数组中添加了UserService依赖。但不是真实的UserService

提供服务替身

被测试的组件不一定要注入真正的服务。实际上,服务的替身(stubs, fakes, spies或者mocks)通常会更加合适。 spec的主要目的是测试组件,而不是服务。真实的服务可能自身有问题。

注入真实的UserService有可能很麻烦。真实的服务可能询问用户登录凭据,也可能试图连接认证服务器。 可能很难处理这些行为。所以在真实的UserService的位置创建和注册UserService替身,会让测试更加容易和安全。

这个测试套件提供了最小化的UserServicestub类,用来满足WelcomeComponent和它的测试的需求:

userServiceStub = {
  isLoggedIn: true,
  user: { name: 'Test User'}
};

获取注入的服务

测试程序需要访问被注入到WelcomeComponent中的UserService(stub类)。

Angular的注入系统是层次化的。 可以有很多层注入器,从根TestBed创建的注入器下来贯穿整个组件树。

最安全并总是有效的获取注入服务的方法,是从被测试的组件的注入器获取。 组件注入器是fixture的DebugElement的属性。

WelcomeComponent's injector

// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

TestBed.get

可以通过TestBed.get方法来从根注入器中获取服务。 它更容易被记住,也更加简介。 但是只有在Angular使用测试的根注入器中的那个服务实例来注入到组件时,它才有效。 幸运的是,在这个测试套件中,唯一UserService提供商就是根测试模块,所以像下面这样调用TestBed.get很安全:

TestBed injector

// UserService from the root injector
userService = TestBed.get(UserService);

inject辅助函数方法是另外一种从测试的根注入器注入一个或多个服务到测试的方法。

如果遇到了injectTestBed.get无效,的情况,请到“重载组件提供商”一节。那里会解释为什么要改用组件的注入器来获取服务。

总是从注入器获取服务

请不要引用测试代码里提供给测试模块的userServiceStub对象。这样不行! 被注入组件的userService实例是完全不一样的对象,它提供的是userServiceStub的克隆。

it('stub object and injected UserService should not be the same', () => {
  expect(userServiceStub === userService).toBe(false);

  // Changing the stub object has no effect on the injected service
  userServiceStub.isLoggedIn = false;
  expect(userService.isLoggedIn).toBe(true);
});

最后的设置和测试程序

这里是使用TestBed.get的完整beforeEach

src/app/welcome.component.spec.ts

  beforeEach(() => {
    // stub UserService for test purposes
    userServiceStub = {
      isLoggedIn: true,
      user: { name: 'Test User'}
    };

    TestBed.configureTestingModule({
       declarations: [ WelcomeComponent ],
       providers:    [ {provide: UserService, useValue: userServiceStub } ]
    });

    fixture = TestBed.createComponent(WelcomeComponent);
    comp    = fixture.componentInstance;

    // UserService from the root injector
    userService = TestBed.get(UserService);

    //  get the "welcome" element by CSS selector (e.g., by class name)
    de = fixture.debugElement.query(By.css('.welcome'));
    el = de.nativeElement;
  });

下面是一些测试程序:

src/app/welcome.component.spec.ts

it('should welcome the user', () => {
  fixture.detectChanges();
  const content = el.textContent;
  expect(content).toContain('Welcome', '"Welcome ..."');
  expect(content).toContain('Test User', 'expected name');
});

it('should welcome "Bubba"', () => {
  userService.user.name = 'Bubba'; // welcome message hasn't been shown yet
  fixture.detectChanges();
  expect(el.textContent).toContain('Bubba');
});

it('should request login if not logged in', () => {
  userService.isLoggedIn = false; // welcome message hasn't been shown yet
  fixture.detectChanges();
  const content = el.textContent;
  expect(content).not.toContain('Welcome', 'not welcomed');
  expect(content).toMatch(/log in/i, '"log in"');
});

第一个测试程序是合法测试程序,它确认这个被模拟的UserService是否被调用和工作正常。

Jasmine的it方法的第二个参数(比如'expected name')是可选附加参数。 如果这个期待失败了,Jasmine在期待失败信息后面显示这个附加参数。 在拥有多个期待的spec中,它可以帮助澄清发生了什么错误,哪个期待失败了。

接下来的测试程序确认当服务返回不同的值时组件的逻辑是否工作正常。 第二个测试程序验证变换用户名字的效果。 第三个测试程序检查如果用户没有登录,组件是否显示正确消息。

回到顶部

测试有异步服务的组件

许多服务异步返回值。大部分数据服务向远程服务器发起HTTP请求,响应必然是异步的。

本例的About视图显示马克吐温的名言。 TwainComponent组件处理视图,并委派TwainService向服务器发起请求。

两者都在app/shared目录里,因为作者计划将来在其它页面也显示马克吐温的名言。 下面是TwainComponent

src/app/shared/twain.component.ts

@Component({
  selector: 'twain-quote',
  template: '<p class="twain"><i>{{quote}}</i></p>'
})
export class TwainComponent  implements OnInit {
  intervalId: number;
  quote = '...';
  constructor(private twainService: TwainService) { }

  ngOnInit(): void {
    this.twainService.getQuote().then(quote => this.quote = quote);
  }
}

TwainService的实现细节现在并不重要。 ngOnInittwainService.getQuote返回承诺,所以显然它是异步的。

一般来讲,测试程序不应该向远程服务器发请求。 它们应该仿真这样的请求。src/app/shared/twain.component.spec.ts里的配置是其中一种伪造方法:

src/app/shared/twain.component.spec.ts (setup)

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

    fixture = TestBed.createComponent(TwainComponent);
    comp    = fixture.componentInstance;

    // TwainService actually injected into the component
    twainService = fixture.debugElement.injector.get(TwainService);

    // Setup spy on the `getQuote` method
    spy = spyOn(twainService, 'getQuote')
          .and.returnValue(Promise.resolve(testQuote));

    // Get the Twain quote element by CSS selector (e.g., by class name)
    de = fixture.debugElement.query(By.css('.twain'));
    el = de.nativeElement;
  });

刺探(Spy)真实服务

本配置与welcome.component.spec配置类似。 但是与其伪造服务对象,它注入了真实的服务(参见测试模块的providers),并用Jasmine的spy替换关键的getQuote方法。

spy = spyOn(twainService, 'getQuote')
      .and.returnValue(Promise.resolve(testQuote));

这个Spy的设计是,所有调用getQuote的方法都会收到立刻解析的承诺,得到一条预设的名言。Spy拦截了实际getQuote方法,所有它不会联系服务。

伪造服务实例和刺探真实服务都是好方法。挑选一种对当前测试套件最简单的方法。你可以随时改变主意。

刺探真实的服务往往并不容易,特别是真实的服务依赖其它服务时。 我们可以同时打桩和刺探,就像后面的例子那样。

下面是接下来带有注解的测试程序:

src/app/shared/twain.component.spec.ts (tests)

  1. it('should not show quote before OnInit', () => {
  2. expect(el.textContent).toBe('', 'nothing displayed');
  3. expect(spy.calls.any()).toBe(false, 'getQuote not yet called');
  4. });
  5. it('should still not show quote after component initialized', () => {
  6. fixture.detectChanges();
  7. // getQuote service is async => still has not returned with quote
  8. expect(el.textContent).toBe('...', 'no quote yet');
  9. expect(spy.calls.any()).toBe(true, 'getQuote called');
  10. });
  11. it('should show quote after getQuote promise (async)', async(() => {
  12. fixture.detectChanges();
  13. fixture.whenStable().then(() => { // wait for async getQuote
  14. fixture.detectChanges(); // update view with quote
  15. expect(el.textContent).toBe(testQuote);
  16. });
  17. }));
  18. it('should show quote after getQuote promise (fakeAsync)', fakeAsync(() => {
  19. fixture.detectChanges();
  20. tick(); // wait for async getQuote
  21. fixture.detectChanges(); // update view with quote
  22. expect(el.textContent).toBe(testQuote);
  23. }));

同步测试程序

前两个测试程序是同步的。 在Spy的帮助下,它们验证了在Angular调用ngOnInit期间发生的第一次变更检测后,getQuote被调用了。

两者都不能证明被显示的值是服务提供的。 虽然spy返回了解析的承诺,名言本身还没有到来。

这个测试程序必须等待JavaScript引擎一整个回合,返回值才会有效。该测试程序必须要变成异步的

it里的async函数方法

注意第三个测试程序的async方法。

src/app/shared/twain.component.spec.ts (async test)

it('should show quote after getQuote promise (async)', async(() => {
  fixture.detectChanges();

  fixture.whenStable().then(() => { // wait for async getQuote
    fixture.detectChanges();        // update view with quote
    expect(el.textContent).toBe(testQuote);
  });
}));

async函数是Angular TestBed的一部分。通过将测试代码放到特殊的异步测试区域来运行,async函数简化了异步测试程序的代码。就像以前讨论过的,它会在beforeEach中被调用。

虽然async做了很多工作来尽量隐藏异步特性,但在测试程序(比如fixture.whenStable)里面调用函数时,有时还是会体现它们的异步行为。

fakeAsync可选方法,正如下面解释的,进一步移除了异步行为,提供了更加直观的代码经验。

whenStable

测试程序必须等待getQuote在JavaScript引擎的下一回合中被解析。

本测试对twainService.getQuote返回的承诺没有直接的访问,因为它被埋没在TwainComponent.ngOnInit里, 所以对于只测试组件API表面的测试来说,它是无法被访问的。

幸运的是,异步测试区域可以访问getQuote承诺,因为它拦截所有调用异步方法所发出的承诺,不管它们在哪儿。

ComponentFixture.whenStable方法返回它自己的承诺,它在getQuote承诺完成时被解析。实际上,“stable”的意思是当所有待处理异步行为完成时的状态,在“stable”后whenStable承诺被解析。

然后测试程序继续运行,并开始另一轮的变更检测(fixture.detectChanges),通知Angular使用名言来更新DOM。 getQuote辅助方法提取出显示元素文本,然后expect语句确认这个文本与预备的名言相符。

fakeAsync函数方法

第四个测试程序用不同的方法验证同样的组件行为。

src/app/shared/twain.component.spec.ts (fakeAsync test)

it('should show quote after getQuote promise (fakeAsync)', fakeAsync(() => {
  fixture.detectChanges();
  tick();                  // wait for async getQuote
  fixture.detectChanges(); // update view with quote
  expect(el.textContent).toBe(testQuote);
}));

注意,在it的参数中,asyncfaceAsync替换。 fakeAsync是另一种Angular测试工具。

async一样,它也接受无参数函数并返回一个函数,变成Jasmine的it函数的参数。

fakeAsync函数通过在特殊的fakeAsync测试区域运行测试程序,让测试代码更加简单直观。

对于async来说,fakeAsync最重要的好处是测试程序看起来像同步的。里面没有任何承诺。 没有then(...)链来打断控制流。

但是fakeAsync有局限性。比如,你不能从fakeAsync发起XHR请求。

tick函数

tick函数是Angular测试工具之一,是fakeAsync的同伴。 它只能在fakeAsync的主体中被调用。

调用tick()模拟时间的推移,直到全部待处理的异步任务都已完成,在这个测试案例中,包含getQuote承诺的解析。

它不返回任何结果。没有任何承诺需要等待。 直接执行与之前在whenStable.then()的回调函数里相同的代码。

虽然这个例子非常简单,但是它已经比第三个测试程序更易阅读。 为了更充分的体会fakeAsync的好处,试想一下一连串的异步操作,被一长串的承诺回调链在一起。

jasmine.done

虽然asyncfakeAsync函数大大的简化了异步测试,你仍然可以回退到传统的Jasmine异步测试技术上。

你仍然可以将接受 done回调的函数传给it。 但是,你必须链接承诺、处理错误,并在适当的时候调用done

下面是上面两个测试程序的done版本:

src/app/shared/twain.component.spec.ts (done test)

it('should show quote after getQuote promise (done)', (done: any) => {
  fixture.detectChanges();

  // get the spy promise and wait for it to resolve
  spy.calls.mostRecent().returnValue.then(() => {
    fixture.detectChanges(); // update view with quote
    expect(el.textContent).toBe(testQuote);
    done();
  });
});

虽然我们对TwainComponent里的getQuote承诺没有直接访问,但是Spy有,所以才可能等待getQuote完成。

写带有done回调的测试函数,虽然比asyncfakeAsync函数笨拙,但是在少数偶然情况下却是很有必要的技巧。比如,当测试涉及intervalTimer的代码时,你就没法调用asyncfakeAsync函数,在测试异步Observable函数时也一样。

回到顶部

测试带有导入inputs和导出outputs的组件

带有导入和导出的组件通常出现在宿主组件的视图模板中。 宿主使用属性绑定来设置输入属性,使用事件绑定来监听输出属性触发的事件。

测试的目的是验证这样的绑定和期待的那样正常工作。 测试程序应该设置导入值并监听导出事件。

DashboardHeroComponent是非常小的这种类型的例子组件。 它显示由DashboardCompoent提供的英雄个体。 点击英雄告诉DashbaordComponent用户已经选择了这个英雄。

DashboardHeroComponent是这样内嵌在DashboardCompoent的模板中的:

src/app/dashboard/dashboard.component.html (excerpt)

<dashboard-hero *ngFor="let hero of heroes"  class="col-1-4"
  [hero]=hero  (selected)="gotoDetail($event)" >
</dashboard-hero>

DashboardHeroComponent*ngFor循环中出现,设置每个组件的heroinput属性到迭代的值,并监听组件的selected事件。

下面是组件的定义:

src/app/dashboard/dashboard-hero.component.ts (component)

@Component({
  selector:    'dashboard-hero',
  templateUrl: './dashboard-hero.component.html',
  styleUrls: [ './dashboard-hero.component.css' ]
})
export class DashboardHeroComponent {
  @Input() hero: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}

虽然测试这么简单的组件没有什么内在价值,但是它的测试程序是值得学习的。 有下列候选测试方案:

简单看看DashbaordComponent的构造函数就否决了第一种方案:

src/app/dashboard/dashboard.component.ts (constructor)

constructor(
  private router: Router,
  private heroService: HeroService) {
}

DashbaordComponent依赖Angular路由器和HeroService服务。 你必须使用测试替身替换它们两个,似乎过于复杂了。 路由器尤其具有挑战性。

下面 覆盖了如何测试带有路由器的组件。

当前的任务是测试DashboardHeroComponent组件,而非DashbaordComponent,所以无需做不必要的努力。 让我们尝试第二和第三种方案。

独立测试DashboardHeroComponent

下面是spec文件的设置。

src/app/dashboard/dashboard-hero.component.spec.ts (setup)

  // async beforeEach
  beforeEach( async(() => {
    TestBed.configureTestingModule({
      declarations: [ DashboardHeroComponent ],
    })
    .compileComponents(); // compile template and css
  }));

  // synchronous beforeEach
  beforeEach(() => {
    fixture = TestBed.createComponent(DashboardHeroComponent);
    comp    = fixture.componentInstance;
    heroEl  = fixture.debugElement.query(By.css('.hero')); // find hero element

    // pretend that it was wired to something that supplied a hero
    expectedHero = new Hero(42, 'Test Name');
    comp.hero = expectedHero;
    fixture.detectChanges(); // trigger initial data binding
  });

异步beforeEach已经在上面讨论过。 在使用compileComponents异步编译完组件后,接下来的设置执行另一个同步beforeEach,使用之前解释过的基本知识。

注意代码是如何将模拟英雄(expectedHero)赋值给组件的hero属性的,模拟了DashbaordComponent在它的迭代器中通过属性绑定的赋值方式。

紧接着第一个测试程序:

src/app/dashboard/dashboard-hero.component.spec.ts (name test)

it('should display hero name', () => {
  const expectedPipedName = expectedHero.name.toUpperCase();
  expect(heroEl.nativeElement.textContent).toContain(expectedPipedName);
});

它验证了英雄名字通过绑定被传递到模板了。这里有个额外步骤。模板将英雄名字传给Angular的UpperCasePipe, 所以测试程序必须使用大写名字来匹配元素的值:

<div (click)="click()" class="hero">
  {{hero.name | uppercase}}
</div>

这个小测试演示了Angular测试是如何验证组件的视图表现的 —— 这是孤立的单元测试无法实现的 —— 它成本低,而且无需依靠更慢、更复杂的端对端测试。

第二个测试程序验证点击行为。点击英雄应该出发selected事件,可供宿主组件(DashbaordComponent)监听:

src/app/dashboard/dashboard-hero.component.spec.ts (click test)

  it('should raise selected event when clicked', () => {
    let selectedHero: Hero;
    comp.selected.subscribe((hero: Hero) => selectedHero = hero);

    heroEl.triggerEventHandler('click', null);
    expect(selectedHero).toBe(expectedHero);
  });

这个组件公开EventEmitter属性。测试程序像宿主组件那样来描述它。

heroEl是个DebugElement,它代表了英雄所在的<div>。 测试程序用“click”事件名字来调用triggerEventHandler。 调用DashboardHeroComponent.click()时,“click”事件绑定作出响应。

如果组件像期待的那样工作,click()通知组件的selected属性就会发出hero对象,测试程序通过订阅selected事件而检测到这个值,所以测试应该成功。

triggerEventHandler

Angular的DebugElement.triggerEventHandler可以用事件的名字触发任何数据绑定事件。 第二个参数是传递给事件处理器的事件对象。

本例中,测试程序用null事件对象触发“click”事件。

heroEl.triggerEventHandler('click', null);

测试程序假设(在这里应该这样)运行时间的事件处理器——组件的click()方法——不关心事件对象。

其它处理器将会更加严格。 比如,RouterLink指令期待事件对象,并且该对象具有button属性,代表了已被按下的鼠标按钮。 如果该事件对象不具备上面的条件,指令便会抛出错误。

点击按钮、链接或者任意HTML元素是很常见的测试任务。

click触发过程封装到辅助方法中可以简化这个任务,比如下面的click辅助方法:

testing/index.ts (click helper)

/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
   left:  { button: 0 },
   right: { button: 2 }
};

/** Simulate element click. Defaults to mouse left-button click event. */
export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
  if (el instanceof HTMLElement) {
    el.click();
  } else {
    el.triggerEventHandler('click', eventObj);
  }
}

第一个参数是用来点击的元素。如果你愿意,可以将自定义的事件对象传递给第二个参数。 默认的是(局部的)鼠标左键事件对象, 它被许多事件处理器接受,包括RouterLink指令。

click()不是Angular测试工具

click()辅助函数不是Angular测试工具之一。 它是在本章的例子代码中定义的函数方法,被所有测试例子所用。 如果你喜欢它,将它添加到你自己的辅助函数集。

下面是使用了click辅助函数重新编写的上一个测试程序:

src/app/dashboard/dashboard-hero.component.spec.ts (click test revised)

it('should raise selected event when clicked', () => {
  let selectedHero: Hero;
  comp.selected.subscribe((hero: Hero) => selectedHero = hero);

  click(heroEl);   // triggerEventHandler helper
  expect(selectedHero).toBe(expectedHero);
});

在测试宿主组件中测试组件

在测试宿主组件中测试组件

在前面的方法中,测试本身扮演了宿主组件DashbaordComponent的角色。 一种挥之不去的疑虑仍然存在:当正常数据绑定到宿主组件时,DashboardHeroComponent还会正常工作吗?

使用实际的DashbaordComponent宿主来测试是可行的,但是这么做似乎不合算。 像下面这样使用测试宿主组件来模拟DashbaordComponent显得更加容易:

src/app/dashboard/dashboard-hero.component.spec.ts (test host)

@Component({
  template: `
    <dashboard-hero  [hero]="hero"  (selected)="onSelected($event)"></dashboard-hero>`
})
class TestHostComponent {
  hero = new Hero(42, 'Test Name');
  selectedHero: Hero;
  onSelected(hero: Hero) { this.selectedHero = hero; }
}

测试宿主组件和DashboardComponent一样绑定DashboardHeroComponent,但是不用理会RouterHeroService服务,甚至*ngFor循环。

测试宿主将组件的hero导入属性设置为它的模拟英雄。 它将组件的selected事件绑定到它的onSelected处理器,使用selectedHero属性来记录发送来的英雄。 然后测试检查这个属性来验证DashboardHeroComponent.selected事件确实发送了正确的英雄。

配置使用测试宿主的测试程序与配置孤立测试相似:

src/app/dashboard/dashboard-hero.component.spec.ts (test host setup)

beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [ DashboardHeroComponent, TestHostComponent ], // declare both
  }).compileComponents();
}));

beforeEach(() => {
  // create TestHostComponent instead of DashboardHeroComponent
  fixture  = TestBed.createComponent(TestHostComponent);
  testHost = fixture.componentInstance;
  heroEl   = fixture.debugElement.query(By.css('.hero')); // find hero
  fixture.detectChanges(); // trigger initial data binding
});

这个测试模块配置展示了两个非常重要的区别:

  1. 它同时声明DashboardHeroComponentTestHostComponent

  2. 创建TestHostComponent,而非DashboardHeroComponent

createComponent返回的fixture里有TestHostComponent实例,而非DashboardHeroComponent组件实例。

当然,创建TestHostComponent有创建DashboardHeroComponent的副作用,因为后者出现在前者的模板中。 英雄元素(heroEl)的查询语句仍然可以在测试DOM中找到它,尽管元素树比以前更深。

这些测试本身和它们的孤立版本几乎相同:

src/app/dashboard/dashboard-hero.component.spec.ts (test-host)

it('should display hero name', () => {
  const expectedPipedName = testHost.hero.name.toUpperCase();
  expect(heroEl.nativeElement.textContent).toContain(expectedPipedName);
});

it('should raise selected event when clicked', () => {
  click(heroEl);
  // selected hero should be the same data bound hero
  expect(testHost.selectedHero).toBe(testHost.hero);
});

只有selected事件的测试不一样。它确保被选择的DashboardHeroComponent英雄确实通过事件绑定被传递到宿主组件。

回到顶部

测试带路由器的组件

测试实际的DashbaordComponent似乎令人生畏,因为它注入了Router

src/app/dashboard/dashboard.component.ts (constructor)

constructor(
  private router: Router,
  private heroService: HeroService) {
}

它同时还注入了HeroService,但是我们已经知道如何伪造它。 Router的API非常复杂,并且它缠绕了其它服务和许多应用的先决条件。

幸运的是,DashbaordComponent没有使用Router做很多事情。

src/app/dashboard/dashboard.component.ts (goToDetail)

gotoDetail(hero: Hero) {
  let url = `/heroes/${hero.id}`;
  this.router.navigateByUrl(url);
}

通常都是这样的。原则上,你测试的是组件,不是路由器,应该只关心在指定的条件下,组件是否导航到正确的地址。 用模拟类来替换路由器是一种简单的方案。下面的代码应该可以:

src/app/dashboard/dashboard.component.spec.ts (Router Stub)

class RouterStub {
  navigateByUrl(url: string) { return url; }
}

现在我们来利用RouterHeroService的测试stub类来配置测试模块,并为接下来的测试创建DashboardComponent的测试实例。

src/app/dashboard/dashboard.component.spec.ts (compile and create)

beforeEach( async(() => {
  TestBed.configureTestingModule({
    providers: [
      { provide: HeroService, useClass: FakeHeroService },
      { provide: Router,      useClass: RouterStub }
    ]
  })
  .compileComponents().then(() => {
    fixture = TestBed.createComponent(DashboardComponent);
    comp = fixture.componentInstance;
  });

下面的测试程序点击显示的英雄,并利用spy来确认Router.navigateByUrl被调用了,而且传进的url是所期待的值。

src/app/dashboard/dashboard.component.spec.ts (navigate test)

    it('should tell ROUTER to navigate when hero clicked',
      inject([Router], (router: Router) => { // ...

      const spy = spyOn(router, 'navigateByUrl');

      heroClick(); // trigger click on first inner <div class="hero">

      // args passed to router.navigateByUrl()
      const navArgs = spy.calls.first().args[0];

      // expecting to navigate to id of the component's first hero
      const id = comp.heroes[0].id;
      expect(navArgs).toBe('/heroes/' + id,
        'should nav to HeroDetail for first hero');
    }));

inject函数

注意第二个it参数里面的inject函数。

it('should tell ROUTER to navigate when hero clicked',
  inject([Router], (router: Router) => { // ...
}));

inject函数是Angular测试工具之一。 它注入服务到测试函数,以供修改、监视和操纵。

inject函数有两个参数:

  1. 一列数组,包含了Angular依赖注入令牌

  2. 一个测试函数,它的参数与注入令牌数组里的每个项目严格的一一对应。

使用TestBed注入器来注入

inject函数使用当前TestBed注入器,并且只返回这个级别提供的服务。 它不会返回组件提供商提供的服务。

这个例子通过当前的TestBed注入器来注入Router。 对这个测试程序来说,这是没问题的,因为Router是(也必须是)由应用的根注入器来提供。

如果你需要组件自己的注入器提供的服务,调用fixture.debugElement.injector.get

Component's injector

// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

使用组件自己的注入器来获取实际注入到组件的服务。

inject函数关闭当前TestBed实例,使它无法再被配置。 你不能再调用任何TestBed配置方法、configureTestModule或者任何override...方法,否则TestBed将抛出错误。

不要在调用inject以后再试图配置TestBed

回到顶部

测试带有路由和路由参数的组件

点击Dashboard英雄触发导航到heros/:id,其中:id是路由参数,它的值是进行编辑的英雄的id。 这个URL匹配到HeroDetailComponent的路由。

路由器将:id令牌的值推送到ActivatedRoute.params可观察属性里, Angular注入ActivatedRouteHeroDetailComponent中, 然后组件提取id,这样它就可以通过HeroDetailService获取相应的英雄。 下面是HeroDetailComponent的构造函数:

src/app/hero/hero-detail.component.ts (constructor)

constructor(
  private heroDetailService: HeroDetailService,
  private route:  ActivatedRoute,
  private router: Router) {
}

HeroDetailComponent在它的ngOnInit方法中监听ActivatedRoute.params的变化。

src/app/hero/hero-detail.component.ts (ngOnInit)

ngOnInit(): void {
  // get hero when `id` param changes
  this.route.params.subscribe(p => this.getHero(p && p['id']));
}

route.params之后的表达式链接了可观察操作符,它从params中提取id,然后链接forEach操作符来订阅id变化事件。 每次id变化时,用户被导航到不同的英雄。

forEach将新的id值传递到组件的getHero方法(这里没有列出来),它获取英雄并将它赋值到组件的hero属性。 如果id参数无效,pluck操作符就会失败,catch将失败当作创建新英雄来处理。

路由器章更详尽的讲述了ActivatedRoute.params

通过操纵被注入到组件构造函数的ActivatedRoute服务,测试程序可以探索HeroDetailComponent是如何对不同的id参数值作出响应的。

现在,你已经知道如何模拟Router和数据服务。 模拟ActivatedRoute遵循类似的模式,但是有个额外枝节:ActivatedRoute.params可观察对象

可观察对象的测试替身

hero-detail.component.spec.ts依赖ActivatedRouteStub来为每个测试程序设置ActivatedRoute.params值。 它是跨应用、可复用的测试辅助类。 我们建议将这样的辅助类放到app目录下的名为testing的目录。 本例把ActivatedRouteStub放到testing/router-stubs.ts

testing/router-stubs.ts (ActivatedRouteStub)

import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Injectable()
export class ActivatedRouteStub {

  // ActivatedRoute.params is Observable
  private subject = new BehaviorSubject(this.testParams);
  params = this.subject.asObservable();

  // Test parameters
  private _testParams: {};
  get testParams() { return this._testParams; }
  set testParams(params: {}) {
    this._testParams = params;
    this.subject.next(params);
  }

  // ActivatedRoute.snapshot.params
  get snapshot() {
    return { params: this.testParams };
  }
}

这个stub类有下列值得注意的特征:

snapshot是组件使用路由参数的另一种流行的方法。

本章的路由器stub类是为了给你灵感。创建你自己的stub类,以适合你的测试需求。

测试可观察对象的替身

下面的测试程序是演示组件在被观察的id指向现有英雄时的行为:

src/app/hero/hero-detail.component.spec.ts (existing id)

  describe('when navigate to existing hero', () => {
    let expectedHero: Hero;

    beforeEach( async(() => {
      expectedHero = firstHero;
      activatedRoute.testParams = { id: expectedHero.id };
      createComponent();
    }));

    it('should display that hero\'s name', () => {
      expect(page.nameDisplay.textContent).toBe(expectedHero.name);
    });
  });

下一节将解释createComponent方法和page对象,现在暂时跟着自己的直觉走。

当无法找到id时,组件应该重新导航到HeroListComponent。 该测试套件配置与上面描述RouterStub一样,它在不实际导航的情况下刺探路由器。 该测试程序提供了“坏”的id,期望组件尝试导航。

src/app/hero/hero-detail.component.spec.ts (bad id)

describe('when navigate to non-existant hero id', () => {
  beforeEach( async(() => {
    activatedRoute.testParams = { id: 99999 };
    createComponent();
  }));

  it('should try to navigate back to hero list', () => {
    expect(page.gotoSpy.calls.any()).toBe(true, 'comp.gotoList called');
    expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
  });
});

虽然本应用没有在缺少id参数的时候,继续导航到HeroDetailComponent的路由,但是,将来它可能会添加这样的路由。 当没有id时,该组件应该作出合理的反应。

在本例中,组件应该创建和显示新英雄。 新英雄的id为零,name为空。本测试程序确认组件是按照预期的这样做的:

src/app/hero/hero-detail.component.spec.ts (no id)

describe('when navigate with no hero id', () => {
  beforeEach( async( createComponent ));

  it('should have hero.id === 0', () => {
    expect(comp.hero.id).toBe(0);
  });

  it('should display empty hero name', () => {
    expect(page.nameDisplay.textContent).toBe('');
  });
});

在线例子 / 可下载的例子查看和下载所有本章应用程序测试代码。

回到顶部

使用page对象来简化配置

HeroDetailComponent是带有标题、两个英雄字段和两个按钮的简单视图。

HeroDetailComponent in action

但是它已经有很多模板复杂性。

src/app/hero/hero-detail.component.html

<div *ngIf="hero">
  <h2><span>{{hero.name | titlecase}}</span> Details</h2>
  <div>
    <label>id: </label>{{hero.id}}</div>
  <div>
    <label for="name">name: </label>
    <input id="name" [(ngModel)]="hero.name" placeholder="name" />
  </div>
  <button (click)="save()">Save</button>
  <button (click)="cancel()">Cancel</button>
</div>

要彻底测试该组件,测试程序需要一系列设置:

即使是像这样一个很小的表单,也能产生令人疯狂的错综复杂的条件设置和CSS元素选择。

通过简化组件属性的访问和封装设置属性的逻辑,Page类可以轻松解决这个令人抓狂的难题。 下面是为hero-detail.component.spec.ts准备的page类:

src/app/hero/hero-detail.component.spec.ts (Page)

class Page {
  gotoSpy:      jasmine.Spy;
  navSpy:       jasmine.Spy;

  saveBtn:      DebugElement;
  cancelBtn:    DebugElement;
  nameDisplay:  HTMLElement;
  nameInput:    HTMLInputElement;

  constructor() {
    const router = TestBed.get(Router); // get router from root injector
    this.gotoSpy = spyOn(comp, 'gotoList').and.callThrough();
    this.navSpy  = spyOn(router, 'navigate');
  }

  /** Add page elements after hero arrives */
  addPageElements() {
    if (comp.hero) {
      // have a hero so these elements are now in the DOM
      const buttons    = fixture.debugElement.queryAll(By.css('button'));
      this.saveBtn     = buttons[0];
      this.cancelBtn   = buttons[1];
      this.nameDisplay = fixture.debugElement.query(By.css('span')).nativeElement;
      this.nameInput   = fixture.debugElement.query(By.css('input')).nativeElement;
    }
  }
}

现在,用来操作和检查组件的重要钩子都被井然有序的组织起来了,可以通过page实例来使用它们。

createComponent方法创建page,在hero到来时,自动填补空白。

src/app/hero/hero-detail.component.spec.ts (createComponent)

/** Create the HeroDetailComponent, initialize it, set test variables  */
function createComponent() {
  fixture = TestBed.createComponent(HeroDetailComponent);
  comp    = fixture.componentInstance;
  page    = new Page();

  // 1st change detection triggers ngOnInit which gets a hero
  fixture.detectChanges();
  return fixture.whenStable().then(() => {
    // 2nd change detection displays the async-fetched hero
    fixture.detectChanges();
    page.addPageElements();
  });
}

上一节的可观察对象测试展示了createComponentpage如何让测试程序简短和即时。 没有任何干扰:无需等待承诺的解析,也没有搜索DOM元素值进行比较。

这里是一些更多的HeroDetailComponent测试程序,进一步的展示了这一点。

src/app/hero/hero-detail.component.spec.ts (selected tests)

    it('should display that hero\'s name', () => {
      expect(page.nameDisplay.textContent).toBe(expectedHero.name);
    });

    it('should navigate when click cancel', () => {
      click(page.cancelBtn);
      expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
    });

    it('should save when click save but not navigate immediately', () => {
      // Get service injected into component and spy on its`saveHero` method.
      // It delegates to fake `HeroService.updateHero` which delivers a safe test result.
      const hds = fixture.debugElement.injector.get(HeroDetailService);
      const saveSpy = spyOn(hds, 'saveHero').and.callThrough();

      click(page.saveBtn);
      expect(saveSpy.calls.any()).toBe(true, 'HeroDetailService.save called');
      expect(page.navSpy.calls.any()).toBe(false, 'router.navigate not called');
    });

    it('should navigate when click save and save resolves', fakeAsync(() => {
      click(page.saveBtn);
      tick(); // wait for async save to complete
      expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
    }));

    it('should convert hero name to Title Case', () => {
      const inputName = 'quick BROWN  fox';
      const titleCaseName = 'Quick Brown  Fox';

      // simulate user entering new name into the input box
      page.nameInput.value = inputName;

      // dispatch a DOM event so that Angular learns of input value change.
      page.nameInput.dispatchEvent(newEvent('input'));

      // Tell Angular to update the output span through the title pipe
      fixture.detectChanges();

      expect(page.nameDisplay.textContent).toBe(titleCaseName);
    });
回到顶部

模块导入imports的配置

此前的组件测试程序使用了一些declarations来配置模块,就像这样:

src/app/dashboard/dashboard-hero.component.spec.ts (config)

// async beforeEach
beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [ DashboardHeroComponent ],
  })
  .compileComponents(); // compile template and css
}));

DashbaordComponent非常简单。它不需要帮助。 但是更加复杂的组件通常依赖其它组件、指令、管道和提供商, 所以这些必须也被添加到测试模块中。

幸运的是,TestBed.configureTestingModule参数与传入@NgModule装饰器的元数据一样,也就是所你也可以指定providersimports.

虽然HeroDetailComponent很小,结构也很简单,但是它需要很多帮助。 除了从默认测试模块CommonModule中获得的支持,它还需要:

一种方法是在测试模块中一一配置,就像这样:

src/app/hero/hero-detail.component.spec.ts (FormsModule setup)

beforeEach( async(() => {
   TestBed.configureTestingModule({
    imports:      [ FormsModule ],
    declarations: [ HeroDetailComponent, TitleCasePipe ],
    providers: [
      { provide: ActivatedRoute, useValue: activatedRoute },
      { provide: HeroService,    useClass: FakeHeroService },
      { provide: Router,         useClass: RouterStub},
    ]
  })
  .compileComponents();
}));

因为许多应用组件需要FormsModuleTitleCasePipe,所以开发者创建了SharedModule来合并它们和一些频繁需要的部件。 测试配置也可以使用SharedModule,请看下面另一种配置:

src/app/hero/hero-detail.component.spec.ts (SharedModule setup)

beforeEach( async(() => {
  TestBed.configureTestingModule({
    imports:      [ SharedModule ],
    declarations: [ HeroDetailComponent ],
    providers: [
      { provide: ActivatedRoute, useValue: activatedRoute },
      { provide: HeroService,    useClass: FakeHeroService },
      { provide: Router,         useClass: RouterStub},
    ]
  })
  .compileComponents();
}));

它的导入声明少一些(未显示),稍微干净一些,小一些。

导入特性模块

HeroDetailComponentHeroModule特性模块的一部分,它组合了更多互相依赖的部件,包括SharedModule。 试试下面这个导入HeroModule的测试配置:

src/app/hero/hero-detail.component.spec.ts (HeroModule setup)

beforeEach( async(() => {
   TestBed.configureTestingModule({
    imports:   [ HeroModule ],
    providers: [
      { provide: ActivatedRoute, useValue: activatedRoute },
      { provide: HeroService,    useClass: FakeHeroService },
      { provide: Router,         useClass: RouterStub},
    ]
  })
  .compileComponents();
}));

这样特别清爽。只有providers里面的测试替身被保留。连HeroDetailComponent声明都消失了。

事实上,如果里试图声明它,Angular会抛出错误,因为HeroDetailComponent已经在HeroModule和测试模块的DynamicTestModule中声明。

导入组件的特性模块通常是最简单的配置测试的方法, 尤其是当特性模块很小而且几乎自包含时...特性模块应该是自包含的。

回到顶部

重载组件的提供商

HeroDetailComponent提供自己的HeroDetailService服务。

src/app/hero/hero-detail.component.ts (prototype)

@Component({
  selector:    'app-hero-detail',
  templateUrl: './hero-detail.component.html',
  styleUrls:  ['./hero-detail.component.css' ],
  providers:  [ HeroDetailService ]
})
export class HeroDetailComponent implements OnInit {
  constructor(
    private heroDetailService: HeroDetailService,
    private route:  ActivatedRoute,
    private router: Router) {
  }
}

TestBed.configureTestingModuleproviders中stub伪造组件的HeroDetailService是不可行的。 这些是测试模块的提供商,而非组件的。组件级别的供应商应该在fixture级别准备的依赖注入器。

Angular创建组件时,该组件有自己的注入器,它是fixture注入器的子级。 Angular使用这个子级注入器来注册组件的提供商(也就是HeroDetailService)。 测试程序无法从fixture的注入器获取这个子级注入器。 而且TestBed.configureTestingModule也无法配置它们。

Angular始终都在创建真实HeroDetailService的实例。

如果HeroDetailService向远程服务器发出自己的XHR请求,这些测试可能会失败或者超时。 这个远程服务器可能根本不存在。

幸运的是,HeroDetailService将远程数据访问的责任交给了注入进来的HeroService

src/app/hero/hero-detail.service.ts (prototype)

@Injectable()
export class HeroDetailService {
  constructor(private heroService: HeroService) {  }
  /* . . . */
}

之前的测试配置将真实的HeroService替换为FakeHeroService,拦截了服务起请求,伪造了它们的响应。

如果我们没有这么幸运怎么办?如果伪造HeroService很难怎么办?如果HeroDetailService自己发出服务器请求怎么办?

TestBed.overrideComponent方法可以将组件的providers替换为容易管理的测试替身,参见下面的设置变化:

src/app/hero/hero-detail.component.spec.ts (Override setup)

  beforeEach( async(() => {
    TestBed.configureTestingModule({
      imports:   [ HeroModule ],
      providers: [
        { provide: ActivatedRoute, useValue: activatedRoute },
        { provide: Router,         useClass: RouterStub},
      ]
    })

    // Override component's own provider
    .overrideComponent(HeroDetailComponent, {
      set: {
        providers: [
          { provide: HeroDetailService, useClass: HeroDetailServiceSpy }
        ]
      }
    })

    .compileComponents();
  }));

注意,TestBed.configureTestingModule不再提供(伪造)HeroService,因为已经没有必要了

overrideComponent方法

注意这个overrideComponent方法。

src/app/hero/hero-detail.component.spec.ts (overrideComponent)

.overrideComponent(HeroDetailComponent, {
  set: {
    providers: [
      { provide: HeroDetailService, useClass: HeroDetailServiceSpy }
    ]
  }
})

它接受两个参数:要重载的组件类型(HeroDetailComponent)和用于重载的元数据对象。 重载元数据对象是泛型类,就像这样:

type MetadataOverride = {
  add?: T;
  remove?: T;
  set?: T;
};

元数据重载对象可以添加和删除元数据属性的项目,也可以彻底重设这些属性。 这个例子重新设置了组件的providers元数据。

这个类型参数,T,是你会传递给@Component装饰器的元数据的类型。

selector?: string;
template?: string;
templateUrl?: string;
providers?: any[];
...

提供一个刺探桩(Spy stub)HeroDetailServiceSpy

这个例子把组件的providers数组完全替换成了一个包含HeroDetailServiceSpy的新数组。

HeroDetailServiceSpy是实际HeroDetailService服务的桩版本,它伪造了该服务的所有必要特性。 但它既不需要注入也不会委托给低层的HeroService服务,因此我们不用为HeroService提供测试替身。

通过对该服务的方法进行刺探,HeroDetailComponent的关联测试将会对HeroDetailService是否被调用过进行断言。 因此,这个桩类会把它的方法实现为刺探方法:

src/app/hero/hero-detail.component.spec.ts (HeroDetailServiceSpy)

class HeroDetailServiceSpy {
  testHero = new Hero(42, 'Test Hero');

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

  saveHero = jasmine.createSpy('saveHero').and.callFake(
    (hero: Hero) => Promise
      .resolve(true)
      .then(() => Object.assign(this.testHero, hero))
  );
}

重载的测试程序

现在,测试程序可以通过操控stub的testHero,直接控制组件的英雄,并确保服务的方法被调用过。

src/app/hero/hero-detail.component.spec.ts (override tests)

let hdsSpy: HeroDetailServiceSpy;

beforeEach( async(() => {
  createComponent();
  // get the component's injected HeroDetailServiceSpy
  hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any;
}));

it('should have called `getHero`', () => {
  expect(hdsSpy.getHero.calls.count()).toBe(1, 'getHero called once');
});

it('should display stub hero\'s name', () => {
  expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});

it('should save stub hero change', fakeAsync(() => {
  const origName = hdsSpy.testHero.name;
  const newName = 'New Name';

  page.nameInput.value = newName;
  page.nameInput.dispatchEvent(newEvent('input')); // tell Angular

  expect(comp.hero.name).toBe(newName, 'component hero has new name');
  expect(hdsSpy.testHero.name).toBe(origName, 'service hero unchanged before save');

  click(page.saveBtn);
  expect(hdsSpy.saveHero.calls.count()).toBe(1, 'saveHero called once');

  tick(); // wait for async save to complete
  expect(hdsSpy.testHero.name).toBe(newName, 'service hero has new name after save');
  expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
}));

更多重载

TestBed.overrideComponent方法可以在相同或不同的组件中被反复调用。 TestBed还提供了类似的overrideDirectiveoverrideModuleoverridePipe方法,用来深入并重载这些其它类的部件。

自己探索这些选项和组合。

回到顶部

测试带有RouterOutlet的组件

AppComponent<router-outlet>中显示导航组件。 它还显示了导航条,包含了链接和它们的RouterLink指令。

src/app/app.component.html

<app-banner></app-banner>
<app-welcome></app-welcome>

<nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>

<router-outlet></router-outlet>

组件的类没有做任何事。

src/app/app.component.ts

import { Component } from '@angular/core';
@Component({
  selector: 'my-app',
  templateUrl: './app.component.html'
})
export class AppComponent { }

在不涉及路由的情况下,单元测试可以确认链接的设置是否正确。 参见下面的内容,了解为什么值得这么做。

stub伪造不需要的组件

该测试配置应该看起来很眼熟:

src/app/app.component.spec.ts (Stub Setup)

beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [
      AppComponent,
      BannerComponent, WelcomeStubComponent,
      RouterLinkStubDirective, RouterOutletStubComponent
    ]
  })

  .compileComponents()
  .then(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp    = fixture.componentInstance;
  });
}));

AppComponent是被声明的测试对象。

使用一个真实的组件(BannerComponent)和几个stub,该配置扩展了默认测试模块。

组件stub替代品很关键。 没有它们,Angular编译器无法识别<app-welcome<router-outlet>标签,抛出错误。

RouterLinkStubDirective为测试作出了重要的贡献:

testing/router-stubs.ts (RouterLinkStubDirective)

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

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

host元数据属性将宿主元素(<a>)的click事件与指令的onClick方法关联起来。 绑定到[routerLink]的URL属性被传递到指令的linkParams属性。 点击这个链接应该能触发onClick方法,从而设置navigatedTo属性。 测试程序可以查看这个属性,来确认期望的点击导航行为。

By.directive和注入的指令

再一步配置触发了数据绑定的初始化,获取导航链接的引用:

src/app/app.component.spec.ts (test setup)

beforeEach(() => {
  // trigger initial data binding
  fixture.detectChanges();

  // find DebugElements with an attached RouterLinkStubDirective
  linkDes = fixture.debugElement
    .queryAll(By.directive(RouterLinkStubDirective));

  // get the attached link directive instances using the DebugElement injectors
  links = linkDes
    .map(de => de.injector.get(RouterLinkStubDirective) as RouterLinkStubDirective);
});

特别值得注意的两点:

  1. 你还可以按指令定位元素,使用By.directive,而不仅仅是通过CSS选择器。

  2. 你可以使用组件的依赖注入器来获取附加的指令,因为Angular总是将附加组件添加到组件的注入器中。

下面是一些使用这个配置的测试程序:

src/app/app.component.spec.ts (selected tests)

it('can get RouterLinks from template', () => {
  expect(links.length).toBe(3, 'should have 3 links');
  expect(links[0].linkParams).toBe('/dashboard', '1st link should go to Dashboard');
  expect(links[1].linkParams).toBe('/heroes', '1st link should go to Heroes');
});

it('can click Heroes link in template', () => {
  const heroesLinkDe = linkDes[1];
  const heroesLink = links[1];

  expect(heroesLink.navigatedTo).toBeNull('link should not have navigated yet');

  heroesLinkDe.triggerEventHandler('click', null);
  fixture.detectChanges();

  expect(heroesLink.navigatedTo).toBe('/heroes');
});

本例中的“click”测试程序其实毫无价值。 它显得很有用,但是事实上,它测试的是RouterLinkStubDirective,而非测试组件。 这是指令stub的通病。

在本章中,它有存在的必要。 它演示了如何在不涉及完整路由器机制的情况下,如何找到RouterLink元素、点击它并检查结果。 要测试更复杂的组件,你可能需要具备这样的能力,能改变视图和重新计算参数,或者当用户点击链接时,有能力重新安排导航选项。

这些测试有什么好处?

stub伪造的RouterLink测试可以确认带有链接和outlet的组件的设置的正确性,确认组件有应该有的链接,确认它们都指向了正确的方向。 这些测试程序不关心用户点击链接时,应用是否会成功的导航到目标组件。

对于这样局限的测试目标,stub伪造RouterLink和RouterOutlet是最佳选择。 依靠真正的路由器会让它们很脆弱。 它们可能因为与组件无关的原因而失败。 例如,一个导航守卫可能防止没有授权的用户访问HeroListComponent。 这并不是AppComponent的过错,并且无论该组件怎么改变都无法修复这个失败的测试程序。

不同的测试程序可以探索在不同条件下(比如像检查用户是否认证),该应用是否和期望的那样导航。

未来本章的更新将介绍如何使用RouterTestingModule来编写这样的测试程序。

回到顶部

使用NO_ERRORS_SCHEMA来“浅化”组件测试程序

以前的配置声明了BannerComponent,并stub伪造了两个其它组件,仅仅是为了避免编译错误,不是为别的原因

没有它们,Angular编译器无法识别app.component.html模板里的<app-banner><app-welcome><router-outlet>标签,并抛出错误。

添加NO_ERRORS_SCHEMA到测试模块的schemas元数据中,告诉编译器忽略不认识的元素和属性。 这样你不再需要声明无关组件和指令。

这些测试程序比较,因为它们只“深入”到你要测试的组件。 这里是一套配置(拥有import语句),体现了相比使用stub伪造的配置来说,测试程序的简单性。

import { NO_ERRORS_SCHEMA }          from '@angular/core';
import { AppComponent }              from './app.component';
import { RouterOutletStubComponent } from '../testing';

beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [ AppComponent, RouterLinkStubDirective ],
    schemas:      [ NO_ERRORS_SCHEMA ]
  })

  .compileComponents()
  .then(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp    = fixture.componentInstance;
  });
}));

这里唯一声明的是被测试的组件(AppComponent)和测试需要的RouterLinkStubDirective。 没有改变任何原测试程序

使用NO_ERRORS_SCHEMA浅组件测试程序很大程度上简化了拥有复杂模板组件的单元测试。 但是,编译器将不再提醒你一些错误,比如模板中拼写错误或者误用的组件和指令。

回到顶部

测试属性指令

属性指令修改元素、组件和其它指令的行为。正如它们的名字所示,它们是作为宿主元素的属性来被使用的。

本例子应用的HighlightDirective使用数据绑定的颜色或者默认颜色来设置元素的背景色。 它同时设置元素的customProperty属性为true,这里仅仅是为了显示它能这么做而已,并无其它原因。

src/app/shared/highlight.directive.ts

import { Directive, ElementRef, Input, OnChanges } from '@angular/core';

@Directive({ selector: '[highlight]' })
/** Set backgroundColor for the attached element to highlight color
 *  and set the element's customProperty to true */
export class HighlightDirective implements OnChanges {

  defaultColor =  'rgb(211, 211, 211)'; // lightgray

  @Input('highlight') bgColor: string;

  constructor(private el: ElementRef) {
    el.nativeElement.style.customProperty = true;
  }

  ngOnChanges() {
    this.el.nativeElement.style.backgroundColor = this.bgColor || this.defaultColor;
  }
}

它的使用贯穿整个应用,也许最简单的使用在AboutComponent里:

src/app/about.component.ts

import { Component } from '@angular/core';
@Component({
  template: `
  <h2 highlight="skyblue">About</h2>
  <twain-quote></twain-quote>
  <p>All about this sample</p>`
})
export class AboutComponent { }

使用AboutComponent来测试这个HightlightDirective的使用,只需要上面解释过的知识就够了,(尤其是"浅测试程序"方法)。

src/app/about.component.spec.ts

beforeEach(() => {
  fixture = TestBed.configureTestingModule({
    declarations: [ AboutComponent, HighlightDirective],
    schemas:      [ NO_ERRORS_SCHEMA ]
  })
  .createComponent(AboutComponent);
  fixture.detectChanges(); // initial binding
});

it('should have skyblue <h2>', () => {
  const de = fixture.debugElement.query(By.css('h2'));
  const bgColor = de.nativeElement.style.backgroundColor;
  expect(bgColor).toBe('skyblue');
});

但是,测试单一的用例一般无法探索该指令的全部能力。 查找和测试所有使用该指令的组件非常繁琐和脆弱,并且通常无法覆盖所有组件。

孤立单元测试可能有用。 但是像这样的属性指令一般都操纵DOM。孤立单元测试不能控制DOM,所以不推荐用它测试指令的功能。

更好的方法是创建一个展示所有使用该组件的方法的人工测试组件。

src/app/shared/highlight.directive.spec.ts (TestComponent)

@Component({
  template: `
  <h2 highlight="yellow">Something Yellow</h2>
  <h2 highlight>The Default (Gray)</h2>
  <h2>No Highlight</h2>
  <input #box [highlight]="box.value" value="cyan"/>`
})
class TestComponent { }
HighlightDirective spec in action

<input>用例将HighlightDirective绑定到输入框里输入的颜色名字。 初始只是单词“cyan”,所以输入框的背景色应该是cyan。

下面是一些该组件的测试程序:

src/app/shared/highlight.directive.spec.ts (selected tests)

  1. beforeEach(() => {
  2. fixture = TestBed.configureTestingModule({
  3. declarations: [ HighlightDirective, TestComponent ]
  4. })
  5. .createComponent(TestComponent);
  6. fixture.detectChanges(); // initial binding
  7. // all elements with an attached HighlightDirective
  8. des = fixture.debugElement.queryAll(By.directive(HighlightDirective));
  9. // the h2 without the HighlightDirective
  10. bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])'));
  11. });
  12. // color tests
  13. it('should have three highlighted elements', () => {
  14. expect(des.length).toBe(3);
  15. });
  16. it('should color 1st <h2> background "yellow"', () => {
  17. const bgColor = des[0].nativeElement.style.backgroundColor;
  18. expect(bgColor).toBe('yellow');
  19. });
  20. it('should color 2nd <h2> background w/ default color', () => {
  21. const dir = des[1].injector.get(HighlightDirective) as HighlightDirective;
  22. const bgColor = des[1].nativeElement.style.backgroundColor;
  23. expect(bgColor).toBe(dir.defaultColor);
  24. });
  25. it('should bind <input> background to value color', () => {
  26. // easier to work with nativeElement
  27. const input = des[2].nativeElement as HTMLInputElement;
  28. expect(input.style.backgroundColor).toBe('cyan', 'initial backgroundColor');
  29. // dispatch a DOM event so that Angular responds to the input value change.
  30. input.value = 'green';
  31. input.dispatchEvent(newEvent('input'));
  32. fixture.detectChanges();
  33. expect(input.style.backgroundColor).toBe('green', 'changed backgroundColor');
  34. });
  35. it('bare <h2> should not have a customProperty', () => {
  36. expect(bareH2.properties['customProperty']).toBeUndefined();
  37. });

一些技巧值得注意:

回到顶部

孤立的单元测试

使用Angular测试工具测试应用程序是本章的重点。

但是,使用孤立单元测试来探索应用类的内在逻辑往往更加有效率,它不依赖Angular。 这种测试程序通常比较小、更易阅读、编写和维护。

它们不用背负额外的包袱:

它们会遵循测试时众所周知的模式:

同时采用这两种测试程序

优秀的开发者同时编写这两种测试程序来测试相同的应用部件,往往在同一个spec文件。 编写简单的孤立单元测试程序来验证孤立的部分。 编写Angular测试程序来验证与Angular互动、更新DOM、以及与应用其它部分互动的部分。

服务

服务是应用孤立测试的好例子。 下面是未使用Angular测试工具的一些FancyService的同步和异步单元测试:

src/app/bag/bag.no-testbed.spec.ts

  1. // Straight Jasmine - no imports from Angular test libraries
  2. describe('FancyService without the TestBed', () => {
  3. let service: FancyService;
  4. beforeEach(() => { service = new FancyService(); });
  5. it('#getValue should return real value', () => {
  6. expect(service.getValue()).toBe('real value');
  7. });
  8. it('#getAsyncValue should return async value', (done: DoneFn) => {
  9. service.getAsyncValue().then(value => {
  10. expect(value).toBe('async value');
  11. done();
  12. });
  13. });
  14. it('#getTimeoutValue should return timeout value', (done: DoneFn) => {
  15. service = new FancyService();
  16. service.getTimeoutValue().then(value => {
  17. expect(value).toBe('timeout value');
  18. done();
  19. });
  20. });
  21. it('#getObservableValue should return observable value', (done: DoneFn) => {
  22. service.getObservableValue().subscribe(value => {
  23. expect(value).toBe('observable value');
  24. done();
  25. });
  26. });
  27. });

粗略行数表明,这些孤立单元测试比同等的Angular测试小25%。 这表明了它的好处,但是不是最关键的。 主要的好处来自于缩减的配置和代码的复杂性。

比较下面两个同等的FancyService.getTimeoutValue测试程序:

  1. it('#getTimeoutValue should return timeout value', (done: DoneFn) => {
  2. service = new FancyService();
  3. service.getTimeoutValue().then(value => {
  4. expect(value).toBe('timeout value');
  5. done();
  6. });
  7. });

它们有类似的行数。 但是,依赖Angular的版本有更多活动的部分,包括一些工具函数(asyncinject)。 两种方法都可行,而且如果你为了某些原因使用Angular测试工具,也并没有什么问题。 反过来,为什么要为简单的服务测试程序添加复杂度呢?

选择你喜欢的方法。

带依赖的服务

服务通常依赖其它服务,Angular通过构造函数注入它们。 你可以不使用TestBed测试这些服务。 在许多情况下,创建和手动注入依赖来的更加容易。

DependentService是一个简单的例子:

src/app/bag/bag.ts

@Injectable()
export class DependentService {
  constructor(private dependentService: FancyService) { }
  getValue() { return this.dependentService.getValue(); }
}

它将唯一的方法,getValue,委托给了注入的FancyService

这里是几种测试它的方法。

src/app/bag/bag.no-testbed.spec.ts

  1. describe('DependentService without the TestBed', () => {
  2. let service: DependentService;
  3. it('#getValue should return real value by way of the real FancyService', () => {
  4. service = new DependentService(new FancyService());
  5. expect(service.getValue()).toBe('real value');
  6. });
  7. it('#getValue should return faked value by way of a fakeService', () => {
  8. service = new DependentService(new FakeFancyService());
  9. expect(service.getValue()).toBe('faked value');
  10. });
  11. it('#getValue should return faked value from a fake object', () => {
  12. const fake = { getValue: () => 'fake value' };
  13. service = new DependentService(fake as FancyService);
  14. expect(service.getValue()).toBe('fake value');
  15. });
  16. it('#getValue should return stubbed value from a FancyService spy', () => {
  17. const fancy = new FancyService();
  18. const stubValue = 'stub value';
  19. const spy = spyOn(fancy, 'getValue').and.returnValue(stubValue);
  20. service = new DependentService(fancy);
  21. expect(service.getValue()).toBe(stubValue, 'service returned stub value');
  22. expect(spy.calls.count()).toBe(1, 'stubbed method was called once');
  23. expect(spy.calls.mostRecent().returnValue).toBe(stubValue);
  24. });
  25. });

第一个测试程序使用new创建FancyService实例,并将它传递给DependentService构造函数。

很少有这么简单的,注入的服务有可能很难创建和控制。 你可以mock依赖,或者使用假值,或者用易于控制的替代品stub伪造相关服务。

这些孤立单元测试技巧是一个很好的方法,用来探索服务的内在逻辑,以及它与组件类简单的集成。 当在运行时间环境下,使用Angular测试工具来验证一个服务是如何与组件互动的。

管道

管道很容易测试,无需Angular测试工具。

管道类有一个方法,transform,用来转换输入值到输出值。 transform的实现很少与DOM交互。 除了@Pipe元数据和一个接口外,大部分管道不依赖Angular。

假设TitleCasePipe将每个单词的第一个字母变成大写。 下面是使用正则表达式实现的简单代码:

src/app/shared/title-case.pipe.ts

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({name: 'titlecase', pure: false})
/** Transform to Title Case: uppercase the first letter of the words in a string.*/
export class TitleCasePipe implements PipeTransform {
  transform(input: string): string {
    return input.length === 0 ? '' :
      input.replace(/\w\S*/g, (txt => txt[0].toUpperCase() + txt.substr(1).toLowerCase() ));
  }
}

任何使用正则表达式的类都值得彻底的进行测试。 使用Jasmine来探索预期的用例和极端的用例。

src/app/shared/title-case.pipe.spec.ts

  1. describe('TitleCasePipe', () => {
  2. // This pipe is a pure, stateless function so no need for BeforeEach
  3. let pipe = new TitleCasePipe();
  4. it('transforms "abc" to "Abc"', () => {
  5. expect(pipe.transform('abc')).toBe('Abc');
  6. });
  7. it('transforms "abc def" to "Abc Def"', () => {
  8. expect(pipe.transform('abc def')).toBe('Abc Def');
  9. });
  10. // ... more tests ...
  11. });

同时也编写Angular测试

有些管道的测试程序是孤立的。 它们不能验证TitleCasePipe是否在应用到组件上时是否工作正常。

考虑像这样添加组件测试程序:

src/app/hero/hero-detail.component.spec.ts (pipe test)

  1. it('should convert hero name to Title Case', () => {
  2. const inputName = 'quick BROWN fox';
  3. const titleCaseName = 'Quick Brown Fox';
  4. // simulate user entering new name into the input box
  5. page.nameInput.value = inputName;
  6. // dispatch a DOM event so that Angular learns of input value change.
  7. page.nameInput.dispatchEvent(newEvent('input'));
  8. // Tell Angular to update the output span through the title pipe
  9. fixture.detectChanges();
  10. expect(page.nameDisplay.textContent).toBe(titleCaseName);
  11. });

组件

组件测试通常检查该组件类是如何与自己的模板或者其它合作组件交互的。 Angular测试工具是专门为这种测试设计的。

考虑这个ButtonComp组件。

src/app/bag/bag.ts (ButtonComp)

@Component({
  selector: 'button-comp',
  template: `
    <button (click)="clicked()">Click me!</button>
    <span>{{message}}</span>`
})
export class ButtonComponent {
  isOn = false;
  clicked() { this.isOn = !this.isOn; }
  get message() { return `The light is ${this.isOn ? 'On' : 'Off'}`; }
}

下面的Angular测试演示点击模板里的按钮后,引起了屏幕上的消息的更新。

src/app/bag/bag.spec.ts (ButtonComp)

it('should support clicking a button', () => {
  const fixture = TestBed.createComponent(ButtonComponent);
  const btn  = fixture.debugElement.query(By.css('button'));
  const span = fixture.debugElement.query(By.css('span')).nativeElement;

  fixture.detectChanges();
  expect(span.textContent).toMatch(/is off/i, 'before click');

  click(btn);
  fixture.detectChanges();
  expect(span.textContent).toMatch(/is on/i, 'after click');
});

该判断验证了数据绑定从一个HTML控件(<button>)流动到组件,以及从组件回到不同的HTML控件(<span>)。 通过的测试程序说明组件和它的模块是否设置正确。

孤立单元测试可以更快的在API边界探测组件,更轻松的探索更多条件。

下面是一套单元测试程序,用来验证面对多种输入时组件的输出。

src/app/bag/bag.no-testbed.spec.ts (ButtonComp)

describe('ButtonComp', () => {
  let comp: ButtonComponent;
  beforeEach(() => comp = new ButtonComponent());

  it('#isOn should be false initially', () => {
    expect(comp.isOn).toBe(false);
  });

  it('#clicked() should set #isOn to true', () => {
    comp.clicked();
    expect(comp.isOn).toBe(true);
  });

  it('#clicked() should set #message to "is on"', () => {
    comp.clicked();
    expect(comp.message).toMatch(/is on/i);
  });

  it('#clicked() should toggle #isOn', () => {
    comp.clicked();
    expect(comp.isOn).toBe(true);
    comp.clicked();
    expect(comp.isOn).toBe(false);
  });
});

孤立组件单元测试使用更少的代码以及几乎不存在的配置,提供了很多测试覆盖率。 在测试复杂的组件时,这个优势显得更加明显,因为可能需要使用Angular测试工具进行精心准备。

但是,孤立测试无法确认ButtonComp是否与其模板正确的绑定,或者是否有数据绑定。 使用Angular测试来应对它们。

回到顶部

Angular测试工具API

本节将最有用的Angular测试功能提取出来,并总结了它们的作用。

Angular测试工具包括TestBedComponentFixture和一些其他函数,用来控制测试环境。 TestBedComponentFixture在这里分别解释了。

下面是一些独立函数的总结,以使用频率排序:

函数

描述

async

在特殊的async测试区域运行测试程序(it)或者设置(beforeEach)的主体。 参见上面的讨论.

fakeAsync

在特殊的fakeAsync测试区域运行测试程序(it)的主体,造就控制流更加线性的代码风格。 参见上面的讨论.

tick

fakeAsync测试区域内触发计时器微任务队列,以模拟时间的推移和未完成异步任务的完成。

好奇和执着的读者可能会喜欢这篇长博客: "Tasks, microtasks, queues and schedules".

接受一个可选参数,往前推移虚拟时间提供数字的毫秒数,清除在这段时间内的异步行为。 参见上面的讨论

inject

从当前TestBed注入器注入一个或多个服务到测试函数。参见上面

discardPeriodicTasks

fakeAsync测试程序以正在运行的计时器事件任务(排队中的setTimeOutsetInterval的回调)结束时, 测试会失败,并显示一条明确的错误信息。

一般来讲,测试程序应该以无排队任务结束。 当待执行计时器任务存在时,调用discardPeriodicTasks来触发任务队列,防止该错误发生。

flushMicrotasks

fakeAsync测试程序以待执行微任务(比如未解析的承诺)结束时,测试会失败并显示明确的错误信息。

一般来说,测试应该等待微任务结束。 当待执行微任务存在时,调用flushMicrotasks来触发微任务队列,防止该错误发生。

ComponentFixtureAutoDetect

一个提供商令牌,用来设置auto-changeDetect的值,它默认值为false。 参见自动变更检测

getTestBed

获取当前TestBed实例。 通常用不上,因为TestBed的静态类方法已经够用。 TestBed实例有一些很少需要用到的方法,它们没有对应的静态方法。

TestBed 类总结

TestBed类是Angular测试工具的主要类之一。它的API很庞大,可能有点过于复杂,直到你一点一点的探索它们。 阅读本章前面的部分,了解了基本的知识以后,再试着了解完整API。

传递给configureTestingModule的模块定义是@NgModule元数据属性的子集。

type TestModuleMetadata = {
  providers?: any[];
  declarations?: any[];
  imports?: any[];
  schemas?: Array<SchemaMetadata | any[]>;
};

每一个重载方法接受一个MetadataOverride<T>,这里T是适合这个方法的元数据类型,也就是@NgModule@Component@Directive或者@Pipe的参数。

type MetadataOverride = {
  add?: T;
  remove?: T;
  set?: T;
};

TestBed的API包含了一系列静态类方法,它们更新或者引用全局TestBed实例。

在内部,所有静态方法在getTestBed()函数返回的当前运行时间的TestBed实例上都有对应的方法。

BeforeEach()内调用TestBed方法,这样确保在运行每个单独测试时,都有崭新的开始。

这里列出了最重要的静态方法,以使用频率排序:

方法

描述

configureTestingModule

测试垫片(karma-test-shim, browser-test-shim)创建了初始测试环境和默认测试模块。 默认测试模块是使用基本声明和一些Angular服务替代品,它们是所有测试程序都需要的。

调用configureTestingModule来为一套特定的测试定义测试模块配置,添加和删除导入、(组件、指令和管道的)声明和服务提供商。

compileComponents

在你完成配置以后异步编译测试模块。 如果任何测试组件有templateUrlstyleUrls,那么你必须调用这个方法。因为获取组件模块和样式文件必须是异步的。 参见上面的描述

调用完compileComponents之后,TestBed的配置就会在当前测试期间被冻结。

createComponent

基于当前TestBed的配置创建一个类型为T的组件实例。 一旦调用,TestBed的配置就会在当前测试期间被冻结。

overrideModule

替换指定的NgModule的元数据。回想一下,模块可以导入其他模块。 overrideModule方法可以深入到当前测试模块深处,修改其中一个内部模块。

overrideComponent

替换指定组件类的元数据,该组件类可能嵌套在一个很深的内部模块中。

overrideDirective

替换指定指令类的元数据,该指令可能嵌套在一个很深的内部模块中。

overridePipe

替换指定管道类的元数据,该管道可能嵌套在一个很深的内部模块中。

get

从当前TestBed注入器获取一个服务。

inject函数通常很适合这个任务。 但是如果inject不能提供服务,它会抛出错误。 如果服务是可选的呢?

TestBed.get方法接受一个可选的第二参数,它是在Angular找不到所需提供商时返回的对象。(在本例中为null):

service = TestBed.get(FancyService, null);

一旦调用,TestBed的配置就会在当前测试期间被冻结。

initTestEnvironment

为整套测试的运行初始化测试环境。

测试垫片(karma-test-shim, browser-test-shim)会为你调用它,所以你很少需要自己调用它。

这个方法只能被调用一次。如果确实需要在测试程序运行期间变换这个默认设置,那么先调用resetTestEnvironment

指定Angular编译器工厂,PlatformRef,和默认Angular测试模块。 以@angular/platform-<platform_name>/testing/<platform_name>的形式提供非浏览器平台的替代品。

resetTestEnvironment

重设初始测试环境,包括默认测试模块在内。

少数TestBed实例方法没有对应的静态方法。它们很少被使用。

ComponentFixture对象

TestBed.createComponent<T>创建一个组件T的实例,并为该组件返回一个强类型的ComponentFixture

ComponentFixture的属性和方法提供了对组件、它的DOM和它的Angular环境方面的访问。

ComponentFixture的属性

下面是对测试最重要的属性,以使用频率排序:

属性

描述

componentInstance

TestBed.createComponent创建的组件类实例。

debugElement

与组件根元素关联的DebugElement

debugElement在测试和调试期间,提供对组件及其DOM元素的访问。 它是测试者至关重要的属性。它最有用的成员在下面有所介绍。

nativeElement

组件的原生根DOM元素。

changeDetectorRef

组件的ChangeDetectorRef

在测试一个拥有ChangeDetectionStrategy.OnPush的组件,或者在组件的变化测试在你的程序控制下时,ChangeDetectorRef是最重要的。

ComponentFixture的方法

fixture方法使Angular对组件树执行某些任务。 在触发Angular行为来模拟的用户行为时,调用这些方法。

下面是对测试最有用的方法。

方法

描述

detectChanges

为组件触发一轮变化检查。

调用它来初始化组件(它调用ngOnInit)。或者在你的测试代码改变了组件的数据绑定属性值后调用它。 Angular不能检测到你已经改变了personComponent.name属性,也不会更新name的绑定,直到你调用了detectChanges

之后,运行checkNoChanges,来确认没有循环更新,除非它被这样调用:detectChanges(false)

autoDetectChanges

设置fixture是否应该自动试图检测变化。

当自动检测打开时,测试fixture监听zone事件,并调用detectChanges。 当你的测试代码直接修改了组件属性值时,你还是要调用fixture.detectChanges来触发数据绑定更新。

默认值是false,喜欢对测试行为进行精细控制的测试者一般保持它为false

checkNoChanges

运行一次变更检测来确认没有待处理的变化。如果有未处理的变化,它将抛出一个错误。

isStable

如果fixture当前是稳定的,则返回true。 如果有异步任务没有完成,则返回false

whenStable

返回一个承诺,在fixture稳定时解析。

钩住这个承诺,以在异步行为或者异步变更检测之后继续测试。参见上面

destroy

触发组件的销毁。

DebugElement

DebugElement提供了对组件的DOM的访问。

fixture.debugElement返回测试根组件的DebugElement,通过它你可以访问(查询)fixture的整个元素和组件子树。

下面是DebugElement最有用的成员,以使用频率排序。

成员

描述

nativeElement

与浏览器中DOM元素对应(WebWorkers时,值为null)。

query

调用query(predicate: Predicate<DebugElement>)返回子树所有层中第一个匹配predicateDebugElement

queryAll

调用query(predicate: Predicate<DebugElement>)返回子树所有层中所有匹配predicateDebugElement

injector

宿主依赖注入器。 比如,根元素的组件实例注入器。

componentInstance

元素自己的组件实例(如果有)。

context

为元素提供父级上下文的对象。 通常是控制该元素的祖级组件实例。

当一个元素被*ngFor重复,它的上下文为NgForRow,它的$implicit属性值是该行的实例值。 比如,*ngFor="let hero of heroes"里的hero

children

DebugElement的直接子级。通过children来降序探索元素树。

DebugElement还有childNodes,即DebugNode对象列表。 DebugElementDebugNode对象衍生,而且通常节点(node)比元素多。测试者通常忽略赤裸节点。

parent

DebugElement的父级。如果DebugElement是根元素,parent为null。

name

元素的标签名字,如果它是一个元素的话。

triggerEventHandler

如果在元素的listeners列表中有一个对应的listener,则以事件名字触发。 第二个参数是事件对象,一般为事件处理器。 参见上面

如果事件缺乏监听器,或者有其它问题,考虑调用nativeElement.dispatchEvent(eventObject)

listeners

元素的@Output属性以及/或者元素的事件属性所附带的回调函数。

providerTokens

组件注入器的查询令牌。 包括组件自己的令牌和组件的providers元数据中列出来的令牌。

source

source是在源组件模板中查询这个元素的处所。

references

与模板本地变量(比如#foo)关联的词典对象,关键字与本地变量名字配对。

DebugElement.query(predicate)DebugElement.queryAll(predicate)方法接受一个条件方法, 它过滤源元素的子树,返回匹配的DebugElement

这个条件方法是任何接受一个DebugElement并返回真值的方法。 下面的例子查询所有拥有名为content的模块本地变量的所有DebugElement

// Filter for DebugElements with a #content reference
const contentRefs = el.queryAll( de => de.references['content']);

Angular的By类为常用条件方法提供了三个静态方法:

src/app/hero/hero-list.component.spec.ts

// Can find DebugElement either by css selector or by directive
const h2        = fixture.debugElement.query(By.css('h2'));
const directive = fixture.debugElement.query(By.directive(HighlightDirective));

下面是在线“Specs Bag”例子 / 可下载的例子Renderer测试程序的例子

it('BankAccountComponent should set attributes, styles, classes, and properties', () => {
  const fixture = TestBed.createComponent(BankAccountParentComponent);
  fixture.detectChanges();
  const comp = fixture.componentInstance;

  // the only child is debugElement of the BankAccount component
  const el = fixture.debugElement.children[0];
  const childComp = el.componentInstance as BankAccountComponent;
  expect(childComp).toEqual(jasmine.any(BankAccountComponent));

  expect(el.context).toBe(childComp, 'context is the child component');

  expect(el.attributes['account']).toBe(childComp.id, 'account attribute');
  expect(el.attributes['bank']).toBe(childComp.bank, 'bank attribute');

  expect(el.classes['closed']).toBe(true, 'closed class');
  expect(el.classes['open']).toBe(false, 'open class');

  expect(el.styles['color']).toBe(comp.color, 'color style');
  expect(el.styles['width']).toBe(comp.width + 'px', 'width style');
});
回到顶部

测试环境的设置文件

单元测试需要一些配置和启动代码,它们被收集到了这些设置文件中。 当你遵循环境设置中的步骤操作时,就会得到这些设置文件。 CLI工具也会生成类似的文件。

下面是对本章中这些设置文件的简短说明:

本章不会深入讲解这些文件的详情以及如何根据需要重新配置它们,那超出了本章的范围。

文件

描述

karma.conf.js

这个karma配置文件指定了要使用那些插件、要加载那些应用文件和测试文件、要使用哪些浏览器以及如何报告测试结果。

它加载了下列设置文件:

  • systemjs.config.js
  • systemjs.config.extras.js
  • karma-test-shim.js
karma-test-shim.js

这个垫片(shim)文件为karma准备Angular特有的测试环境,并启动karma自身。 这期间,它还加载systemjs.config.js文件。

systemjs.config.js

SystemJS加载应用文件和测试文件。 这个脚本告诉SystemJS到哪里去找那些文件,以及如何加载它们。 它和你在环境设置期间安装的那个systemjs.config.js是同一个版本。

systemjs.config.extras.js

一个可选的文件,它会为systemjs.config.js中提供SystemJS的配置加上应用自身需要的特殊配置。

常规的systemjs.config.js文件无法满足那些需求,我们需要自己填充它。

本章的例子中把*模型桶(barrel)添加到了SystemJS的packages配置中。

systemjs.config.extras.js

/** App specific SystemJS configuration */
System.config({
  packages: {
    // barrels
    'app/model': {main:'index.js', defaultExtension:'js'},
    'app/model/testing': {main:'index.js', defaultExtension:'js'}
  }
});

npm包

这些范例测试是为在Jasmine和karma而写的。 那两条“捷径”设置会把适当的Jasmine和Karma包添加到package.jsondevDependencies区。 当我们运行npm install时,它们就会被安装上。

返回顶部

常见问题

为何将测试的spec配置文件放置到被测试文件的傍边?

我们推荐将单元测试的spec配置文件放到与应用程序源代码文件所在的同一个文件夹中,因为:

什么时候我应该把测试spec文件放到测试目录中?

应用程序的整合测试spec文件可以测试横跨多个目录和模块的多个部分之间的互动。 它们不属于任何部分,很自然,没有特别的地方存放它们。

通常,在test目录中为它们创建一个合适的目录比较好。

当然,测试助手对象的测试spec文件也属于test目录,与它们对应的助手文件相邻。