经过夜以继日的奋战,终于熬到可以对外发布的日子了。是时候让过去投入的大量精力和时间得到回报了。然而,传来的一个消息犹如晴天霹雳:一个致命的bug导致用户无法注册。

15.1 测试驱动?

测试能够防患于未然,提升对程序的信心,也可以为新加入的开发人员提供指引。在软件开发领域,几乎没人质疑测试的作用。但是,人们在如何测试这个问题上一直争论不休。

一种方法是先写测试,再写实现过程,直至测试通过;另一种是已有实现代码,再写测试,验证代码是否正确。令人不解的是,二者的合理性常在开发社区中引发口水战。双方僵持不下,争论哪个才是正确的方法。

基于以往的经验,尤其是在严重依赖原型的情况下,我们将重点放在构建可测试代码上。我们发现,即使你的经历有所不同,但是在构建原型时,测试可能经常变更的代码片断会比让它运行起来耗费2~3倍的工作量。与此相反,我们在构建基于小型组件的应用程序时,将大量功能分解成不同的方法,从而测试整个蓝图的部分功能。这就是我们所说的可测试代码。

 一种替代构建原型(后测试)的方法论便是所谓的“红色—绿色—重构”。它的理念是要求你先写测试。运行测试会得到失败结果(红色),因为你还没有写任何实现的代码。只有在测试失败之后,才去写实现代码,直至所有测试通过(绿色)。

当然,测试什么取决于你和你的团队,而本章的重点在于讨论如何测试程序。

15.2 端对端测试与单元测试

测试程序有两种主要方法:端对端测试单元测试

如果使用自上而下的方法进行测试,那么写测试时就将程序视为一个“黑盒”。与程序交互就如真实用户一样,从“旁观者”的角度评判程序是否达标。这种自上而下的测试技巧被称为端对端测试

 在Angular中,最常用的工具叫作Protractor。Protractor能够打开浏览器与程序交互,收集测试结果,并检验测试结果与预期值是否相符。

第二种常用的测试方法是隔离程序的每个部件,在隔离环境中运行测试。这种测试形式叫作单元测试

在单元测试中,所写的测试需要事先提供既定的输入值与相应的逻辑单元,检测输出结果,确定它是否与我们的预期结果匹配。

在本章中,我们将会探讨如何对Angular程序进行单元测试

15.3 测试工具

为了测试程序,我们将用到两种工具:Jasmine和Karma。

15.3.1 Jasmine

Jasmine是一种用于测试JavaScript代码的行为驱动框架。

利用Jasmine,你可以设置代码在调用后的预期结果。

比如,我们假定Calculator对象有一个sum函数。想确保1加1的结果为2,就可以用一个测试(也叫规格,spec)来表达。使用以下代码:

describe('Calculator',()=> {

 it('sums 1 and 1 to 2',()=> {

  var calc = new Calculator();

  expect(calc.sum(1, 1)).toEqual(2);

 });

});

使用Jasmine的一个优点是代码易于阅读。从以上代码可以看到,我们期望 calc.sum的结果等于2。

测试通常由多个describe块和it块组成。

通常,我们用describe来组织要测试的逻辑单元,对于其内部每个要使用断言的预期都会用到一个it块。然而,这并不是一个硬性规定。你会经常看到一个it块包含多个预期。

在上述Calculator示例中,我们只是列举了一个简单的对象。正因为如此,整个类只使用了一个describe块,而每个方法使用一个it块。

大多数情况下并非如此。比如,某些方法会根据不同输入值产生不同结果,那么这些方法可以拥有多个相应的it块。在这种情况下,最好使用嵌套的describe块:对象级别用一个,每个方法也各用一个,然后在其内部的每个断言语句用一个单独的it块包裹。

大量有关describe块和it块的示例将贯穿本章。不必烦恼到底该用describe块还是it块,我们将用大量示例演示说明。

更多有关Jasmine和其语法的资料,参见Jasmine官方文档:http://jasmine.github.io/2.4/introduction.html。

15.3.2 Karma

使用Jasmine,我们可以描述测试和预期结果。要运行测试,还需要为测试提供一个浏览器环境。

Karma应运而生。使用Karma,我们可以在Chrome或Firefox之类的真实浏览器或者PhantomJS这样的空壳浏览器(无用户界面)内运行JavaScript代码。

15.4 编写单元测试

本节的重点是理解如何对一个Angular程序的各个部件进行单元测试。

我们将会学习如何测试服务组件HTTP请求等。同时,我们也会收获一些小技巧,让代码更容易测试。

15.5 Angular单元测试框架

Angular自身提供了一套基于Jasmine框架的辅助类,用以帮助我们编写单元测试。

主要的测试框架位于@angular/core/testing包中。(然而,为了测试组件,我们会用到 @angular/compiler/testing包和@angular/platform-browser/testing包中的一些辅助类。稍后具体介绍。)

如果这是你初次测试Angular程序,那么在为Angular写单元测试时,需要先完成一些必要的设置步骤。

例如,在需要注入依赖时,我们经常手动配置它们。在测试一个组件时,需要使用测试辅助类初始化它们。在测试路由时,还需要构建一些依赖。

设置有些繁琐,但不用太担心。一旦掌握,你就会发现从一个项目切换到另外一个项目,配置不会有多大变化。另外,本章也会指引你完成每一步。

和往常一样,可以在代码下载页面获取本章所有的源代码。用你喜欢的编辑器直接打开浏览,可以对本章涵盖的细节有一个大体的把握。我们建议你坚持参照代码来阅读本章。

15.6 测试前准备

我们在第7章创建了一个用于搜索音乐的应用。本章开始为这个程序编写测试。

Karma需要一个配置文件才能运行。因此配置Karma的第一步就是创建一个karma.conf.js文件。

将karma.conf.js放在项目的根目录下,如下所示。

code/routes/music/karma.conf.js

// Karma configuration

var path = require('path');

var cwd = process.cwd();

module.exports = function(config){

 config.set({

  // base path that will be used to resolve all patterns(eg.files, exclude)

  basePath:'',

  // frameworks to use

  // available frameworks:https://npmjs.org/browse/keyword/karma-adapter

  frameworks:['jasmine'],

  // list of files / patterns to load in the browser

  files:[

   { pattern:'test.bundle.js', watched:false }

  ],

  // list of files to exclude

  exclude:[

  ],

  // preprocess matching files before serving them to the browser

  // available preprocessors:https://npmjs.org/browse/keyword/karma-preproces\

sor

  preprocessors:{

   'test.bundle.js':['webpack', 'sourcemap']

  },

  webpack:{

   devtool:'inline-source-map',

   resolve:{

    root:[path.resolve(cwd)],

    modulesDirectories:['node_modules', 'app', 'app/ts', 'test', '.'],

    extensions:['', '.ts', '.js', '.css'],

    alias:{

     'app':'app'

    }

   },

   module:{

    loaders:[

     { test:/\.ts$/, loader:'ts-loader', exclude:[/node_modules/]}

    ]

   },

   stats:{

    colors:true,

    reasons:true

   },

   watch:true,

   debug:true

  },

  webpackServer:{

   noInfo:true

  },

  // test results reporter to use

  // possible values:'dots', 'progress'

  // available reporters:https://npmjs.org/browse/keyword/karma-reporter

  reporters:['spec'],

  // web server port

  port:9876,

  // enable / disable colors in the output(reporters and logs)

  colors:true,

  // level of logging

  // possible values:config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WAR\

N || config.LOG_INFO || config.LOG_DEBUG

  logLevel:config.LOG_INFO,

  // enable / disable watching file and executing tests whenever any file chan\

ges

  autoWatch:true,

  // start these browsers

  // available browser launchers:https://npmjs.org/browse/keyword/karma-launc\

her

  browsers:['Chrome'],

  // Continuous Integration mode

  // if true, Karma captures browsers, runs the tests and exits

  singleRun:false

 })

}

先别急于弄清这个文件的内容,而是记住以下几点:

●将PhantomJS设置成目标测试浏览器;

●使用Jasmine karma框架进行测试;

●使用一个名为test.bundle.js的webpack bundle文件包裹所有的测试和程序代码。

下一步,新建一个名为test的文件夹,用于存放测试文件:

mkdir test

15.7 测试服务类和HTTP

服务类在Angular程序中常以普通类的形式出现。在某种意义上说,这简化了测试,因为有时可以在不需要Angular的情况下直接进行测试。

配置好Karma,就可以开始测试SpotifyService类了。如果记得没错,这个服务类通过与Spotify API交互读取专辑、曲目和艺术家相关信息。

切换到test文件夹,新建一个service子文件夹,用于存放即将开始的服务类测试。一切准备就绪,开始创建第一个服务类测试文件,名为SpotifyService.spec.ts。

下面开始组织这个测试文件。首先需要从@angular/core/testing包中导入几个辅助类。

