整合測試主要的目的,是要測試 Component 的 template 的動作是否能如我們所預期的方式運作,而這是單元測試無法涵的範圍
Angular 也貼心準備小幫手,TestBed
,來協助我們完成整合測試
TestBed
TestBed 是 Angular 提供的小幫手來建立測試用的 Module 環境,基本的用法如下
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 import { async , ComponentFixture , TestBed } from '@angular/core/testing' ;import { By } from '@angular/platform-browser' ;import { DebugElement } from '@angular/core' ;... let component : VoteComponent ;let fixture : ComponentFixture <VoteComponent >;let de : DebugElement ;let el : HTMLElement ;... beforeEach (async (() => { TestBed .configureTestingModule ({ declarations : [ VoteComponent ] }) .compileComponents (); })); beforeEach (() => { fixture = TestBed .createComponent (VoteComponent ); component = fixture.componentInstance ; fixture.detectChanges (); de = fixture.debugElement .query (By .css ('p' )); el = de.nativeElement ; });
configuratTestingModule
內的物件結構,與設定 @NgModule
是一樣的,在 beforeEach
內設定,可以確保每個測試案例不會受到其他測試案例結果的影響,當 TestingModule
設定完成後,可以透過 createComponent
的方式建立相對於 component 的 fixtureComponent
,這個 fixtureComponent
將提供完整的 component 本身與對應的 template 內容
這裡有一個要留意的事情是,第一個 beforeEach
有使用 async
這個關鍵字讓包在裡面的函式變成非同步的處理方式,當 component 的 template 是單獨一個檔案時,因為有 IO 的非同步行為,所以需要 async
的幫忙讓非同步變成同步行為的處理方式,但是如果是使用 webpack 作為建置工具時,其實是不需要使用 async
的,因為 webpack 會將獨立的 html 檔案變成 inline template 的模式
fixture.detectChanges()
是手動觸發 changeDetector 的方法,任何變數異動後要更新到 template 上時,都必須執行 detectChanges()
,當然,也可以設定自動偵測異動並執行更新動作,透過以下的設定即可達成
1 2 3 4 5 6 7 8 import { ComponentFixtureAutoDetect } from '@angular/core/testing' ;... TestBed .configureTestingModule ({ ... providers : [ { provide : ComponentFixtureAutoDetect , useValue : true } ] })
當這樣子設定完成後,之後的測試案例內,就不需要執行 fixture.detectChages()
了,但是請注意,預設的 detechChages()
只為在非同步的事件觸發時才會被執行,例如 promise resolution、timers 或是 DOM Events,上述行為不包含直接修改變數值,因為這是屬於同步的行為,在這情況下,還是得自行執行 fixture.detectChanges()
1 2 3 4 5 6 7 8 9 10 11 12 it ('should still see original title after comp.title change' , () => { const oldTitle = comp.title ; comp.title = 'Test Title' ; expect (el.textContent ).toContain (oldTitle); }); it ('should display updated title after detectChanges' , () => { comp.title = 'Test Title' ; fixture.detectChanges (); expect (el.textContent ).toContain (comp.title ); });
所以,手動控制 detectChanges 會比開啟自動偵測機制來的好,我們就不需要去考慮什麼時候要自己執行 detectChanges,什麼時候不用,反正多執行也不會造成問題
測試範例
property and class bindings
測試屬性 ( property ) 是一個很常見的測試情境,當一個變數值改變時,畫面上是否有正常顯示
1 2 3 4 5 6 7 8 9 10 it ('should render total votes' , () => { component.otherVotes = 20 ; component.myVote = 1 ; fixture.detectChanges (); de = fixture.debugElement .query (By .css ('.vote-count' )); el = de.nativeElement ; expect (el.innerText ).toContain ('21' ); });
1 <span class ="vote-count" > {{ totalVotes }}</span >
寫法上要注意的還是 fixture.detectChanges()
,記得要執行阿,不然測試會失敗
debugElement
本身有提供方法可以取得 classes
、style
、attributes
、properties
等資訊,在這個測試案例,我們要測試當 myVote == 1
時,是否有 hightlight
的 css class 產生,而 debugElement.classes 是一個 keyValue 形式的物件,測試 Class 是否有正常的運作,測試案例可以這樣子寫
1 <button class ="upVote" [class.highlight ]="myVote==1" (click )="upVote()" > +</button >
1 2 3 4 5 6 7 it ('should hightlight the upvote button is click' , () => { component.myVote = 1 ; fixture.detectChanges (); de = fixture.debugElement .query (By .css ('.upVote' )); expect (de.classes ['highlight' ]).toBeTruthy (); });
Event bindings
觸發事件的方法有兩種,一個是使用 debugElement 的 triggerEventHandler
,另外一種是使用 navtiveElement 轉型成 HTMLElement 後,操作 HTMLElement 的事件,這兩種方式都可以達到效果
1 2 3 4 5 6 it ('should click upVote and totalValue is 1' , () => { const button = fixture.debugElement .query (By .css ('.upVote' )); button.triggerEventHandler ('click' , null ); expect (component.totalVotes ).toBe (1 ); });
1 2 3 4 5 6 7 it ('should click upVote and totalValue is 1' , () => { de = fixture.debugElement .query (By .css ('.upVote' )); el = de.nativeElement ; el.click (); expect (component.totalVotes ).toBe (1 ); });
到這個階段,或許會有一個問題,這個跟直接觸發 component.upVote()
後在檢查 totalVotes
的結果有什麼差別呢? 整合測試是要確保 template 上的行為是可以正常執行的,有時候函式在單元測試內是測試成功的,但是 template 上會因為沒有正常實作而造成測試失敗,這也是單元測試與整合測試的差異了。
Dependencies
Providing the dependencies
一個 Component 通常都會注入其他的 service,在測試時又該怎麼處理呢? 回想看看 TestBed
的功能是什麼,是設定一個測試用的 module,既然是 module,providers 和 imports 的動作就跟平常在設定 @NgModules
的方式是一模一樣的
假設 TodosComponent 有注入 TodoService,TodoService 有注入 HttpClient 服務。
1 2 3 4 5 export class TodosComponent implements OnInit { todos = []; constructor (private service : TodoService ) {} ... }
測試檔案的內容於 beforeEach
的區塊,加上 imports 與 providers 兩個區塊,並將所需要的 service 與 modules 設定進去
1 2 3 4 5 6 7 8 9 10 11 ... beforeEach ( async (() => { TestBed .configureTestingModule ({ imports : [HttpClientModule ], declarations : [TodosComponent ], providers : [TodoService ] }).compileComponents (); }) ); ...
當這樣子設定完成後,providing service 的部分就已經完成了
Getting the dependencies
Angular 設定 provider 的地方有兩個,@NgModule
與 @Component
內都可以設定 providers,因為設定位置的不一樣,所以取得的方式也會有所不同
如果 service 是設定在 @NgModule
內時,取得 service 的方式如下
1 const service = TestBed .get (TodoService );
如果 service 是設定在 @Component
內時,取得 service 的方式如下
1 const service = fixture.debugElement .injector .get (TodoService );
在整合測試時,我們還是不希望依賴外部引用的 service ,這裡的處理方式會跟單元測試的方式一樣,透過 spyOn
的方式控制 service 的行為
providing stubs
有時候 component 所使用的 service 會遇到測試困難,例如路由。有時為了簡化測試的複雜度,會使用 stubs
的手法簡化,與其使用真的 service,不如自己建立一個簡單又符合目前所需的 service class 即可,也感謝 Angular 的 DI 機制,讓這一切變簡單了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export class TodosComponent implements OnInit { constructor (private router : Router , private route : ActivatedRoute ) {} ngOnInit ( ) { this .route .params .subscribe (params => { if (params['id' ] === 0 ) { this .router .navigate (['not-found' ]); } }); } save ( ) { this .router .navigate (['/dash' ]); } }
Router 本身的功能很複雜,要測試的項目又很多,所以簡化的方式就是建立一個 RouterStub class 替換真的 Router
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class RouterStub { navigate (params ) {} } class ActivatedRouteStub { params : Observable <any > = Observable .empty (); } ... beforeEach ( async (() => { TestBed .configureTestingModule ({ imports : [HttpClientModule ], declarations : [TodosComponent ], providers : [ { provide : Router , useClass : RouterStub }, { provide : ActivatedRoute , useClass : ActivatedRouteStub } ] }).compileComponents (); }) );
這樣子的手法就可以大大的簡化測試的難度,這手法適用於其他第三方套件情境
Route
Navigation
由於在上一小節將 Router 與 ActivatedRoute 都用假的 class 替換掉了,所以這裡的測試就變簡單了
測試當某動作完成後,是否有正確的呼叫 router.navigate 函式,可以使用 toHaveBeenCalledWith
的方法來檢查
1 2 3 4 5 6 7 8 9 it ('should redirect user to dash page' , () => { const router = TestBed .get (Router ); const spy = spyOn (router, 'navigate' ); component.save (); expect (spy).toHaveBeenCalledWith (['dash' ]); });
Parameters
測試路由參數的方式跟測試路由轉換的方式很類似,但還是要稍微修改一下 ActiveatedRouteStub 的內容,我們必須建立一個方法可以讓外部使用者將要設定路由參數傳入,修改如下
1 2 3 4 5 6 7 8 9 10 class ActivatedRouteStub { private subject = new Subject (); get params () { return this .subject .asObservable (); } push (value ) { this .subject .next (value); } }
透過 RxJS Subject 的特性,可以很簡單的完成這個 params.subscribe
的功能,接下來就是測試在 ngOnInit
內的功能是否正常
1 2 3 4 5 6 7 ngOnInit ( ) { this .route .params .subscribe (params => { if (params['id' ] === 0 ) { this .router .navigate (['not-found' ]); } }); }
當路由參數 id 是 0 時,會轉址到 not-found
的頁面
1 2 3 4 5 6 7 8 9 it ('should redirect user to NotFound page' , () => { const router = TestBed .get (Router ); const spy = spyOn (router, 'navigate' ); const route : ActivatedRouteStub = TestBed .get (ActivatedRoute ); route.push ({ id : 0 }); expect (spy).toHaveBeenCalledWith (['not-found' ]); });
RouterOutlet components
<router-outlet></router-outlet>
是搭配路由設定顯示 Component 的標籤,一但沒有這個就無法正常地顯示 component 內容,那要怎麼確保這個標籤不會被誤刪呢? 就是寫個測試來保護他
1 2 3 4 5 6 import { RouterOutlet } from '@angular/router' ;... it ('should have a route-outlet tag' , () => { const de = fixture.debugElement .query (By .directive (RouterOutlet )); expect (de).not .toBeNull (); });
除了 <router-outlet>
外,也會有 routerLink
做頁面連結的入口,測試 routerLink
的方法有幾種,這裡用最簡單的方式作為範例,稍微複雜一點的是寫一個 RouterLinkStubDirective
來替換內建的 RouterLinkDirective
1 2 3 4 5 6 7 8 it ('should have todos link' , () => { const de = fixture.debugElement .queryAll (By .directive (RouterLinkWithHref )); const idx = de.findIndex ( element => element.properties ['href' ] === '/todos' ); expect (idx).toBeGreaterThan (-1 ); });
Shallow component
當一個 Component 內有使用到其他的 component 時,因為其他的 component 並不是我們所在乎的重點,所以在 TestingModule 內的 declarations 不應該註冊其他的 component,但是,這樣子會發生錯誤,Angular 會抱怨說有些 tag element 他看不懂,這時候就需要在 TestingModule 內加上 schemas: [NO_ERRORS_SCHEMA]
來避免錯誤訊息
1 2 3 4 5 6 7 8 9 beforeEach ( async (() => { TestBed .configureTestingModule ({ imports : [RouterTestingModule ], declarations : [AppComponent ], schemas : [NO_ERRORS_SCHEMA ] }).compileComponents (); }) );
Attribute directives
測試 attribute directive 時,建立一個空的 host component 用來測試 directive
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 31 32 33 34 35 36 37 38 39 40 @Component ({ selector : 'app-host-comp' , template : '' }) class HostComponent {}describe ('HighlightDirective' , () => { let fixture : ComponentFixture <HostComponent >; beforeEach ( async (() => { TestBed .configureTestingModule ({ declarations : [HostComponent , HighlightDirective ] }); }) ); function createComponent ( ) { fixture = TestBed .createComponent (HostComponent ); fixture.detectChanges (); } it ('should highlight with cyan' , () => { TestBed .overrideComponent (HostComponent , { set : { template : `<p highlight="cyan">empty</p>` } }); createComponent (); const de = fixture.debugElement .query (By .css ('p' )); expect (de.nativeElement .style .backgroundColor ).toBe ('cyan' ); }); it ('should highlight with yellow' , () => { TestBed .overrideComponent (HostComponent , { set : { template : `<p highlight>empty</p>` } }); createComponent (); const de = fixture.debugElement .query (By .css ('p' )); expect (de.nativeElement .style .backgroundColor ).toBe ('yellow' ); }); });
利用 overrideComponent
的方法來改變 TestBed 內某 component template 的設定,利用這樣子的方式就可以測試 attribute directive 了
Asynchronous operations
遇到 promise 的非同步行為時,又該怎麼測試呢? Angular 有兩種方式測試非同步行為
這個是用來做測試範例的 component
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { Component } from '@angular/core' ;import { QuoteService } from './quote.service' ;@Component ({ selector : 'my-quote' , template : '<h3>Random Quote</h3> <div>{{quote}}</div>' }) export class QuoteComponent { quote : string ; constructor (private quoteService : QuoteService ){}; getQuote ( ) { this .quoteService .getQuote ().then ((quote ) => { this .quote = quote; }); }; }
async + whenStable
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 import { QuoteService } from './quote.service' ;import { QuoteComponent } from './quote.component' ;import { provide } from '@angular/core' ;import { async , TestBed , fakeAsync, tick } from '@angular/core/testing' ;class MockQuoteService { public quote : string = 'Test quote' ; getQuote ( ) { return Promise .resolve (this .quote ); } } describe ('Testing Quote Component' , () => { let fixture; beforeEach (() => { ... }); it ('Should get quote' , async (() => { fixture.componentInstance .getQuote (); fixture.detectChanges (); fixture.whenStable ().then (()=> { const compiled = fixture.debugElement .nativeElement ; expect (compiled.querySelector ('div' ).innerText ).toEqual ('Test quote' ); }) })); });
fakeAsync + tick
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 import { QuoteService } from './quote.service' ;import { QuoteComponent } from './quote.component' ;import { provide } from '@angular/core' ;import { async , TestBed , fakeAsync, tick } from '@angular/core/testing' ;class MockQuoteService { public quote : string = 'Test quote' ; getQuote ( ) { return Promise .resolve (this .quote ); } } describe ('Testing Quote Component' , () => { let fixture; beforeEach (() => { ... }); it ('Should get quote' , fakeAsync (() => { fixture.componentInstance .getQuote (); tick (); fixture.detectChanges (); const compiled = fixture.debugElement .nativeElement ; expect (compiled.querySelector ('div' ).innerText ).toEqual ('Test quote' ); })); });
fakeAsync
搭配 tick()
方法使用,我們可以控制時間的變化,將非同步的行為轉換成同步行為進行測試。
延伸測試應用,當我們有一個函式的功能是每分鐘會觸發一次動作,在測試的過程中,當然不可能等 1 分鐘後才知道測試結果,使用 tick(ms)
就可以讓時間快轉了
1 2 3 4 5 6 7 8 9 10 11 describe ('this test' , () => { it ('looks async but is synchronous' , <any >fakeAsync ((): void => { let flag = false ; setTimeout (() => { flag = true ; }, 100 ); expect (flag).toBe (false ); tick (50 ); expect (flag).toBe (false ); tick (50 ); expect (flag).toBe (true ); })); });
總結
單元測試與整合測試是互相支援的,不能只單獨測試一種,因為某些情況下,還是得依靠整合測試才能覆蓋所有的可能性,但至於哪些要寫單元測試,哪些要補整合測試,因為每個人測試的手感不同,很難有一個統一的規則在,至少我是這樣子認為的,每人有自己一套對於測試的理解方式,這就留給各位去探索了。