如何在 Angular 對 Service 單元測試 ?
凡與顯示相關邏輯,我們會寫在 component;凡與資料相關邏輯,我們會寫在 service。而 service 最常見的應用,就是透過 HttpClient
存取 API。
對於 service 單元測試而言,我們必須對 HttpClient
加以隔離;而對 component 單元測試而言,我們必須對 service
加以隔離,我們該如何對 service 與 component 進行單元測試呢 ?
Version
Angular CLI 1.6.2
Node.js 8.9.4
Angular 5.2.2
User Story
- Header 會顯示
Welcome to app!
- 程式一開始會顯示
所有 post
- 按
Add Post
會呼叫POST
API新增 post
- 按
List Posts
會呼叫GET
API 回傳所有 post
Task
- 目前有
PostService
使用HttpClient
存取 API,為了對PostService
做單元測試
,必須對HttpClient
加以隔離 - 目前有
AppComponent
使用PostService
, 為了對AppComponent
做單元測試
,必須對PostService
加以隔離
Architecture
AppComponent
負責新增 post
與顯示 post
的介面顯示;而PostService
負責 API 的串接- 根據
依賴反轉原則
,AppComponent
不應該直接相依於PostService
,而是兩者相依於 interface - 根據
介面隔離原則
,AppComponent
只相依於它所需要的 interface,因此以AppComponent
的角度訂出PostInterface
,且PostService
必須實作此 interface - 因為
AppComponent
與PostService
都相依於PostInterface
,兩者都只知道PostInterface
而已,而不知道彼此,因此AppComponent
與PostService
徹底解耦合 - 透過 DI 將實作
PostInterface
的PostService
注入到AppComponent
,且將HttpClient
注入到PostService
Implementation
AppComponent
與 PostService
的實作並非本文的重點,本文的重點在於實作 AppComponent
與 PostService
的 單元測試
部分。
PostService
post.service.spec.ts
1 | import { TestBed } from '@angular/core/testing'; |
14 行
1 | TestBed.configureTestingModule({ |
Angular 有 module 觀念,若使用到了其他 module,必須在 imports
設定;若使用到 DI,則必須在 providers
設定。
若只有一個 module,則在 AppModule
設定。
但是單元測試時,並沒有使用 AppModule
的設定,因為我們可能在測試時使用其他替代 module,也可能自己實作 fake 另外 DI。
Angular 提供了 TestBed.configureTestingModule()
,讓我們另外設定跑測試時的 imports
與 providers
部分。
15 行
1 | imports: [ |
原本 HttpClient
使用的是 HttpClientModule
,這會使得 HttpClient
真的透過網路去打 API,這就不符合單元測試 隔離
的要求,因此 Angular 另外提供 HttpClientTestingModule
取代 HttpClientModule
。
18 行
1 | providers: [ |
由於我們要測試的就是 PostService
,因此 PostService
也必須由 DI 幫我們建立。
但因爲 PostService
是基於 PostInterface
建立,因此必須透過 PostInterfaceToken
mapping 到 PostService
。
23 行
1 | postService = TestBed.get(PostInterfaceToken, PostService); |
由 providers
設定好 interface 與 class 的 mapping 關係後,我們必須透過 DI 建立 postService
與 mockHttpClient
。
其中 HttpTestingController
相當於 mock 版的 HttpClient
,因此取名為 mockHttpClient
。
TestBed.get() 其實相當於
new
,只是這是藉由 DI 幫我們new
而已
31 行
1 | it(`should list all posts`, () => { |
先談談如何測試 GET
。
3A 原則的 arrange
習慣都會寫在最前面,但在 service 的單元測試時,arrange
必須寫在最後面,否則會執行錯誤,稍後會解釋原因。
32 行
1 | /** act */ |
直接對 PostService.listPost$()
測試,由於 listPost$()
回傳 Observable
,因此 expect()
必須寫在 subscribe()
內。
將預期的測試結果寫在 expected
內。
一般
Observable
會在subscribe()
後執行,不過在HttpTestingController
的設計裡,subscribe()
會在flush()
才執行,稍後會看到flush()
,所以此時並還沒有執行expect()
測試
46 行
1 | /** arrange */ |
之前已經使用 HttpClientTestingModule
取代 HttpClient
,HttpTestingController
取代 HttpClient
,這只能確保呼叫 API 時不用透過網路。
還要透過 expectOne()
設定要 mock 的 URI 與 action,之所以取名為 expectOne()
,就是期望有人真的呼叫這個 URI 一次
,且必須為 GET
,若沒有呼叫這個 URI 或者不是 GET
,將造成單元測試 紅燈
。
這也是為什麼 HttpTestingController
的設計是 act
與 assert
要先寫,最後再寫 arrange
,因為 HttpTestingController
本身也有 assert
功能,必須有 act
,才能知道 assert
URI 與 GET 有沒有錯誤。
最後使用 flush()
設定 mock 的回傳值,flush
英文就是 沖水
,當 HttpTestingController
將 mockResponse
沖出去後,才會執行 subscribe()
內的 expect()
測試。
也就是說若你忘了寫
flush()
,其實單元測試也會綠燈
,但此時的綠燈並不是真的測試通過,而是根本沒有執行到subscribe()
內的expect()
。
81 行
1 | afterEach(() => { |
實務上可能真的忘了寫 expectOne()
與 flush()
,導致 subscribe()
內的 expect()
根本沒跑到而造成單元測試 綠燈
,因此必須在每個單元測試跑完補上 mockHttpClient.verify()
,若有任何 API request 卻沒有經過 expectOne()
與 flush()
測試,則 verify()
會造成單元測試 紅燈
,藉以彌補忘了寫 expectOne()
與 flush()
的人為錯誤。
Q : 我們在
listPosts$()
的單元測試到底測試了什麼 ?
- 若 service 的 API 與 mock 不同,會出現單元測試
紅燈
,可能是 service 的 API 錯誤 - 若 service 的 action 與 mock 不同,會出現單元測試
紅燈
,可能是 service 的 action 錯誤 - 若 service 的 response 與 expected 不同,可能是 service 的邏輯錯誤
61 行
1 | it(`should add post`, () => { |
再來談談如何測試 POST
。
62 行
1 | /** act */ |
直接對 PostService.addPost()
測試,由於 addPost()
回傳 Observable
,因此 expect()
必須寫在 subscribe()
內。
將預期的測試結果寫在 expected
內。
74 行
1 | /** arrange */ |
因為要 mock POST
,因此 method
部分改為 POST
,其他部分與 GET
部分完全相同。
Q : 我們在
addPost()
到底測試了什麼 ?
- 若 service 的 API 與 mock 不同,會出現單元測試
紅燈
,可能是 service 的 API 錯誤 - 若 service 的 action 與 mock 不同,會出現單元測試
紅燈
,可能是 service 的 action 錯誤 - 若 service 的 response 與 expected 不同,可能是 service 的邏輯錯誤
使用 Wallaby.js 通過所有 service 單元測試。
AppComponent
app.component.spec.ts
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; |
20 行
1 | TestBed.configureTestingModule({ |
跑 component 單元測試時,一樣沒有使用 AppModule
的設定,因為我們可能在測試時使用其他替代 module,也可能自己實作 fake 另外 DI。
因此一樣使用 TestBed.configureTestingModule()
,讓我們另外設定跑測試時的 imports
與 providers
部分。
24 行
1 | imports: [ |
因為在 component 使用了 two-way binding,因此要加上 FormsModule
。
Q : 為什麼要 import
HttpClientTestingModule
呢 ?
AppComponent
依賴的是 PostService
,看起來與 HttpClient
無關,應該不需要注入 HttpClientTestingModule
。
但其實 DI 並不是這樣運作,雖然 AppComponent
只用到了 PostService
,但 DI 會將 PostService
下所有的 dependency 都一起注入,所以也要 import HttpClientTestingModule
。
28 行
1 | providers: [ |
由於我們要測試的就是 PostService
,因此 PostService
也必須由 DI 幫我們建立。
但因爲 PostService
是基於 PostInterface
建立,因此必須透過 PostInterfaceToken
mapping 到 PostService
。
39 行
1 | postService = TestBed.get(PostInterfaceToken, PostService); |
由 providers
設定好 interface 與 class 的 mapping 關係後,我們必須透過 DI 建立 postService
與。
53 行
1 | it(`should list posts`, () => { |
55 行
1 | const expected$ = Observable.of([ |
透過 Observable.of()
將 Post[]
轉成 Observable<Post[]>
。
63 行
1 | /** arrange */ |
由於我們要隔離 PostService
,因此使用 spyOn()
對 PostService
的 listPost$()
加以 mock,並設定其假的回傳值。
65 行
1 | /** act */ |
實際測試 onListPostClick()
。
67 行
1 | /** assert */ |
測試 AppComponent.post$
是否如預期。
Q : 我們在
onListPostsClick()
到底測試了什麼 ?
- 若 component 的 return 與 expected 不同,可能是 component 的邏輯錯誤
70 行
1 | it(`should add post`, () => { |
72 行
1 | const expected$ = Observable.of( |
透過 Observable.of()
將 Post
轉成 Observable<Post>
。
80 行
1 | /** arrange */ |
由於我們要隔離 PostService
,因此使用 spyOn()
對 PostService
的 addPost
加以 mock,並設定其假的回傳值。
82 行
1 | /** act */ |
實際測試 onAddPostClick()
。
84 行
1 | /** assert */ |
由於 onAddPostClick()
回傳值為 void
,且 PostService.addPost()
已經有單元測試保護,因此只要測試 PostService.addPost()
曾經被呼叫過即可。
使用 Wallaby.js 通過所有 component 單元測試。
Conclusion
- 當 component 使用了 service,若要單元測試就牽涉到 DI 與
spyOn()
- Service 單元測試可透過
HttpTestingController
加以隔離HttpClient
- Component 單元測試可透過
spyOn()
加以隔離 service
Sample Code
完整的範例可以在我的 GitHub 上找到
Reference
Angular, Testing
Angular, HttpClient
Fabian Gosebrink, Testing Angular Http Service
Chris Pawlukiewicz, Simple Observable Unit Testing in Angular 2
Jesse Palmer, Testing Angular Applications