code/routes/music/test/services/SpotifyService.spec.ts

import {

 inject,

 fakeAsync,

 tick,

 TestBed

} from '@angular/core/testing';

接下来,还需要导入其他几个类。

code/routes/music/test/services/SpotifyService.spec.ts

import {MockBackend} from '@angular/http/testing';

import {

 Http,

 ConnectionBackend,

 BaseRequestOptions,

 Response,

 ResponseOptions

} from '@angular/http';

既然我们的服务用到了HTTP请求,就需要从@angular/http/testing包中导入MockBackend。有了这个类,就可以设置预期值和验证HTTP请求结果了。

最后,导入我们要测试目标类。

code/routes/music/test/services/SpotifyService.spec.ts

import {SpotifyService} from '../../app/ts/services/SpotifyService';

15.7.1 HTTP要点

马上要编写测试了,但是在每个测试的运行过程中都要访问Spotify服务器。这显然有些不妥,原因如下。

(1)HTTP请求相对比较慢,而且随着测试套件的体积越来越大,可以预见运行全部测试需要的时间也会越来越长。

(2)Spotify的API调用设置了阈值限制,如果不停地运行测试,会很快耗尽所有的API调用资源。

(3)如果处于离线状态、Spotify崩溃或无法访问,那么测试也会失败,即使代码在技术角度上没有问题也是一样。

这在写单元测试时给了我们一个提示:在运行测试前,必须隔离那些无法掌控的东西。

在我们例子中,对应的就是Spotify服务。解决方法是,用一个替身替换掉HTTP请求,而且这个替身不需要访问真实的Spotify服务器

在测试领域,这个过程被称为模拟依赖,也时也叫作伪装依赖。

 阅读文章“模拟不是伪装”(http://martinfowler.com/articles/mocksArentStubs.html)可以了解更多有关模拟和伪装之间的差异。

假设我们正在写的测试依赖于某个Car类。

它有几个方法:你可以调用start来启动一个Car实例,也可以调用汽车的其他方法,如 stop(停车),park(泊车)和 getSpeed(读取车速)。

下面介绍如何使用伪装和模拟来写依赖于这个类的测试。

15.7.2 伪装

伪装是即时创建的对象,它包含所依赖对象所有行为的一个子集。

下面写一个测试与start方法交互。

为Car即时创建一个伪装并将它注入到要测试的类中:

describe('Speedtrap', function(){

 it('tickets a car at more than 60mph', function(){

  var stubCar = { getSpeed:function(){ return 61; } };

  var speedTrap = new SpeedTrap(stubCar);

  speedTrap.ticketCount = 0;

  speedTrap.checkSpeed();

  expect(speedTrap.ticketCount).toEqual(1);

 });

});

这是使用伪装的一个典型场景。我们可能仅仅在某个测试内部使用它。

15.7.3 模拟

在我们的例子中,模拟是对象更完整的体现,它会重写依赖的部分或全部行为。在大部分情况下,模拟可以在一个测试套件的多个测试间反复使用。

它们有时用于断言方法是否按预期的方式调用。

一个模拟版本的Car类可能是这样的:

class MockCar {

 startCallCount:number = 0;

 start(){

  this.startCallCount++;

 }

}

它可以用在另外一个测试中,如:

describe('CarRemote', function(){

 it('starts the car when the start key is held', function(){

  var car = new MockCar();

  var remote = new CarRemote();

  remote.holdButton('start');

  expect(car.startCallCount).toEqual(1);

 });

});

模拟和伪装的最大区别在于:

●伪装提供手动重写行为功能的一个子集;

●模拟通常预设期望值,验证调用某些方法的返回结果。

15.7.4 Http MockBackend

既然现在心里有底了,就继续编写之前的服务类测试代码。

每次运行测试时都与在线的Spotify服务进行交互显然不是个好主意。幸运的是Angular提供了一种方法,使用MockBackend来伪装HTTP调用。

可以将这个类注入到一个Http实例中,这样我们就能按照自己的意图对HTTP交互行为进行操控了。可以使用不同的方法进行干预和断言:手动设置响应,模拟HTTP错误,添加更多预期(比如判断请求的URL是否与预期值匹配,请求参数是否正确,等等)。

因此这里的想法就是使用一个伪HTTP库。这个伪HTTP看起来和真实的HTTP库一样:所有方法一一匹配,可以返回响应结果,等等。然而,我们却不会真正发出一条请求。

事实上,除了伪造请求外,MockBackend还允许我们设置期望结果,监控我们的预期行为。

15.7.5 TestBed.configureTestingModule和提供者

当测试Angular程序时,需要确保配置顶级NgModule,后面会在这个测试中用到它。在进行配置时,我们要配置提供者、声明组件并导入其他模块:就像你平常使用NgModule一样(参见8.10节)。

测试Angular代码时,我们有时采取手动设置注入的方式。这样做的好处是能够对测试进行更多的操控。

所以在测试Http请求时,我们不会注入一个“真实”的Http类,取而代之的是注入一个看起来像Http的替身,但它可以真实地拦截请求,返回我们事先配置的响应。

为了做到这一点,要创建一个Http变体,其内部使用MockBackend。

方法是,在beforeEach钩子中使用TestBed.configureTestingModule。这个钩子接收一个回调函数,它会在每个测试运行之前被调用。这为替换类的具体实现提供了一个难得的机会。

code/routes/music/test/services/SpotifyService.spec.ts

describe('SpotifyService',()=> {

 beforeEach(()=> {

  TestBed.configureTestingModule({

   providers:[

    BaseRequestOptions,

    MockBackend,

    SpotifyService,

    { provide:Http,

     useFactory:(backend:ConnectionBackend,

            defaultOptions:BaseRequestOptions)=> {

             return new Http(backend, defaultOptions);

            }, deps:[MockBackend, BaseRequestOptions] },

   ]

  });

 });

注意TestBed.configureTestingModule的providers参数可以接收提供者数组,用于测试注入器。

BaseRequestOptions和SpotifyService是那些类的默认实现。最后一个提供者有点复杂。

code/routes/music/test/services/SpotifyService.spec.ts

    { provide:Http,

     useFactory:(backend:ConnectionBackend,

            defaultOptions:BaseRequestOptions)=> {

             return new Http(backend, defaultOptions);

            }, deps:[MockBackend, BaseRequestOptions] },

   ]

这段代码使用了provide和useFactory参数来创建一个Http类变体,使用了工厂模式(也就是useFactory的职责所在)。

这个工厂的方法签名需要接收一个ConnectionBackend实例和一个BaseRequestOption实例。这个对象的第二个参数是deps:[MockBackend, BaseRequestOptions]。这表示MockBackend是工厂的第一个参数,BaseRequestOptions(默认实现)为第二个参数。

最后,返回一个MockBackend作为函数结果的定制Http类。

这样做有什么好处呢?在测试代码中每次需要注入Http的地方,得到的都是我们改装过的Http实例。

我们会在大量测试中使用这个行之有效的方法:用依赖注入的方法定制依赖,隔离需要测试的功能。

15.7.6 测试getTrack方法

下面针对这个服务类写一个测试,验证我们正在调用正确的URL。

 如果你还没看过第7章的音乐程序,可以在7.10.5节找到源代码。

现在开始测试getTrack方法。

code/routes/music/app/ts/services/SpotifyService.ts

 getTrack(id:string):Observable<any[]> {

  return this.query(`/tracks/${id}`);

 }

你可能还记得这个方法的细节,它调用了query方法,从而分析接收的参数并拼接成URL。

code/routes/music/app/ts/services/SpotifyService.ts

 query(URL:string, params?:Array<string>):Observable<any[]> {

  let queryURL:string = `${SpotifyService.BASE_URL}${URL}`;

  if(params){

   queryURL = `${queryURL}?${params.join('&')}`;

  }

  return this.http.request(queryURL).map((res:any)=> res.json());

 }

请求/tracks/${id}意味着假设当调用getTrack('TRACK_ID')方法时,期望返回的URL是https://api.spotify.com/v1/tracks/TRACK_ID。

可以这样写这个测试:

describe('getTrack',()=> {

 it('retrieves using the track ID',

  inject([SpotifyService, MockBackend], fakeAsync((spotifyService, mockBackend)=> {

   var res;

   mockBackend.connections.subscribe(c => {

    expect(c.request.url).toBe('https://api.spotify.com/v1/tracks/TRACK_ID');

    let response = new ResponseOptions(ody:'{"name":"felipe"'});

    c.mockRespond(new Response(response));

   });

   spotifyService.getTrack('TRACK_ID').subscribe((_res)=> {

    res = _res;

   });

   tick();

   expect(res.name).toBe('felipe');

  }))

 );

});

初看有点难以理解,下面一一讲解。

当测试有依赖时,使用Angular注入器提供那些类的实例。如下所示:

inject([Class1, ……, ClassN],(instance1, ……, instanceN)=> {

 …… testing code ……

})

当测试返回的是一个承诺或者RxJS的可观察对象时,可以使用fakeAsync辅助工具来测试那些代码(像测试同步代码那样)。在调用tick()后,承诺立即生效,可观察对象也会马上接收到通知。

如下列代码所示:

inject([SpotifyService, MockBackend], fakeAsync((spotifyService, mockBackend)=> {

 ……

}));

首先要读取两个变量:spotifyService和mockBackend。前者是一个特定的SpotifyService实例,后者是一个MockBackend实例。注意内部函数(spotifyService, mockBackend)的参数是注入的,相应的类型在inject函数第一个参数的数组中(SpotifyService和MockBackend)指定。

其次运行位于fakeAsync内部的代码。这就意味着当调用tick()时,异步代码会以同步方式运行。

测试的运行环境已经准备就绪,现在可以写“真正”的测试代码了。首先声明一个res变量,存放HTTP调用响应结果。然后,订阅mockBackend.connections事件:

var res;

mockBackend.connections.subscribe(c => { …… });

简单地说,每当mockBackend上产生一个新的连接,我们都希望收到通知(例如,调用了这个函数)。

为了验证SpotifyService根据指定的TRACK_ID调用了正确的URL,可以指定预期结果为我们预设的URL。首先通过c.request.url得到URL值,然后设置期望结果:c.request.url的值应该是字符串'https://api.spotify.com/v1/tracks/TRACK_ID':

expect(c.request.url).toBe('https://api.spotify.com/v1/tracks/TRACK_ID');

运行测试。如果请求URL不匹配,则测试失败。

现在我们已经收到了请求,并证明了它是正确的。现在需要打造一个响应。为此,新建一个ResponseOptions实例,指定JSON字符串{"name":"felipe"}为响应内容。

let response = new ResponseOptions(ody:'{"name":"felipe"'});

