内容简介:如何對 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
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
群智能算法及其应用
高尚 / 中国水利水电出版社 / 2006-5 / 25.00元
《群智能算法及其应用》系统地描述了蚁群算法和粒子群优化算法的理论和实现技术及其应用,简单地介绍了鱼群算法。《群智能算法及其应用》着重强调各种算法的混合,讨论了蚁群算法与模拟退火算法的混合、蚁群算法与遗传算法的混合、蚁群算法与混沌理论混合、模拟退火算法、遗传算法与粒子群优化算法混合、混沌理论与粒子群优化算法的混合以及蚁群算法与粒子群优化算法的混合。书中还讨论了群智能算法在旅行商问题、武器一目标分配问......一起来看看 《群智能算法及其应用》 这本书的介绍吧!