背景
软件工程上的一个很重要的考量就是,如何进行单元测试.
无论是不是TDD,执行自动化的测试都是非常重要的.因此看看Angular的测试.
前提知识: 建议先了解 Jasmine
和 Karma
.
环境相关
Angular使用 Jasmine
测试框架,并使用 karma
测试运行器,
默认脚手架已经搭建好.运行命令 ng test
测试即可.
有关的配置文件有
karma.conf.js
src/test.ts
脚手架会生成许多 *.spec.ts
文件,是测试用的文件
一般放置测试文件有两条约定
单元测试放在要测试的文件旁边
集成测试等跨文件夹和模块的测试由于不属于任何一个部分,可以放在 tests
目录下
代码覆盖率
测试时使用
1 ng test --no-watch --code-coverage
则会给出代码覆盖率的结果,
然后会在项目下生成 /coverage/project-name
文件夹,再往里则是分门别类的测试结果.
或者也可以在配置文件中要求 ng test
时默认做覆盖率测试
1 2 3 4 5 6 "test" : { "options" : { "codeCoverage" : true } }
对于最低覆盖率的配置可以在 karma.conf.js
文件中配置
Angular提供了什么
使用jasmine测试框架
涉及依赖注入的地方使用 TestBed
来注入
但这不意味着注入一个真正的服务,因为服务里发生的错误,一般是妨碍当前组件的测试的.
服务的测试
通常服务是最容易测试的部分.
没有依赖的服务
一些没有依赖的服务,测试起来尤为简单
1 2 3 4 5 6 7 8 9 10 11 12 describe('ValueService' , () => { let service: ValueService; beforeEach(() => { service = new ValueService(); }); it('#getValue should return real value' , () => { expect(service.getValue()).toBe('real value' ); }); });
有依赖的服务
常见地,服务之间会有依赖,比如一个 MasterService
依赖 ValueService
1 2 3 4 5 @Injectable ()export class MasterService { constructor (private valueService: ValueService ) { } getValue ( ) { return this .valueService.getValue(); } }
测试时一个比较笨的方法是手动初始化两个服务,拼接起来
1 2 3 4 5 6 7 8 9 describe('MasterService without Angular testing support' , () => { let masterService: MasterService; it('#getValue should return real value from the real service' , () => { masterService = new MasterService(new ValueService()); expect(masterService.getValue()).toBe('real value' ); }); });
这样做两个缺点
有时依赖非常复杂
ValueService中的错误导致MasterService不能用于测试
对应的解决方法是
一方面做一个假的 ValueService
另一方面使用更合理的方式进行依赖的管理
此处要介绍Angular官方的 TestBed
服务
TestBed发挥类似NgModule的作用,
使用providers等关键字来定义提供服务的类,
然后使用依赖注入的方式注入服务.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 let masterService: MasterService;let valueServiceSpy: jasmine.SpyObj<ValueService>;beforeEach(() => { const spy = jasmine.createSpyObj('ValueService' , ['getValue' ]); TestBed.configureTestingModule({ providers: [ MasterService, { provide : ValueService, useValue : spy } ] }); masterService = TestBed.inject(MasterService); valueServiceSpy = TestBed.inject(ValueService) as jasmine.SpyObj<ValueService>; }); it('#getValue should return stubbed value from a spy' , () => { const stubValue = 'stub value' ; valueServiceSpy.getValue.and.returnValue(stubValue); expect(masterService.getValue()) .toBe(stubValue, 'service returned stub value' ); });
如果不喜欢使用beforeEach
有些人不喜欢beforeEach(),(可能是因为这样不能给刚入门的人表达这段代码的意图)
认为使用另一种基于函数的方式更好
1 2 3 4 5 6 7 8 9 function setup ( ) { return {masterService, valueServiceSpy, stubValue}; } it('新的测试中使用setup' , () => { const {masterService, valueServiceSpy, stubValue} = setup(); })
组件的测试
组件类本身的一些逻辑可以看作和service的测试,没有太大区别,
但不要忘了组件的DOM也是需要测试的,比如
等等则需要使用专门的API来获取组件的元素,继而获取元素的内容来进行测试
组件本身(ts文件相关)
测试时与使用时不同的是,
组件本身的一些原本需要用户触发的函数可以直接调用了
比如click事件
再比如使用@Input传入的参数
需要考虑生命周期
假如要测试的组件长这样
1 2 3 4 5 6 export class DashboardHeroComponent { @Input () hero: Hero; isOn = false ; clicked ( ) { this .isOn = !this .isOn; } get message () { return `The light is ${this .isOn ? 'On' : 'Off' } ` ; } }
测试时可以使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 describe('LightswitchComp' , () => { it('#clicked() should toggle #isOn' , () => { const comp = new LightswitchComponent(); expect(comp.isOn).toBe(false , 'off at first' ); comp.clicked(); expect(comp.isOn).toBe(true , 'on after click' ); }); it('其他的测试' , () => { const comp = new DashboardHeroComponent(); const hero: Hero = {id : 42 , name : 'Test' }; comp.hero = hero; }) });
组件的创建方式也可以
直接使用new
如果有service的依赖,可以使用TestBed注入
比如一个依赖UserService的组件
1 2 3 4 5 6 7 8 9 export class WelcomeComponent implements OnInit { welcome: string ; constructor (private userService: UserService ) { } ngOnInit(): void { this .welcome = this .userService.isLoggedIn ? 'Welcome, ' + this .userService.user.name : 'Please log in.' ; } }
测试时,一面可以制作一个假的替身
1 2 3 4 class MockUserService { isLoggedIn = true ; user = { name : 'Test User' }; }
另一面需要使用TestBed来注入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 beforeEach(() => { TestBed.configureTestingModule({ providers: [ WelcomeComponent, { provide : UserService, useClass : MockUserService } ] }); comp = TestBed.inject(WelcomeComponent); userService = TestBed.inject(UserService); }); it('should ask user to log in if not logged in after ngOnInit' , () => { comp.ngOnInit(); expect(comp.welcome).toContain(userService.user.name); expect(comp.welcome).toContain('log in' ); });
组件的DOM
为了测试html中的某个元素,
首先需要让测试代码渲染一个假想中存在的html页面,
然后为了获取页面上的某个元素,angular建立了一个fixture概念,
比如使用 fixture.nativeElement.querySelector('p').textContent
的方法来获取页面上的一个 <p>
的文字内容.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 describe('BannerComponent (with beforeEach)' , () => { let component: BannerComponent; let fixture: ComponentFixture<BannerComponent>; beforeEach(() => { TestBed.configureTestingModule({declarations : [BannerComponent]}); fixture = TestBed.createComponent(BannerComponent); component = fixture.componentInstance; }); it('should create' , () => { expect(component).toBeDefined(); }); it('should have <p> with "banner works!"' , () => { const bannerElement: HTMLElement = fixture.nativeElement; const p = bannerElement.querySelector('p' ); expect(p.textContent).toEqual('banner works!' ); }); });
另外补充,事实上 fixture.nativeElement
是 fixture.debugElement.nativeElement
的快捷方式.
1 2 const bannerDe: DebugElement = fixture.debugElement;const bannerEl: HTMLElement = bannerDe.nativeElement;
因为有些平台不是浏览器,或许不支持 HTMLElement
类,
只能先获取 DebugElement
类,然后让该类中继一下.
这里预计使用平台就是浏览器,直接将 DebugElement
类整个直接转成 HTMLElement
了.
非浏览器的平台上,可能不能直接在 HTMLElement
类上使用 querySelector
方法.
而是需要先在 DebugElement
类上使用 query(By.css('p'))
之类的方法来获取对象,
然后再转成 HTMLElement
.
1 2 3 4 5 6 it('should find the <p> with fixture.debugElement.query(By.css)' , () => { const bannerDe: DebugElement = fixture.debugElement; const paragraphDe = bannerDe.query(By.css('p' )); const p: HTMLElement = paragraphDe.nativeElement; expect(p.textContent).toEqual('banner works!' ); });
TODO 组件测试高级
需要手动检测变化
Angular是双向绑定框架,组件中的变量变化后,
不能立即反映在html中,需要手动检测变化
1 2 3 4 5 it('should display a different test title' , () => { component.title = 'Test Title' ; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title' ); });
一定程度上可以自动检测变化
1 2 3 4 5 6 7 8 import { ComponentFixtureAutoDetect } from '@angular/core/testing' ;TestBed.configureTestingModule({ providers: [ { provide : ComponentFixtureAutoDetect, useValue : true } ] });
限制在于该自动检测变化只能检测异步活动,比如Observable,Promise,DOM事件,
而不能对同步活动做响应(比如手动使用测试代码更新了属性的值),
此时需要手动调用 fixture.detectChanges()
事实上许多人为了保证一定调用了 detectChanges
, 喜欢频繁手动调用,因为并没有损害.
输入框需要先发射事件
页面上的一个input框输入了内容,往往后台会收到,
但在测试时,仅仅使用 detectChanges
, 仅仅能读取到input里面的初始值,
若要得到输入到input中的值,则需要先使用 dispatchEvent
以通知input内容有变.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 it('should convert hero name to Title Case' , () => { const hostElement = fixture.nativeElement; const nameInput: HTMLInputElement = hostElement.querySelector('input' ); const nameDisplay: HTMLElement = hostElement.querySelector('span' ); nameInput.value = 'quick BROWN fOx' ; nameInput.dispatchEvent(new Event('input' )); fixture.detectChanges(); expect(nameDisplay.textContent).toBe('Quick Brown Fox' ); });
依赖服务的注入和服务的测试中一样
组件 WelcomeComponent
依赖 UserService
的话
一方面可以制作一个假的服务,比如 userServiceStub
然后需要在 providers
里面带上这个服务
最后就能正常使用组件了,如果想获取服务,有两种办法
直接从TestBed获取
从fixture获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 let userServiceStub: Partial<UserService>;beforeEach(() => { userServiceStub = { isLoggedIn: true , user: { name : 'Test User' }, }; TestBed.configureTestingModule({ declarations: [ WelcomeComponent ], providers: [ { provide : UserService, useValue : userServiceStub } ], }); fixture = TestBed.createComponent(WelcomeComponent); comp = fixture.componentInstance; userService = TestBed.inject(UserService); userService = fixture.debugElement.injector.get(UserService); el = fixture.nativeElement.querySelector('.welcome' ); });
元素的点击
浏览器平台和非浏览器平台的点击事件实现起来很不一样
浏览器平台
非浏览器平台
1 heroDe.triggerEventHandler('click' , null );
为此,可以写一个工具函数来帮忙
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export const ButtonClickEvents = { left: { button : 0 }, right: { button : 2 } }; export function click (el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left ): void { if (el instanceof HTMLElement) { el.click(); } else { el.triggerEventHandler('click' , eventObj); } }
使用时就能变成
1 2 click(heroEl); click(heroDe);
TODO 路由
TODO 带有routerlink
调用compileComponents
许多组件是引用外部定义的模板和css文件的,
如果使用 ng test
来运行测试,那么大可不必担心,
但如果使用的是Web IDE,则需要使用 compileComponents
提醒它,查找并编译有关联的文件.
1 2 3 4 5 6 7 beforeEach(waitForAsync(() => { TestBed .configureTestingModule({ declarations: [BannerComponent], }) .compileComponents(); }));
当然这个代码在使用 ng test
测试时也是无害的.
异步过程的测试
许多被测试的代码是异步的,比如某组件的一个向远程请求的异步过程如下
1 2 3 4 5 6 7 8 9 10 11 12 13 getQuote ( ) { this .errorMessage = '' ; this .quote = this .twainService.getQuote().pipe( startWith('...' ), catchError( (err: any ) => { setTimeout (() => this .errorMessage = err.message || err.toString()); return of ('...' ); }) ); }
通常服务才关心返回的是什么值,而组件比较关心页面上显示什么.
对于制作数据,有一些方法
特别简单的return(of())方法,只能测试正常结果,对setTimeout等代码无能为力
return(defer())方法,可以延迟一个js引擎周期,数据的返回显得更真实一些.
return(throwError())返回一个错误,用于测试一些异常
测试方面,通常有4种方法,
简单的同步测试方法,不能模拟时间流逝,最多测一些正常的结果
fakeAsync中使用tick与detectChanges搭配,
既能测试setTimeout,又能模拟经过一段时间后正常的数据才返回来.
如果有XHR调用,则不得不使用waitForAsync,
不再能够使用同步风格的tick,功能上可以用whenStable来代替
jasmine自己的done方法
其中fakeAsync既能测试正常,又能测试异常,个人认为最适合使用.
现在假设已经有一个SpyObj,暂定返回数据的方式是return(of())
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 beforeEach(() => { testQuote = 'Test Quote' ; const twainService = jasmine.createSpyObj('TwainService' , ['getQuote' ]); getQuoteSpy = twainService.getQuote.and.returnValue(of (testQuote)); TestBed.configureTestingModule({ declarations: [TwainComponent], providers: [{provide : TwainService, useValue : twainService}] }); fixture = TestBed.createComponent(TwainComponent); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain' ); });
最普通的同步方法
1 2 3 4 5 6 7 it('should show quote after component initialized' , () => { fixture.detectChanges(); expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).toBe(true , 'getQuote called' ); });
fakeAsync方法
测试异常结果
准备数据方面,使用的是return(throwError())
1 2 3 4 5 6 7 8 9 10 11 it('should display error when TwainService fails' , fakeAsync(() => { getQuoteSpy.and.returnValue(throwError('TwainService test failure' )); fixture.detectChanges(); tick(); fixture.detectChanges(); expect(errorMessage()).toMatch(/test failure/ , 'should display error' ); expect(quoteEl.textContent).toBe('...' , 'should show placeholder' ); }));
测试正常结果
准备数据方面,可以使用return(defer()),具体使用方法如下
1 2 3 4 5 6 7 8 9 10 11 12 export function asyncData <T >(data: T ) { return defer(() => Promise .resolve(data)); } export function asyncError <T >(errorObject: any ) { return defer(() => Promise .reject(errorObject)); } getQuoteSpy.and.returnValue(asyncData(testQuote));
测试中会使用tick和detectChanges模拟过了一段时间数据才返回,
因此可以测试一些placeholder
1 2 3 4 5 6 7 8 9 10 it('should show quote after getQuote (fakeAsync)' , fakeAsync(() => { fixture.detectChanges(); expect(quoteEl.textContent).toBe('...' , 'should show placeholder' ); tick(); fixture.detectChanges(); expect(quoteEl.textContent).toBe(testQuote, 'should show quote' ); expect(errorMessage()).toBeNull('should not show error' ); }));
tick
tick可以接收参数,默认似乎是一个js引擎周期?
1 2 3 4 5 6 7 8 it('should run timeout callback with delay after call tick with millis' , fakeAsync(() => { let called = false ; setTimeout (() => { called = true ; }, 100 ); tick(100 ); expect(called).toBe(true ); }));
waitForAsync方法
道理上和fakeAsync方法也是相同的,不过没有了tick.
1 2 3 4 5 6 7 8 9 it('should show quote after getQuote (async)' , waitForAsync(() => { fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).toBeNull('should not show error' ); }); }));
done方法
fakeAsync和waitForAsync都是jasmine原本的done方法的改进版.
如果用done来写可能会有些麻烦.
并且从官方例子中看不出来用done的意义是什么.
1 2 3 4 5 6 7 8 9 10 11 12 13 it('should show last quote (quote done)' , (done: DoneFn ) => { fixture.detectChanges(); component.quote.pipe(last()).subscribe(() => { fixture.detectChanges(); expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).toBeNull('should not show error' ); done(); }); });
指令的测试
比如一个指令,用于设置一个html元素高亮,并接收一个参数作为色彩
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Directive ({ selector : '[highlight]' })export class HighlightDirective implements OnChanges { defaultColor = 'rgb(211, 211, 211)' ; @Input ('highlight' ) bgColor: string ; constructor (private el: ElementRef ) { el.nativeElement.style.customProperty = true ; } ngOnChanges ( ) { this .el.nativeElement.style.backgroundColor = this .bgColor || this .defaultColor; } }
在模板中使用的方式如下
1 2 3 4 5 6 7 8 9 import { Component } from '@angular/core' ;@Component ({ template: ` <h2 highlight="skyblue">About</h2> <h3>Quote of the day:</h3> <twain-quote></twain-quote> ` }) export class AboutComponent { }
与其他测试有些不同的地方就是TestBed要多设置一下,
其他地方没有本质不同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 beforeEach(() => { fixture = TestBed.configureTestingModule({ declarations: [ AboutComponent, HighlightDirective ], schemas: [ NO_ERRORS_SCHEMA ] }) .createComponent(AboutComponent); fixture.detectChanges(); }); it('should have skyblue <h2>' , () => { const h2: HTMLElement = fixture.nativeElement.querySelector('h2' ); const bgColor = h2.style.backgroundColor; expect(bgColor).toBe('skyblue' ); });
管道的测试
假如有一个管道,能够将一句话中的所有单词变成:
首位大写,其余小写.
1 2 3 4 5 6 7 @Pipe ({name : 'titlecase' , pure : true })export class TitleCasePipe implements PipeTransform { transform(input: string ): string { return input.length === 0 ? '' : input.replace(/\w\S*/g , (txt => txt[0 ].toUpperCase() + txt.substr(1 ).toLowerCase() )); } }
一方面可以直接使用简单的输入/输出测试
1 2 3 4 5 6 7 8 9 10 11 12 13 describe('TitleCasePipe' , () => { const pipe = new TitleCasePipe(); it('transforms "abc" to "Abc"' , () => { expect(pipe.transform('abc' )).toBe('Abc' ); }); it('transforms "abc def" to "Abc Def"' , () => { expect(pipe.transform('abc def' )).toBe('Abc Def' ); }); });
不过也可以使用DOM元素测试最终的效果
提前假设有这么一个页面
一个input可以让用户输入词语
一个span显示用户输入的,经过管道格式化的结果
1 2 3 4 5 6 7 8 9 10 11 12 13 it('should convert hero name to Title Case' , () => { const hostElement = fixture.nativeElement; const nameInput: HTMLInputElement = hostElement.querySelector('input' ); const nameDisplay: HTMLElement = hostElement.querySelector('span' ); nameInput.value = 'quick BROWN fOx' ; nameInput.dispatchEvent(new Event('input' )); fixture.detectChanges(); expect(nameDisplay.textContent).toBe('Quick Brown Fox' ); });
参考
官方文档