如何对 Service 单元测试 ?
使用 HttpTestingController 将大幅简化单元测试
Contents
1.Version
2.User Story
3.Task
4.Architecture
5.Implementation
1.PostService
2.AppComponent
6.Conclusion
7.Sample Code
8.Reference
凡与显示相关逻辑,我们会写在 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 单元测试。
上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。