最后,将连接的响应替换成一个Response对象,它包裹了刚刚创建的ResponseOptions实例。

c.mockRespond(new Response(response));

 注意,subscribe中的回调函数可以复杂到任何你想要的程度,可以包含基于URL的条件逻辑、查询参数或者任何可以从请求对象中读取的信息。

这样一来,我们就可以为可能遇到的每个场景编写测试了。

现在已经准备好了使用TRACK_ID参数来调用getTrack方法,并且可以通过res变量跟踪响应结果:

spotifyService.getTrack('TRACK_ID').subscribe((_res)=> {

 res = _res;

});

如果此时中断测试,在触发回调函数前会一直等待HTTP请求发送和响应结果返回。也有可能产生其他执行路径,我们不得不重新组织代码对任务进行同步。幸好fakeAsync可以解决这个问题。方法是调用tick(),异步代码会立即执行,就像变魔术一样:

tick();

执行最后一步检验,确保设置的响应结果和接收到的相同:

expect(res.name).toBe('felipe');

细想一下,这个服务类的所有方法的代码都非常类似。将设置URL预期值的代码片断抽取出来,放到一个名为expectedURL的函数中。

code/routes/music/test/services/SpotifyService.spec.ts

 function expectURL(backend:MockBackend, url:string){

  backend.connections.subscribe(c => {

   expect(c.request.url).toBe(url);

   let response = new ResponseOptions(ody:'{"name":"felipe"'});

   c.mockRespond(new Response(response));

  });

 }

依葫芦画瓢,可以轻而易举地为getArtist和getAlbum方法编写测试。

code/routes/music/test/services/SpotifyService.spec.ts

 describe('getArtist',()=> {

  it('retrieves using the artist ID',

   inject([SpotifyService, MockBackend], fakeAsync((svc, backend)=> {

    var res;

    expectURL(backend, 'https://api.spotify.com/v1/artists/ARTIST_ID');

    svc.getArtist('ARTIST_ID').subscribe((_res)=> {

     res = _res;

    });

    tick();

    expect(res.name).toBe('felipe');

   }))

  );

 });

 describe('getAlbum',()=> {

  it('retrieves using the album ID',

   inject([SpotifyService, MockBackend], fakeAsync((svc, backend)=> {

    var res;

    expectURL(backend, 'https://api.spotify.com/v1/albums/ALBUM_ID');

    svc.getAlbum('ALBUM_ID').subscribe((_res)=> {

     res = _res;

    });

    tick();

    expect(res.name).toBe('felipe');

   }))

  );

 });

searchTrack方法稍有不同:它不直接调用query,而是使用search方法替代。

code/routes/music/app/ts/services/SpotifyService.ts

 searchTrack(query:string):Observable<any[]> {

  return this.search(query, 'track');

 }

search接着调用query,将/search作为第一个参数并将一个包含q=<query>和type=track的数组作为第二个参数。

code/routes/music/app/ts/services/SpotifyService.ts

 search(query:string, type:string):Observable<any[]> {

  return this.query(`/search`, [

   `q=${query}`,

   `type=${type}`

  ]);

 }

最后,query将参数转换成带有QueryString的URL路径。我们期待调用的URL是以/search?q=&type=track结尾的。

综合所学知识,为searchTrack方法编写测试。

code/routes/music/test/services/SpotifyService.spec.ts

 describe('searchTrack',()=> {

  it('searches type and term',

   inject([SpotifyService, MockBackend], fakeAsync((svc, backend)=> {

    var res;

    expectURL(backend, 'https://api.spotify.com/v1/search?q=TERM&type=track'\

);

    svc.searchTrack("TERM").subscribe((_res)=> {

     res = _res;

    });

    tick();

    expect(res.name).toBe('felipe');

   }))

  );

 });

这个测试与之前写过的测试异曲同工。下面一起回顾这个测试的内容:

●植入HTTP生命周期,在HTTP连接初始化时添加回调;

●为当前连接设置预期URL,包含查询类型和搜索关键字;

●调用测试方法searchTrack;

●通知Angular完成所有等待的异步调用;

●断言预期响应结果。

简而言之,测试服务类时要做的是:

(1)使用伪装或模拟来隔离全部依赖;

(2)在异步调用的情况下,使用fakeAsync和tick确保它们的完成;

(3)调用要测试的服务类;

(4)断言方法返回值与预期值匹配。

下面把注意力转向那些消费服务的类:组件。

15.8 测试组件间的路由

测试组件时,可以使用下列两种策略之一:

(1)编写测试从外部与组件进行交互,传递属性值,检验标签生成结果;

(2)测试各个组件方法及其输出结果。

这两种测试策略分别称为黑盒测试白盒测试。本节将演示如何混合使用它们。

首先为相对简单的一个组件ArtistComponent类编写测试。第一部分测试将测试组件的内部结构,所以它属于白盒测试

在开始之前,先回顾一下ArtistComponent的内容:

在类的构造函数上,首先从routeParams集合中读取id。

code/routes/music/app/ts/components/ArtistComponent.ts

 constructor(private route:ActivatedRoute, private spotify:SpotifyService,

       private location:Location){

  route.params.subscribe(params => { this.id = params['id']; });

 }

很快,我们遇到了第一个绊脚石:在没有运行状态路由器的情况下,如何获取当前路由的ID?

15.8.1 为测试创建路由器

在Angular中写测试时,我们手动配置了大量注入的类。路由(和测试组件)也包含大量需要注入的依赖。尽管如此,一旦配置好了,它就很少变更而且简单易用。

写测试时,使用beforeEach和TestBed.configureTestingModule设置可注入的依赖是很方便的。在测试ArtistComponent的时候,将定义一个函数来创建和配置这个测试的路由。

code/routes/music/test/components/ArtistComponent.spec.ts

