内容简介:如何對 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
import { TestBed } from '@angular/core/testing'; import { PostService } from './post.service'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { PostInterfaceToken } from '../interface/injection.token'; import { Post } from '../../model/post.model'; import { environment } from '../../environments/environment'; import { PostInterface } from '../interface/post.interface'; describe('PostService', () => { let postService: PostInterface; let mockHttpClient: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ {provide: PostInterfaceToken, useClass: PostService} ] }); postService = TestBed.get(PostInterfaceToken, PostService); mockHttpClient = TestBed.get(HttpTestingController); }); it('should be created', () => { expect(PostService).toBeTruthy(); }); it(`should list all posts`, () => { /** act */ const expected: Post[] = [ { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ]; postService.listPosts$().subscribe(posts => { /** assert */ expect(posts).toEqual(expected); }); /** arrange */ const mockResponse: Post[] = [ { id: 1, title: 'Design Pattern', author: 'Eric Gamma' } ]; mockHttpClient.expectOne({ url: `${environment.apiServer}/posts`, method: 'GET' }).flush(mockResponse); }); it(`should add post`, () => { /** act */ const expected: Post = { id: 1, title: 'OOP', author: 'Sam' }; postService.addPost(expected).subscribe(post => { /** assert */ expect(post).toBe(expected); }); /** arrange */ mockHttpClient.expectOne({ url: `${environment.apiServer}/posts`, method: 'POST' }).flush(expected); }); afterEach(() => { mockHttpClient.verify(); }); });
14 行
TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ {provide: PostInterfaceToken, useClass: PostService} ] });
Angular 有 module 觀念,若使用到了其他 module,必須在 imports
設定;若使用到 DI,則必須在 providers
設定。
若只有一個 module,則在 AppModule
設定。
但是單元測試時,並沒有使用 AppModule
的設定,因為我們可能在測試時使用其他替代 module,也可能自己實作 fake 另外 DI。
Angular 提供了 TestBed.configureTestingModule()
,讓我們另外設定跑測試時的 imports
與 providers
部分。
15 行
imports: [ HttpClientTestingModule ],
原本 HttpClient
使用的是 HttpClientModule
,這會使得 HttpClient
真的透過網路去打 API,這就不符合單元測試 隔離
的要求,因此 Angular 另外提供 HttpClientTestingModule
取代 HttpClientModule
。
18 行
providers: [ {provide: PostInterfaceToken, useClass: PostService} ]
由於我們要測試的就是 PostService
,因此 PostService
也必須由 DI 幫我們建立。
但因爲 PostService
是基於 PostInterface
建立,因此必須透過 PostInterfaceToken
mapping 到 PostService
。
23 行
postService = TestBed.get(PostInterfaceToken, PostService); mockHttpClient = TestBed.get(HttpTestingController);
由 providers
設定好 interface 與 class 的 mapping 關係後,我們必須透過 DI 建立 postService
與 mockHttpClient
。
其中 HttpTestingController
相當於 mock 版的 HttpClient
,因此取名為 mockHttpClient
。
TestBed.get() 其實相當於 new
,只是這是藉由 DI 幫我們 new
而已
31 行
it(`should list all posts`, () => { /** act */ const expected: Post[] = [ { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ]; postService.listPosts$().subscribe(posts => { /** assert */ expect(posts).toEqual(expected); }); /** arrange */ const mockResponse: Post[] = [ { id: 1, title: 'Design Pattern', author: 'Eric Gamma' } ]; mockHttpClient.expectOne({ url: `${environment.apiServer}/posts`, method: 'GET' }).flush(mockResponse); });
先談談如何測試 GET
。
3A 原則的 arrange
習慣都會寫在最前面,但在 service 的單元測試時, arrange
必須寫在最後面,否則會執行錯誤,稍後會解釋原因。
32 行
/** act */ const expected: Post[] = [ { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ]; postService.listPosts$().subscribe(posts => { /** assert */ expect(posts).toEqual(expected); });
直接對 PostService.listPost$()
測試,由於 listPost$()
回傳 Observable
,因此 expect()
必須寫在 subscribe()
內。
將預期的測試結果寫在 expected
內。
一般 Observable
會在 subscribe()
後執行,不過在 HttpTestingController
的設計裡, subscribe()
會在 flush()
才執行,稍後會看到 flush()
,所以此時並還沒有執行 expect()
測試
46 行
/** arrange */ const mockResponse: Post[] = [ { id: 1, title: 'Design Pattern', author: 'Eric Gamma' } ]; mockHttpClient.expectOne({ url: `${environment.apiServer}/posts`, method: 'GET' }).flush(mockResponse);
之前已經使用 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 行
afterEach(() => { mockHttpClient.verify(); });
實務上可能真的忘了寫 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 行
it(`should add post`, () => { /** act */ const expected: Post = { id: 1, title: 'OOP', author: 'Sam' }; postService.addPost(expected).subscribe(post => { /** assert */ expect(post).toBe(expected); }); /** arrange */ mockHttpClient.expectOne({ url: `${environment.apiServer}/posts`, method: 'POST' }).flush(expected); });
再來談談如何測試 POST
。
62 行
/** act */ const expected: Post = { id: 1, title: 'OOP', author: 'Sam' }; postService.addPost(expected).subscribe(post => { /** assert */ expect(post).toBe(expected); });
直接對 PostService.addPost()
測試,由於 addPost()
回傳 Observable
,因此 expect()
必須寫在 subscribe()
內。
將預期的測試結果寫在 expected
內。
74 行
/** arrange */ mockHttpClient.expectOne({ url: `${environment.apiServer}/posts`, method: 'POST' }).flush(expected);
因為要 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
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { AppComponent } from './app.component'; import { FormsModule } from '@angular/forms'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { PostService } from './service/post.service'; import { PostInterfaceToken } from './interface/injection.token'; import { DebugElement } from '@angular/core'; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/observable/of'; import { PostInterface } from './interface/post.interface'; describe('AppComponent', () => { let fixture: ComponentFixture<AppComponent>; let appComponent: AppComponent; let debugElement: DebugElement; let htmlElement: HTMLElement; let postService: PostInterface; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent ], imports: [ FormsModule, HttpClientTestingModule ], providers: [ {provide: PostInterfaceToken, useClass: PostService} ] }).compileComponents(); fixture = TestBed.createComponent(AppComponent); appComponent = fixture.componentInstance; debugElement = fixture.debugElement; htmlElement = debugElement.nativeElement; fixture.detectChanges(); postService = TestBed.get(PostInterfaceToken, PostService); })); it('should create the app', async(() => { expect(appComponent).toBeTruthy(); })); it(`should have as title 'app'`, async(() => { expect(appComponent.title).toEqual('app'); })); it('should render title in a h1 tag', async(() => { expect(htmlElement.querySelector('h1').textContent).toContain('Welcome to app!'); })); it(`should list posts`, () => { const expected$ = Observable.of([ { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ]); /** arrange */ spyOn(postService, 'listPosts$').and.returnValue(expected$); /** act */ appComponent.onListPostsClick(); /** assert */ expect(appComponent.posts$).toEqual(expected$); }); it(`should add post`, () => { const expected$ = Observable.of( { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ); /** arrange */ const spy = spyOn(postService, 'addPost').and.returnValue(expected$); /** act */ appComponent.onAddPostClick(); /** assert */ expect(spy).toHaveBeenCalled(); }); });
20 行
TestBed.configureTestingModule({ declarations: [ AppComponent ], imports: [ FormsModule, HttpClientTestingModule ], providers: [ {provide: PostInterfaceToken, useClass: PostService} ] }).compileComponents();
跑 component 單元測試時,一樣沒有使用 AppModule
的設定,因為我們可能在測試時使用其他替代 module,也可能自己實作 fake 另外 DI。
因此一樣使用 TestBed.configureTestingModule()
,讓我們另外設定跑測試時的 imports
與 providers
部分。
24 行
imports: [ FormsModule, HttpClientTestingModule ],
因為在 component 使用了 two-way binding,因此要加上 FormsModule
。
Q : 為什麼要 import HttpClientTestingModule
呢 ?
AppComponent
依賴的是 PostService
,看起來與 HttpClient
無關,應該不需要注入 HttpClientTestingModule
。
但其實 DI 並不是這樣運作,雖然 AppComponent
只用到了 PostService
,但 DI 會將 PostService
下所有的 dependency 都一起注入,所以也要 import HttpClientTestingModule
。
28 行
providers: [ {provide: PostInterfaceToken, useClass: PostService} ]
由於我們要測試的就是 PostService
,因此 PostService
也必須由 DI 幫我們建立。
但因爲 PostService
是基於 PostInterface
建立,因此必須透過 PostInterfaceToken
mapping 到 PostService
。
39 行
postService = TestBed.get(PostInterfaceToken, PostService);
由 providers
設定好 interface 與 class 的 mapping 關係後,我們必須透過 DI 建立 postService
與。
53 行
it(`should list posts`, () => { const expected$ = Observable.of([ { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ]); /** arrange */ spyOn(postService, 'listPosts$').and.returnValue(expected$); /** act */ appComponent.onListPostsClick(); /** assert */ expect(appComponent.posts$).toEqual(expected$); });
55 行
const expected$ = Observable.of([ { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ]);
透過 Observable.of()
將 Post[]
轉成 Observable<Post[]>
。
63 行
/** arrange */ spyOn(postService, 'listPosts$').and.returnValue(expected$);
由於我們要隔離 PostService
,因此使用 spyOn()
對 PostService
的 listPost$()
加以 mock,並設定其假的回傳值。
65 行
/** act */ appComponent.onListPostsClick();
實際測試 onListPostClick()
。
67 行
/** assert */ expect(appComponent.posts$).toEqual(expected$);
測試 AppComponent.post$
是否如預期。
Q : 我們在 onListPostsClick()
到底測試了什麼 ?
- 若 component 的 return 與 expected 不同,可能是 component 的邏輯錯誤
70 行
it(`should add post`, () => { const expected$ = Observable.of( { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } ); /** arrange */ const spy = spyOn(postService, 'addPost').and.returnValue(expected$); /** act */ appComponent.onAddPostClick(); /** assert */ expect(spy).toHaveBeenCalled(); });
72 行
const expected$ = Observable.of( { id: 1, title: 'Design Pattern', author: 'Dr. Eric Gamma' } );
透過 Observable.of()
將 Post
轉成 Observable<Post>
。
80 行
/** arrange */ const spy = spyOn(postService, 'addPost').and.returnValue(expected$);
由於我們要隔離 PostService
,因此使用 spyOn()
對 PostService
的 addPost
加以 mock,並設定其假的回傳值。
82 行
/** act */ appComponent.onAddPostClick();
實際測試 onAddPostClick()
。
84 行
/** assert */ expect(spy).toHaveBeenCalled();
由於 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 , HttpClient service-unit-test.md
Fabian Gosebrink , Testing Angular Http Service
Chris Pawlukiewicz , Simple Observable Unit Testing in Angular 2
Jesse Palmer, Testing Angular Applications
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
自制编程语言 基于C语言
郑钢 / 人民邮电出版社 / 2018-9-1 / CNY 89.00
本书是一本专门介绍自制编程语言的图书,书中深入浅出地讲述了如何开发一门编程语言,以及运行这门编程语言的虚拟机。本书主要内容包括:脚本语言的功能、词法分析器、类、对象、原生方法、自上而下算符优先、语法分析、语义分析、虚拟机、内建类、垃圾回收、命令行及调试等技术。 本书适合程序员阅读,也适合对编程语言原理感兴趣的计算机从业人员学习。一起来看看 《自制编程语言 基于C语言》 这本书的介绍吧!