
经过夜以继日的奋战,终于熬到可以对外发布的日子了。是时候让过去投入的大量精力和时间得到回报了。然而,传来的一个消息犹如晴天霹雳:一个致命的bug导致用户无法注册。
测试能够防患于未然,提升对程序的信心,也可以为新加入的开发人员提供指引。在软件开发领域,几乎没人质疑测试的作用。但是,人们在如何测试这个问题上一直争论不休。
一种方法是先写测试,再写实现过程,直至测试通过;另一种是已有实现代码,再写测试,验证代码是否正确。令人不解的是,二者的合理性常在开发社区中引发口水战。双方僵持不下,争论哪个才是正确的方法。
基于以往的经验,尤其是在严重依赖原型的情况下,我们将重点放在构建可测试代码上。我们发现,即使你的经历有所不同,但是在构建原型时,测试可能经常变更的代码片断会比让它运行起来耗费2~3倍的工作量。与此相反,我们在构建基于小型组件的应用程序时,将大量功能分解成不同的方法,从而测试整个蓝图的部分功能。这就是我们所说的可测试代码。
一种替代构建原型(后测试)的方法论便是所谓的“红色—绿色—重构”
。它的理念是要求你先写测试。运行测试会得到失败结果(红色),因为你还没有写任何实现的代码。只有在测试失败之后,才去写实现代码,直至所有测试通过(绿色)。
当然,测试什么取决于你和你的团队,而本章的重点在于讨论如何测试程序。
测试程序有两种主要方法:端对端测试和单元测试。
如果使用自上而下的方法进行测试,那么写测试时就将程序视为一个“黑盒”。与程序交互就如真实用户一样,从“旁观者”的角度评判程序是否达标。这种自上而下的测试技巧被称为端对端测试。
在Angular中,最常用的工具叫作Protractor
。Protractor能够打开浏览器与程序交互,收集测试结果,并检验测试结果与预期值是否相符。
第二种常用的测试方法是隔离程序的每个部件,在隔离环境中运行测试。这种测试形式叫作单元测试。
在单元测试中,所写的测试需要事先提供既定的输入值与相应的逻辑单元,检测输出结果,确定它是否与我们的预期结果匹配。
在本章中,我们将会探讨如何对Angular程序进行单元测试。
为了测试程序,我们将用到两种工具: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代码。
本节的重点是理解如何对一个Angular程序的各个部件进行单元测试。
我们将会学习如何测试服务、组件、HTTP请求等。同时,我们也会收获一些小技巧,让代码更容易测试。
Angular自身提供了一套基于Jasmine框架的辅助类,用以帮助我们编写单元测试。
主要的测试框架位于@angular/core/testing包中。(然而,为了测试组件,我们会用到 @angular/compiler/testing包和@angular/platform-browser/testing包中的一些辅助类。稍后具体介绍。)

如果这是你初次测试Angular程序,那么在为Angular写单元测试时,需要先完成一些必要的设置步骤。
例如,在需要注入依赖时,我们经常手动配置它们。在测试一个组件时,需要使用测试辅助类初始化它们。在测试路由时,还需要构建一些依赖。
设置有些繁琐,但不用太担心。一旦掌握,你就会发现从一个项目切换到另外一个项目,配置不会有多大变化。另外,本章也会指引你完成每一步。
和往常一样,可以在代码下载页面获取本章所有的源代码。用你喜欢的编辑器直接打开浏览,可以对本章涵盖的细节有一个大体的把握。我们建议你坚持参照代码来阅读本章。
我们在第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
服务类在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)断言方法返回值与预期值匹配。
下面把注意力转向那些消费服务的类:组件。
测试组件时,可以使用下列两种策略之一:
(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 }];
}
}
万事俱备,只欠东风。现在可以为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组件的测试。
为了演示为表单编写测试,我们使用在第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 ——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错误输出
这个版本很清晰,唯一失败的是表单提交事件。
我们可以采用与之前相同的策略来测试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\?/);
}));
你可以按照自己的想法随意加入更多的测试。比如,针对响应结果中包含多个条目并有不同的属性添加一个测试。看看代码中是否有你想进行测试的其他方面。
Angular团队在为Angular提供测试功能方面做了大量的工作。这样我们才能轻松地测试应用程序的方方面面:从控制器到服务类、表单和HTTP。本来棘手的异步代码测试现在也不费吹灰之力。