describe('ArtistComponent',()=> {

 beforeEach(()=> {

  configureMusicTests();

 });

在辅助类文件MusicTestHelpers.ts中定义方法configureMusicTests。一起来看看。

这仅仅是configureMusicTests的实现代码。不用担心,下面将逐一解释。

code/routes/music/test/MusicTestHelpers.ts

export function configureMusicTests(){

 const mockSpotifyService:MockSpotifyService = new MockSpotifyService();

 TestBed.configureTestingModule({

  imports:[

   { // TODO RouterTestingModule.withRoutes coming soon

    ngModule:RouterTestingModule,

    providers:[provideRoutes(routerConfig)]

   },

   TestModule

  ],

  providers:[

   mockSpotifyService.getProviders(),

   {

    provide:ActivatedRoute,

    useFactory:(r:Router)=> r.routerState.root, deps:[ Router ]

   }

  ]

 });

}

首先创建一个MockSpotifyService实例,用来模拟真实的SpotifyService实现。

接下来,使用一个名为TestBed的类,并调用其方法configureTestingModule。TestBed是Angular内置的一个辅助类库,帮助我们简化测试。

本例中,TestBed.configureTestingModule的作用是为测试配置NgModule。你可以看到我们提供了一个NgModule配置作为参数,它包含:

●imports

●providers

在imports中,导入:

●RouterTestingModule,并用routerConfig进行配置——这样能够为测试配置路由器;

●TestModule,这个NgModule声明了所有将要测试的组件(具体细节参见MusicTestHelpers.ts)。

在providers中,提供了:

●MockSpotifyService(通过mockSpotifyService.getProviders())

●ActivatedRoute

我们以Router为入口,进一步学习。

Router

至今尚未提及的是测试时要用到哪些路由。对此有多种方法实现,首先看一下我们要用的方式。

code/routes/music/test/MusicTestHelpers.ts

@Component({

 selector:'blank-cmp',

 template:``

})

export class BlankCmp {

}

@Component({

 selector:'root-cmp',

 template:`<router-outlet></router-outlet>`

})

export class RootCmp {

}

export const routerConfig:Routes = [

 { path:'', component:BlankCmp },

 { path:'search', component:SearchComponent },

 { path:'artists/:id', component:ArtistComponent },

 { path:'tracks/:id', component:TrackComponent },

 { path:'albums/:id', component:AlbumComponent }

];

这里并不(像真实路由器配置的那样)跳转到一个空的URL,而是使用一个BlankCmp替代。

当然,如果你坚持像顶层应用那样使用RouterConfig,那么要先在其他地方使用export导出,并在此处使用import导入。

如果遇到更复杂的场景,必须针对多种不同的路由配置进行测试,那么可以在musicTestProviders函数中接收一个参数,从而每次运行测试都使用一个新的路由器配置。

面临太多的选择,你必须挑选一种最适合自己团队的方式。在路由是相对静态的并且一个配置可以服务于所有测试的情况下,这个配置相当棒。

现在所有依赖都已经解决,可以通过new Router创建一个新的路由器,并调用其r.initialNavigation()方法。

ActivatedRoute

ActivatedRoute服务跟踪“当前路由”。它需要把Router作为依赖,并把它加入到deps来进行注入。

MockSpotifyService

之前通过模拟HTTP库测试了SpotifyService。这里我们将会模拟整个服务类。一起来看看如何模拟这个类,或者说任何服务。

15.8.2 模拟依赖

在music/test目录下,找到mocks/spotify.ts文件,内容如下。

code/routes/music/test/mocks/spotify.ts

import {SpyObject} from './helper';

import {SpotifyService} from '../../app/ts/services/SpotifyService';

export class MockSpotifyService extends SpyObject {

 getAlbumSpy;

 getArtistSpy;

 getTrackSpy;

 searchTrackSpy;

 mockObservable;

 fakeResponse;

这里声明MockSpotifyService服务类,它是真实SpotifyService的一个模拟版本。这些实例变量会被作为探子(spy)使用。

15.8.3 探子

探子是一种比较特别的模拟对象,有两个好处:

(1)可以模拟返回值;

(2)可以计算方法调用次数和调用的参数值。

要在Angular测试中使用探子,可以使用一个内部类SpyObject实现(用于Angular内部测试)。

正如我们的代码所示,你可以即时创建一个新SpyObject或者让模拟类继承SpyObject。

继承或直接使用这个类的好处在于,它提供一个spy方法。spy方法允许你覆盖某个方法并强制返回值(以及监控,确保方法被调用)。下面的代码对类构造函数使用spy。

code/routes/music/test/mocks/spotify.ts

 constructor(){

  super(SpotifyService);

  this.fakeResponse = null;

  this.getAlbumSpy = this.spy('getAlbum').andReturn(this);

  this.getArtistSpy = this.spy('getArtist').andReturn(this);

  this.getTrackSpy = this.spy('getTrack').andReturn(this);

  this.searchTrackSpy = this.spy('searchTrack').andReturn(this);

 }

构造函数的第一行调用了SpyObject构造函数,传递要模拟的特定类。调用super(……)是可选的,但模拟时类会继承所有特定类的方法,因此你只需要覆盖要测试的方法。

 如果你想知道SpyObject是如何实现的,请查看angular/angular项目下的文件/modules/angular2/src/testing/testing_internal.ts(https://github.com/angular/angular/blob/b0cebd-ba6b651e9e7eb5bf801ea42dc7c4a7f25/modules/angular2/src/testing/testing_internal.ts#L205)。

调用super之后,将fakeResponse的值初始化为null,我们稍后会用到它。

接下来用探子替换特定类的方法。编写测试时使用一个引用更容易设置预期值和模拟响应结果。

在ArtistComponent中使用SpotifyService时,真实的getArtist方法返回一个可观察对象。在组件中调用的方法是subscribe方法。

code/routes/music/app/ts/components/ArtistComponent.ts

 ngOnInit():void {

  this.spotify

   .getArtist(this.id)

   .subscribe((res:any)=> this.renderArtist(res));

 }

然而在模拟类中,我们会采取一个小技巧:getArtist并不返回可观察对象,而是返回this,也就是MockSpotifyService自身。这就意味着上面this.spotify.getArtist(this.id)的返回值是MockSpotifyService。

不过这样有一个问题:ArtistComponent将会调用可观察对象的subscribe方法。考虑到这一点,可以在MockSpotifyService中定义一个subscribe方法。

code/routes/music/test/mocks/spotify.ts

 subscribe(callback){

  callback(this.fakeResponse);

 }

现在在模拟对象上调用subscribe方法,会立即调用这个回调函数,异步方法会同步执行。

另外注意,我们使用this.fakeResponse来调用这个回调函数。它将我们引向另一个方法。

code/routes/music/test/mocks/spotify.ts

 setResponse(json:any):void {

  this.fakeResponse = json;

 }

这个方法并没有替换特定服务的任何部件,取而代之的是使用一个辅助方法,允许测试代码设置既定的响应结果(可能来源于特定的服务),并利用它模拟不同的响应。

code/routes/music/test/mocks/spotify.ts

 getProviders():Array<any> {

  return [{ provide:SpotifyService, useValue:this }];

 }

最后一个方法是辅助方法,用在TestBed.configureTestingModule的providers参数上。它和稍后回过头来写组件测试时看到的类似。

下面是MockSpotifyService的完整代码。

code/routes/music/test/mocks/spotify.ts

import {SpyObject} from './helper';

import {SpotifyService} from '../../app/ts/services/SpotifyService';

export class MockSpotifyService extends SpyObject {

 getAlbumSpy;

 getArtistSpy;

 getTrackSpy;

 searchTrackSpy;

 mockObservable;

 fakeResponse;

 constructor(){

  super(SpotifyService);

  this.fakeResponse = null;

  this.getAlbumSpy = this.spy('getAlbum').andReturn(this);

  this.getArtistSpy = this.spy('getArtist').andReturn(this);

  this.getTrackSpy = this.spy('getTrack').andReturn(this);

  this.searchTrackSpy = this.spy('searchTrack').andReturn(this);

 }

 subscribe(callback){

  callback(this.fakeResponse);

 }

 setResponse(json:any):void {

  this.fakeResponse = json;

 }

 getProviders():Array<any> {

  return [{ provide:SpotifyService, useValue:this }];

 }

}

15.9 回到测试代码

万事俱备,只欠东风。现在可以为ArtistComponent编写测试代码了。

首先是导入语句。

code/routes/music/test/components/ArtistComponent.spec.ts

import {

 inject,

 fakeAsync,

} from '@angular/core/testing';

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

import { Location } from '@angular/common';

import { MockSpotifyService } from '../mocks/spotify';

import { SpotifyService } from '../../app/ts/services/SpotifyService';

import {

 advance,

 createRoot,

 RootCmp,

 configureMusicTests

} from '../MusicTestHelpers';

接下来使用configureMusicTests描述测试,确保所有测试用例都可以访问musicTestProviders。

code/routes/music/test/components/ArtistComponent.spec.ts

describe('ArtistComponent',()=> {

 beforeEach(()=> {

  configureMusicTests();

 });

然后写一个测试来验证组件初始化的细节。首先,回顾一下ArtistComponent的初始化过程。

code/routes/music/app/ts/components/ArtistComponent.ts

export class ArtistComponent implements OnInit {

 id:string;

 artist:Object;

 constructor(private route:ActivatedRoute, private spotify:SpotifyService,

       private location:Location){

  route.params.subscribe(params => { this.id = params['id']; });

 }

 ngOnInit():void {

  this.spotify

   .getArtist(this.id)

   .subscribe((res:any)=> this.renderArtist(res));

 }

请记住,创建组件时,使用route.params接收当时路由的id参数,并将它存储在类的id属性中。

当组件初始化时,ngOnInit方法被Angular触发(因为此组件实现了OnInit接口)。然后针对接收到的id使用SpotifyService读取相应的艺术家。当获取艺术家数据后,调用renderArtist,传递艺术家数据。

这里一个重要的理念就是使用依赖注入来获取SpotifyService,但是要记得,我们之前已经创建了一个MockSpotifyService。

为了测试这一行为,执行以下步骤:

(1)使用路由导向到ArtistComponent,组件会进行初始化;

(2)验证MockSpotifyService在ArtistComponent中已经被注入,根据相应的id读取艺术家数据。

下面是完整的测试代码。

code/routes/music/test/components/ArtistComponent.spec.ts

 describe('initialization',()=> {

  it('retrieves the artist', fakeAsync(

   inject([Router, SpotifyService],

      (router:Router,

       mockSpotifyService:MockSpotifyService)=> {

    const fixture = createRoot(router, RootCmp);

    router.navigateByUrl('/artists/2');

    advance(fixture);

    expect(mockSpotifyService.getArtistSpy).toHaveBeenCalledWith('2');

   })));

 });

接下来一步步进行解释。

15.9.1 fakeAsync和advance

首先用fakeAsync包裹测试。有了fakeAsync,我们就能够在状态检测和异步操作发生时进行更多的控制,并且不需要深入其内部细节。这样做的结果是,我们在测试中做了变更,必须显式地通知组件检测变更结果。

一般来说,开发程序时不必担心这个问题,这是Zones应该做的事。但在整个测试过程中,我们可以更细致地对状态变化的检测进行操作。

向下跳过几行,可以看到调用了MusicTestHelpers中的advance函数。一起看看这个函数。

code/routes/music/test/MusicTestHelpers.ts

export function advance(fixture:ComponentFixture<any>):void {

 tick();

 fixture.detectChanges();

}

advance函数做了两件事:

(1)通知组件检测状态变更;

(2)调用tick()。

使用fakeAsync时,计时器是同步的。我们使用tick()来模拟异步流逝的时间。

实际上,在我们的测试中,任何需要Angular大显身手的时候都可以调用advance函数。例如,如果要导向到新的路由,更新一个表单元素,发出一个HTTP请求等,我们都可以调用advance函数给Angular制造机会大显神通。

15.9.2 inject

在测试中需要添加一些依赖。使用inject可以做到这一点。inject接收两个参数:

(1)一个等待注入的令牌数组

(2)一个提供了注入的函数

inject会使用哪些类?提供者通过TestBed.configureTestingModule的providers来定义。

注意,这里要注入:

(1)Router

(2)SpotifyService

要注入的Router类就是上面musicTestProviders中配置的Router。

对于SpotifyService,注意请求注入SpotifyService时,得到的是MockSpotifyService。看起来有点晦涩,但是基于目前为止的讨论你应该可以理解。

15.9.3 测试ArtistComponent组件初始化

一起回顾一下测试代码的内容。

code/routes/music/test/components/ArtistComponent.spec.ts

   const fixture = createRoot(router, RootCmp);

   router.navigateByUrl('/artists/2');

   advance(fixture);

   expect(mockSpotifyService.getArtistSpy).toHaveBeenCalledWith('2');

我们使用createRoot创建一个RootCmp实例。一起看看createRoot辅助函数。

code/routes/music/test/MusicTestHelpers.ts

export function createRoot(router:Router,

              componentType:any):ComponentFixture<any> {

 const f = TestBed.createComponent(componentType);

 advance(f);

 (<any>router).initialNavigation();

 advance(f);

 return f;

}

注意,这时调用createRoot可以:

(1)创建一个根组件实例;

(2)对其进行advance处理;

(3)通知路由器设置initialNavigation;

(4)再次进行advance处理;

(5)返回全新的根组件。

测试依赖路由器的组件时需要不少准备工作,有这个辅助函数可以方便不少。

注意我们再次使用了TestBed类库来调用TestBed.createComponent方法。这个方法创建了一个相应类型的组件。

RootCmp是我们在MusicTestHelpers中创建的一个空组件。其实完全没必要为根组件创建一个空组件。这里这样做是因为能够或多或少在隔离环境中测试子组件(ArtistComponent)。这样一来,就不必担心对上层应用组件的影响了。

但是,也许你想要确保子组件在上下文环境中正确运行。在这种情况下,你可能想使用应用程序常规的父组件,而非RootCmp。

接下来探讨使用router转向URL /artists/2以及advance。当我们定位到该URL时,ArtistComponent应该会进行初始化,因此可以断定调用SpotifyService的getArtist方法时返回了正确的值。

15.9.4 测试ArtistComponent方法

回想一下,ArtistComponent中有一个href调用了back()方法。

code/routes/music/app/ts/components/ArtistComponent.ts

 back():void {

  this.location.back();

 }

下面来测试,当调用back方法时,路由器会将用户重定向回之前的位置。

当前位置状态由Location服务控制。当需要将用户返回原来的位置时,我们使用Location的back方法。

这里演示如何测试back方法。

code/routes/music/test/components/ArtistComponent.spec.ts

 describe('back',()=> {

  it('returns to the previous location', fakeAsync(

   inject([Router, Location],

      (router:Router, location:Location)=> {

    const fixture = createRoot(router, RootCmp);

    expect(location.path()).toEqual('/');

    router.navigateByUrl('/artists/2');

    advance(fixture);

    expect(location.path()).toEqual('/artists/2');

    const artist = fixture.debugElement.children[1].componentInstance;

    artist.back();

    advance(fixture);

    expect(location.path()).toEqual('/');

   })));

 });

初始结构与之类似:注入依赖并创建一个新的组件。

加入一个新的expect语句,断定location.path()与预期结果一致。

这里提供一种新思路:当访问ArtistComponent的方法时,通过fixture.debugElement.children[1].componentInstance这一行得到ArtistComponent实例的一个引用。

有了组件实例,就可以直接调用其方法了,如back()。

调用了back方法后,进行advance处理,然后验证location.path()是否与预期一致。

15.9.5 测试ArtistComponent DOM模板值

最后要测试ArtistComponent的一部分就是生成艺术家模板。

code/routes/music/app/ts/components/ArtistComponent.ts

 template:`

 <div *ngIf="artist">

  <h1>{{ artist.name }}</h1>

  <p>

   <img src="{{ artist.images[0].url }}">

  </p>

  <p><a href(click)="back()">Back</a></p>

 </div>

 `

记住,实例变量artist是SpotifyService类getArtist方法的调用结果。既然用MockSpotifyService模拟SpotifyService,那么模板中使用的数据无论如何都应该是mockSpotifyService的返回结果。下面一起看看如何实现。

code/routes/music/test/components/ArtistComponent.spec.ts

 describe('renderArtist',()=> {

  it('renders album info', fakeAsync(

   inject([Router, SpotifyService],

      (router:Router,

       mockSpotifyService:MockSpotifyService)=> {

    const fixture = createRoot(router, RootCmp);

    let artist = {name:'ARTIST NAME', images:[rl:'IMAGE_1']};

    mockSpotifyService.setResponse(artist);

    router.navigateByUrl('/artists/2');

    advance(fixture);

    const compiled = fixture.debugElement.nativeElement;

    expect(compiled.querySelector('h1').innerHTML).toContain('ARTIST NAME');

    expect(compiled.querySelector('img').src).toContain('IMAGE_1');

   })));

 });

这里比较陌生的是通过mockSpotifyService的setResponse方法手动设置返回结果。

artist变量是一个测试工具夹,代表调用artists终端即使用GET方法请求https://api.spotify.com/v1/artists/{id}时从Spotify API返回的结果。

真实的JSON数据看起来如图15-1所示。

图15-1 Spotify中用来获取艺术家的服务端点

但是对于这个测试,我们仅需要name和images属性。

调用setResponse时,响应会作用于后面所有调用服务方法的下一轮调用过程。在本例中,我们要的结果是getArtist方法返回此响应。

接下来,通过路由器定位并进行advance处理。现在视图已经生成,可以使用组件视图的DOM表现形式检测是否已经正确地生成了艺术家。

fixture.debugElement.nativeElement一行中读取DebugElement的nativeElement属性可以做到这一点。

在断言语句中,我们期望H1标签中包含艺术家的名字,在本例中是字符串ARTIST NAME(源自上面的artist工具夹)。

为了检查这些条件,我们用到了NativeElement的querySelector方法。此方法会返回与CSS选择器匹配的第一个元素。

对于H1,我们检测其文本内容确实是ARTIST NAME,而图片的src属性值为IMAGE 1。

至此,我们已经完成了对ArtistComponent组件的测试。

15.10 测试表单

为了演示为表单编写测试,我们使用在第5章中创建的DemoFormNgModel组件。这个例子很不错,因为它用到了Angular表单的一些特性:

●使用FormBuilder

●包含表单验证

●处理事件

下面是这个类的完整代码。

code/forms/app/forms/demo_form_with_events.ts

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

import {

 FormBuilder,

 FormGroup,

 Validators,

 AbstractControl

} from '@angular/forms';

@Component({

 selector:'demo-form-with-events',

 template:`

 <div class="ui raised segment">

  <h2 class="ui header">Demo Form:with events</h2>

  <form [formGroup]="myForm"

     (ngSubmit)="onSubmit(myForm.value)"

     class="ui form">

   <div class="field"

     [class.error]="!sku.valid && sku.touched">

    <label for="skuInput">SKU</label>

    <input type="text"

        class="form-control"

        id="skuInput"

        placeholder="SKU"

        [formControl]="sku">

    <div *ngIf="!sku.valid"

     class="ui error message">SKU is invalid</div>

    <div *ngIf="sku.hasError('required')"

     class="ui error message">SKU is required</div>

   </div>

   <div *ngIf="!myForm.valid"

    class="ui error message">Form is invalid</div>

   <button type="submit" class="ui button">Submit</button>

  </form>

 </div>

 `

})

export class DemoFormWithEvents {

 myForm:FormGroup;

 sku:AbstractControl;

 constructor(fb:FormBuilder){

  this.myForm = fb.group({

   'sku':['', Validators.required]

  });

  this.sku = this.myForm.controls['sku'];

  this.sku.valueChanges.subscribe(

   (value:string)=> {

    console.log('sku changed to:', value);

   }

  );

  this.myForm.valueChanges.subscribe(

   (form:any)=> {

    console.log('form changed to:', form);

   }

  );

 }

 onSubmit(form:any):void {

  console.log('you submitted value:', form.sku);

 }

}

回顾一下,这段代码包含以下行为:

●当没有值填充SKU字段时,会显示两条验证错误信息,分别是SKU is invalid和SKU is required;

●当SKU字段的值发生改变时,在控制台打印一条日志信息;

●当表单发生改变时,也在控制台打印一条日志信息;

●当提交表单时,在控制台打印最后一条日志信息。

很显然,我们用到了一个外部依赖,即控制台。正如我们之前学到的那样,必须用一些技巧来模拟所有外部依赖。

15.10.1 创建一个ConsoleSpy

这次不用SpyObject来创建伪对象。既然我们所有使用console的场景都是调用log方法,可以让事情更简单一些。

用我们能够掌控的ConsoleSpy替换原来依赖于window.console对象的console实例。

code/forms/test/util.ts

export class ConsoleSpy {

 public logs:string[] = [];

 log(……args){

  this.logs.push(args.join(' '));

 }

 warn(……args){

  this.log(……args);

 }

}

ConsoleSpy会接收所有日志记录,简单地转换成字符串,并存储在其内部一个日志记录字符串列表中。

在我们的console.log版本中,为了接收可变参数,我们使用ES6和TypeScript的Rest参数

这个操作符由省略号表示,如我们的函数参数……theArgs。简单概括,使用它表示我们将接收从点号起所有剩余的参数。例如(a, b, ……theArgs)调用了func(1, 2, 3, 4, 5),那么a应该是1,b应该是2,而theArgs应该包含数组[3, 4, 5]。

如果你安装了最新的Node.js,可以自己尝试一下:

  $ node ——harmony

  var test =(a, b, ……theArgs)=> console.log('a=',a,'b=',b,'theArgs=',theArgs);

  undefined

  test(1,2,3,4,5);

  a= 1 b= 2 theArgs= [ 3, 4, 5 ]

这样,我们并不把它写进控制台本身,而是将它存储在一个数组中。如果在测试下面的代码调用了console.log三次:

console.log('First message', 'is', 123);

console.log('Second message');

console.log('Third message');

我们期望_logs字段中包含一个数组['First message is 123', 'Second message', 'Third message']。

15.10.2 安装ConsoleSpy

为了在测试中使用探子,我们声明了两个变量:originalConsole和fakeConsole。前者存放一份原始控制台实例的引用,后者则存放控制台的模拟版本。我们还声明了一些有助于测试input和form元素的变量。

code/forms/test/forms/demo_form_with_events.spec.ts

describe('DemoFormWithEvents',()=> {

 let originalConsole, fakeConsole;

 let el, input, form;

下面可以安装这个伪控制台,指定提供者。

code/forms/test/forms/demo_form_with_events.spec.ts

 beforeEach(()=> {

  // replace the real window.console with our spy

  fakeConsole = new ConsoleSpy();

  originalConsole = window.console;

  (<any>window).console = fakeConsole;

  TestBed.configureTestingModule({

   imports:[ FormsModule, ReactiveFormsModule ],

   declarations:[ DemoFormWithEvents ]

  });

 });

回到测试代码,下面要做的是将真实控制台替换成我们的模板版本,换掉原始实例。

最后,在afterAll方法中将原始控制台实例还原,以免对其他测试造成泄漏(leak)。

code/forms/test/forms/demo_form_with_events.spec.ts

 // restores the real console

 afterAll(()=>(<any>window).console = originalConsole);

15.10.3 配置测试模块

注意,我们在beforeEach块中调用了TestBed.configureTestingModule。要记住configureTestingModule方法为测试设置了根NgModule。

我们在本例中导入了两个表单模块,声明了一个DemoFormWithEvents组件。

现在我们可以操控控制台了,下面开始测试表单。

15.10.4 测试表单

现在需要对验证错误信息和表单事件进行测试。

首先要做的是取得SKU输入字段和表单元素的引用。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 it('validates and triggers events', fakeAsync((tcb)=> {

  let fixture = TestBed.createComponent(DemoFormWithEvents);

  let el = fixture.debugElement.nativeElement;

  let input = fixture.debugElement.query(By.css('input')).nativeElement;

  let form = fixture.debugElement.query(By.css('form')).nativeElement;

  fixture.detectChanges();

最后一行通知Angular提交所有未完成的变更,正如我们在15.8节所做的那样。接下来将SKU输入值设置为空字符串。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 input.value = '';

 dispatchEvent(input, 'input');

 fixture.detectChanges();

 tick();

这里我们用dispatchEvent通知Angular输入元素发生了变更,再一次触发变更检测。最后用tick()确保此时已触发的所有异步代码都已经执行。

在这个测试中使用fakeAsync和tick的目的就是为了确保表单事件被触发。如果使用async和inject替代,就必须在事件被触发前运行所有代码。

现在我们已经修改了输入值,需要确保表单验证生效。(使用el变量)查询组件元素,寻找是错误信息的所有子元素,并确保错误信息已经显示。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 let msgs = el.querySelectorAll('.ui.error.message');

 expect(msgs[0].innerHTML).toContain('SKU is invalid');

 expect(msgs[1].innerHTML).toContain('SKU is required');

接下来依葫芦画瓢,不过这次在SKU字段输入一个值。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 input.value = 'XYZ';

 dispatchEvent(input, 'input');

 fixture.detectChanges();

 tick();

确保所有的错误信息消失。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 msgs = el.querySelectorAll('.ui.error.message');

 expect(msgs.length).toEqual(0);

最后,我们触发表单的提交事件。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 fixture.detectChanges();

 dispatchEvent(form, 'submit');

 tick();

最终,我们要确保在提交表单时,通过检查打印到控制台的日志信息来确定事件被触发。

code/forms/test/forms/demo_form_with_events_bad.spec.ts

 // checks for the form submitted message

 expect(fakeConsole.logs).toContain('you submitted value:XYZ');

我们可以继续为另外两个事件添加新的检验:SKU变更和表单变更。然而,我们的测试代码越来越冗长。

运行测试,可以看到测试通过,如图15-2所示。

图15-2 DemoFormWithEvents测试输出

测试本身没有问题,但我们在代码风格上闻到了一些坏味道:

●一个超长的it条件(超过5~10行);

●每个it中不止一两个expect;

●测试描述中使用了and一词。

15.10.5 重构表单测试

解决问题的第一步就是将创建组件、获取组件元素和用于输入和表单元素的代码从中抽取出来。

code/forms/test/forms/demo_form_with_events.spec.ts

 function createComponent():ComponentFixture<any> {

  let fixture = TestBed.createComponent(DemoFormWithEvents);

  el = fixture.debugElement.nativeElement;

  input = fixture.debugElement.query(By.css('input')).nativeElement;

  form = fixture.debugElement.query(By.css('form')).nativeElement;

  fixture.detectChanges();

  return fixture;

 }

createComponent代码相当简明:使用TestBed.createComponent创建组件,获取所有元素并调用detectChanges。

现在第一个要测试的是,提供一个空的SKU字段,我们应该看到两条错误信息。

code/forms/test/forms/demo_form_with_events.spec.ts

 it('displays errors with no sku', fakeAsync(()=> {

  let fixture = createComponent();

  input.value = '';

  dispatchEvent(input, 'input');

  fixture.detectChanges();

  // no value on sku field, all error messages are displayed

  let msgs = el.querySelectorAll('.ui.error.message');

  expect(msgs[0].innerHTML).toContain('SKU is invalid');

  expect(msgs[1].innerHTML).toContain('SKU is required');

 }));

如你所见,代码清晰了很多。测试很专注,而且只测试一件事。太棒了!

在新的结构中添加第二个测试也很简单。这次要测试的是,一旦给SKU字段赋值,错误信息就会消失。

code/forms/test/forms/demo_form_with_events.spec.ts

 it('displays no errors when sku has a value', fakeAsync(()=> {

  let fixture = createComponent();

  input.value = 'XYZ';

  dispatchEvent(input, 'input');

  fixture.detectChanges();

  let msgs = el.querySelectorAll('.ui.error.message');

  expect(msgs.length).toEqual(0);

 }));

你可能注意到了一点:到目前为止,我们的测试代码并没有使用fakeAsync,而是使用async和inject替代。

这次重构的另一个好处是,仅在当我们检查是否有信息发送到控制台时才使用fakeAsync和tick(),因为这正是由表单事件处理器负责的。

下一个测试恰恰是:当SKU值发生变更时,我们应该有一条信息发送到控制台。

code/forms/test/forms/demo_form_with_events.spec.ts

 it('handles sku value changes', fakeAsync(()=> {

  let fixture = createComponent();

  input.value = 'XYZ';

  dispatchEvent(input, 'input');

  tick();

  expect(fakeConsole.logs).toContain('sku changed to:XYZ');

 }));

对于表单变更进行同样的处理。

code/forms/test/forms/demo_form_with_events.spec.ts

 it('handles form changes', fakeAsync(()=> {

  let fixture = createComponent();

  input.value = 'XYZ';

  dispatchEvent(input, 'input');

  tick();

  expect(fakeConsole.logs).toContain('form changed to:[object Object]');

 }));

对于表单提交事件也进行同样的处理。

code/forms/test/forms/demo_form_with_events.spec.ts

 it('handles form submission', fakeAsync((tcb)=> {

  let fixture = createComponent();

  input.value = 'ABC';

  dispatchEvent(input, 'input');

  tick();

  fixture.detectChanges();

  dispatchEvent(form, 'submit');

  tick();

  expect(fakeConsole.logs).toContain('you submitted value:ABC');

 }));

再次运行测试,会得到更清晰的输出结果,如图15-3所示。

图15-3 重构后的DemoFormWithEvents测试输出

这次重构的另外一个好处是,出错时一眼就可以看出来。回到组件代码,在提交表单时更改消息,从而强制一个测试失败。

onSubmit(form:any):void {

 console.log('you have submitted the value:', form.sku);

}

如果运行之前版本的测试,可能看到如图15-4所示的结果。

图15-4 重构前的DemoFormWithEvents错误输出

它不会立即显示失败的原因所在。我们必须通过错误代号来明白提交的信息失败了。我们也不能肯定这是破坏组件的唯一事件,因为还可能有其他测试条件在遭遇失败时根本没有机会运行。

现在比较一下在重构过的代码上得到的错误信息,如图15-5所示。

图15-5 重构后的DemoFormWithEvents错误输出

这个版本很清晰,唯一失败的是表单提交事件。

15.11 测试HTTP请求

我们可以采用与之前相同的策略来测试HTTP交互:写一个Http类的模拟版本,因为它是一个外部依赖。

但是因为绝大多数使用Angular编写的单页面程序都是使用HTTP与API交互的,所以Angular测试类库已经提供了一个内置的替代品:MockBackend。

本章之前测试SpotifyService类时已经用到过这个类。

现在继续深入,见识一下更多的测试场景,也可以获得更好的编程实践。为了实现这个目的,我们为第6章的例子编写测试。

首先,一起看看如何测试不同的HTTP方法,如POST和DELETE,还有如何测试正要发送正确的HTTP头信息。

回到第6章,我们创建的这个实例包括了如何使用Http实现达到目的。

15.11.1 测试POST方法

第一个要写的测试是确保makePost方法发送一条正确的POST请求。

code/http/app/ts/components/MoreHTTPRequests.ts

 makePost():void {

  this.loading = true;

  this.http.post(

   'http://jsonplaceholder.typicode.com/posts',

   JSON.stringify({

    body:'bar',

    title:'foo',

    userId:1

   }))

   .subscribe((res:Response)=> {

    this.data = res.json();

    this.loading = false;

   });

 }

为这个方法编写测试时,目的是测试两点:

(1)请求方法(POST)是正确的;

(2)目标URL也是正确的。

下面把这个想法变成测试。

code/http/test/MoreHTTPRequests.spec.ts

 it('performs a POST',

  async(inject([MockBackend],(backend)=> {

   let fixture = TestBed.createComponent(MoreHTTPRequests);

   let comp = fixture.debugElement.componentInstance;

   backend.connections.subscribe(c => {

    expect(c.request.url)

     .toBe('http://jsonplaceholder.typicode.com/posts');

    expect(c.request.method).toBe(RequestMethod.Post);

    c.mockRespond(new Response(<any>ody:'{"response":"OK"'}));

   });

   comp.makePost();

   expect(comp.data).toEqual({'response':'OK'});

  }))

 );

注意,可以在backend.connections上调用subscribe方法。每当有新连接建立时就会触发我们的代码,这提供了检查请求内容的机会,并可以按预期设置响应结果。

这里,你也可以:

●添加请求断言语句,比如检查请求的URL和HTTP方法是否正确;

●设置模拟的响应结果,强制代码根据不同的测试场景作出不同的响应。

Angular使用一个名为RequestMethod的enum来判别不同的HTTP方法。这里是支持的方法。

export enum RequestMethod {

 Get,

 Post,

 Put,

 Delete,

 Options,

 Head,

 Patch

}

最后,在调用makePost()后,我们再次检查以确保预设的响应就是分配给组件的那个。

现在我们理解了其工作原理,针对DELETE方法增加一个测试并不难。

15.11.2 测试DELETE方法

这是makeDelete方法的具体实现。

code/http/app/ts/components/MoreHTTPRequests.ts

 makeDelete():void {

  this.loading = true;

  this.http.delete('http://jsonplaceholder.typicode.com/posts/1')

   .subscribe((res:Response)=> {

    this.data = res.json();

    this.loading = false;

   });

 }

这是我们用来测试它的代码。

code/http/test/MoreHTTPRequests.spec.ts

 it('performs a DELETE',

  async(inject([MockBackend],(backend)=> {

   let fixture = TestBed.createComponent(MoreHTTPRequests);

   let comp = fixture.debugElement.componentInstance;

   backend.connections.subscribe(c => {

    expect(c.request.url)

     .toBe('http://jsonplaceholder.typicode.com/posts/1');

    expect(c.request.method).toBe(RequestMethod.Delete);

    c.mockRespond(new Response(<any>ody:'{"response":"OK"'}));

   });

   comp.makeDelete();

   expect(comp.data).toEqual({'response':'OK'});

  }))

 );

除了URL和HTTP方法(这里使用RequestMethod.Delete)稍有不同,代码并无太大的差异。

15.11.3 测试HTTP头

针对这个类,最后一个要测试的方法是makeHeaders。

code/http/app/ts/components/MoreHTTPRequests.ts

 makeHeaders():void {

  let headers:Headers = new Headers();

  headers.append('X-API-TOKEN', 'ng-book');

  let opts:RequestOptions = new RequestOptions();

  opts.headers = headers;

  this.http.get('http://jsonplaceholder.typicode.com/posts/1', opts)

   .subscribe((res:Response)=> {

    this.data = res.json();

   });

 }

在本例中,我们的测试应该集中在确保X-API-TOKEN头被正确地设置为ng-book。

code/http/test/MoreHTTPRequests.spec.ts

 it('sends correct headers',

  async(inject([MockBackend],(backend)=> {

   let fixture = TestBed.createComponent(MoreHTTPRequests);

   let comp = fixture.debugElement.componentInstance;

   backend.connections.subscribe(c => {

    expect(c.request.url)

     .toBe('http://jsonplaceholder.typicode.com/posts/1');

    expect(c.request.headers.has('X-API-TOKEN')).toBeTruthy();

    expect(c.request.headers.get('X-API-TOKEN')).toEqual('ng-book');

    c.mockRespond(new Response(<any>ody:'{"response":"OK"'}));

   });

   comp.makeHeaders();

   expect(comp.data).toEqual({'response':'OK'});

  }))

 );

请求连接的request.headers属性会返回一个Headers实例。我们使用两个方法执行两个不同的断言:

●has方法检查指定的头是否已经设置,忽略其值;

●get方法返回设置的值。

如果只检查是否设置了头即可,使用has。如果需要检测其设置的值,要使用get。

到此为止,我们完成了Angular中不同HTTP方法和头的测试。现在转向一个更为复杂的例子,它与你在编写真实程序时遇到的场景非常接近。

15.11.4 测试YouTubeService

我们在第6章构建的另外一个实例是YouTube视频搜索。在这个实例中,HTTP交互过程包含在YouTubeService类中。

code/http/app/ts/components/YouTubeSearchComponent.ts

/**

* YouTubeService connects to the YouTube API

* See:* https://developers.google.com/youtube/v3/docs/search/list

*/

@Injectable()

export class YouTubeService {

 constructor(private http:Http,

       @Inject(YOUTUBE_API_KEY)private apiKey:string,

       @Inject(YOUTUBE_API_URL)private apiUrl:string){

 }

 search(query:string):Observable<SearchResult[]> {

  let params:string = [

   `q=${query}`,

   `key=${this.apiKey}`,

   `part=snippet`,

   `type=video`,

   `maxResults=10`

  ].join('&');

  let queryUrl:string = `${this.apiUrl}?${params}`;

  return this.http.get(queryUrl)

   .map((response:Response)=> {

    return(<any>response.json()).items.map(item => {

     // console.log("raw item", item); // uncomment if you want to debug

     return new SearchResult({

      id:item.id.videoId,

      title:item.snippet.title,

      description:item.snippet.description,

      thumbnailUrl:item.snippet.thumbnails.high.url

     });

    });

   });

 }

}

它利用YouTube API搜索视频,解析结果并保存到一个SearchResult实例中。

code/http/app/ts/components/YouTubeSearchComponent.ts

class SearchResult {

 id:string;

 title:string;

 description:string;

 thumbnailUrl:string;

 videoUrl:string;

 constructor(obj?:any){

  this.id       = obj && obj.id       || null;

  this.title      = obj && obj.title     || null;

  this.description   = obj && obj.description  || null;

  this.thumbnailUrl  = obj && obj.thumbnailUrl  || null;

  this.videoUrl    = obj && obj.videoUrl    ||

               `https://www.youtube.com/watch?v=${this.id}`;

 }

}

我们需要测试这个服务的几个重要方面:

●给定一个JSON响应,服务能够分析出视频的id、标题(title)、描述(description)和缩略图(thumbnail)属性;

●我们请求的URL使用了提供的搜索关键字;

●URL前缀设置在YOUTUBE_API_URL常量中;

●使用的API键值与YOUTUBE_API_KEY常量匹配。

记住这些,开始编写测试。

code/http/test/YouTubeSearchComponentBefore.spec.ts

 describe('MoreHTTPRequests(before)',()=> {

  beforeEach(()=> {

   TestBed.configureTestingModule({

    providers:[

     YouTubeService,

     BaseRequestOptions,

     MockBackend,

     { provide:YOUTUBE_API_KEY, useValue:'YOUTUBE_API_KEY' },

     { provide:YOUTUBE_API_URL, useValue:'YOUTUBE_API_URL' },

     { provide:Http,

      useFactory:(backend:ConnectionBackend,

             defaultOptions:BaseRequestOptions)=> {

              return new Http(backend, defaultOptions);

             }, deps:[MockBackend, BaseRequestOptions] }

    ]

  });

 });

和之前写测试的准备工作一样,首先配置依赖:这里我们使用真实的YouTubeService,但YOUTUBE_API_KEY和YOUTUBE_API_URL使用伪值。同时配置Http类使用一个MockBackend类。

现在,开始写第一个测试用例。

code/http/test/YouTubeSearchComponentBefore.spec.ts

 describe('search',()=> {

  it('parses YouTube response',

   inject([YouTubeService, MockBackend], fakeAsync((service, backend)=> {

    let res;

    backend.connections.subscribe(c => {

     c.mockRespond(new Response(<any>{

      body:`

      {

       "items":[

       {

        "id":{ "videoId":"VIDEO_ID" },

        "snippet":{

         "title":"TITLE",

         "description":"DESCRIPTION",

         "thumbnails":{

          "high":{ "url":"THUMBNAIL_URL" }

          }}}]}`

     }));

    });

    service.search('hey').subscribe(_res => {

     res = _res;

    });

    tick();

    let video = res[0];

    expect(video.id).toEqual('VIDEO_ID');

    expect(video.title).toEqual('TITLE');

    expect(video.description).toEqual('DESCRIPTION');

    expect(video.thumbnailUrl).toEqual('THUMBNAIL_URL');

   }))

  )

 });

这里我们通知Http返回一个伪响应结果,与调用真实URL时期望YouTube API返回响应结果的相关字段一致。这可以通过调用连接的mockRespond方法实现。

code/http/test/YouTubeSearchComponentBefore.spec.ts

    service.search('hey').subscribe(_res => {

     res = _res;

    });

    tick();

接下来调用我们要测试的方法:search。调用时使用关键字hey,并抓取响应结果保存在res变量中。

你之前可能注意到了,我们使用的是fakeAsync,它需要手动调用tick()来同步异步代码。这里沿用这种模式,期望搜索完成了执行过程,并且res已经保存了结果。

现在就可以验证结果值了。

code/http/test/YouTubeSearchComponentBefore.spec.ts

    let video = res[0];

    expect(video.id).toEqual('VIDEO_ID');

    expect(video.title).toEqual('TITLE');

    expect(video.description).toEqual('DESCRIPTION');

    expect(video.thumbnailUrl).toEqual('THUMBNAIL_URL');

从响应列表中读取第一个元素。我们已知它是SearchResult,现在要基于之前预设的响应结果检查每个属性设置正确无误:id、标题、描述和缩略图URL应该全部匹配。

至此,我们完成了写测试的第一个目标。然而,刚才不是说使用一个超大it的方法并使用太多的expect产生了代码坏味道吗?

的确是,所以在继续前进之前,先对代码进行重构,从而能更容易地使用单独的断言。

在describe('search', ……)内部添加以下辅助函数。

code/http/test/YouTubeSearchComponentAfter.spec.ts

  function search(term:string, response:any, callback){

   return inject([YouTubeService, MockBackend],

    fakeAsync((service, backend)=> {

     var req;

     var res;

     backend.connections.subscribe(c => {

      req = c.request;

      c.mockRespond(new Response(<any>ody:response));

     });

     service.search(term).subscribe(_res => {

      res = _res;

     });

     tick();

     callback(req, res);

    })

   )

  }

一起看看这个函数如何工作:它使用inject和fakeAsync完成了与之前同样的任务,不同的是它使用了一种配置的方式。我们提供了一个搜索关键字、一个响应和一个回调函数。有了这些参数,我们使用搜索关键字调用search方法,设置好伪响应结果,并在完成请求时调用回调函数,从而提供请求和响应对象。

使用这种方法,测试只需要调用这个函数并检查其中一个对象即可。

将之前写的测试拆分成四个测试,每个测试针对一种不同的响应结果。

code/http/test/YouTubeSearchComponentAfter.spec.ts

  it('parses YouTube video id', search('hey', response,(req, res)=> {

   let video = res[0];

   expect(video.id).toEqual('VIDEO_ID');

  }));

  it('parses YouTube video title', search('hey', response,(req, res)=> {

   let video = res[0];

   expect(video.title).toEqual('TITLE');

  }));

  it('parses YouTube video description', search('hey', response,(req, res)=>\

{

   let video = res[0];

   expect(video.description).toEqual('DESCRIPTION');

  }));

  it('parses YouTube video thumbnail', search('hey', response,(req, res)=> {

   let video = res[0];

   expect(video.description).toEqual('DESCRIPTION');

  }));

看起来不错吧?这个小而且专注的测试只有一个测试目的。太棒了!

现在为余下的目标添加测试代码应该很容易了。

code/http/test/YouTubeSearchComponentAfter.spec.ts

  it('sends the query', search('term', response,(req, res)=> {

   expect(req.url).toContain('q=term');

  }));

  it('sends the API key', search('term', response,(req, res)=> {

   expect(req.url).toContain('key=YOUTUBE_API_KEY');

  }));

  it('uses the provided YouTube URL', search('term', response,(req, res)=> {

   expect(req.url).toMatch(/^YOUTUBE_API_URL\?/);

  }));

你可以按照自己的想法随意加入更多的测试。比如,针对响应结果中包含多个条目并有不同的属性添加一个测试。看看代码中是否有你想进行测试的其他方面。

15.12 总结

Angular团队在为Angular提供测试功能方面做了大量的工作。这样我们才能轻松地测试应用程序的方方面面:从控制器到服务类、表单和HTTP。本来棘手的异步代码测试现在也不费吹灰之力。

  1. Red-Green-Refactor,是一种标准的测试驱动开发流程。——译者注
  2. https://angular.github.io/protractor/#/
  3. http://jasmine.github.io/2.4/introduction.html
  4. https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/rest_parameters
  5. https://nodejs.org/en/