好一陣子沒寫單元測試與整合測試了,大家是否覺得有些生疏了呢?
之前的測試都寫得很簡單,正好昨天好好地寫了搜尋輸入框還有呼叫 API ,可以藉由撰寫這個功能的測試來分享一些小技巧給大家。
小提醒:昨天的程式碼大家可以從 Github - Branch: day24 上 Clone 或者是 Fork 下來。
實作開始 這次要撰寫測試的檔案比較多,有三個 Pipe
、 一個 Service
與一個 Component
的測試需要撰寫。
不過雖然檔案比較多,但要撰寫的測試其實不會比較難,相反地,由於我們昨天在開發的時候有把邏輯切到各個 Pipe
與 Service
,因此凡而在撰寫測試上會顯得更加地好寫。
測試單元 - BooleanInZhTwPipe 首先,我們來看看最簡單的 BooleanInZhTwPipe
,其程式碼如下:
1 2 3 4 5 6 7 export class BooleanInZhTwPipe implements PipeTransform { transform(value: boolean , ...args: unknown[]): string { return value ? '是' : '否' ; } }
BooleanInZhTwPipe
只有一個函式 transform
,因此我們只要驗證:
當傳入的 value
為 true
時,則回傳 是
。
當傳入的 value
為 false
時,則回傳 否
。
夠簡單了吧?
測試程式碼如下:
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 describe('BooleanInZhTwPipe' , () => { let pipe: BooleanInZhTwPipe; beforeEach(() => { pipe = new BooleanInZhTwPipe(); }); it('create an instance' , () => { expect(pipe).toBeTruthy(); }); describe('transform' , () => { describe('when the first parameter is `true`' , () => { it('should return "是"' , () => { const firstParameter = true ; const expectedResult = '是' ; const actualResult = pipe.transform(firstParameter); expect(actualResult).toBe(expectedResult); }); }); describe('when the first parameter is `false`' , () => { it('should return "否"' , () => { const firstParameter = false ; const expectedResult = '否' ; const actualResult = pipe.transform(firstParameter); expect(actualResult).toBe(expectedResult); }); }); }); });
測試結果:
測試單元 - GoogleMapLinkPipe GoogleMapLinkPipe
的部份也很簡單,其程式碼如下:
1 2 3 4 5 6 7 export class GoogleMapLinkPipe implements PipeTransform { transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string { return `https://www.google.com/maps?q=${PositionLat} ,${PositionLon} &z=7` ; } }
而其驗證項目只需要驗證將傳入的第一個參數的 PositionLat
跟 PositionLong
是否有與 URL 相結合即可。
其測試程式碼如下:
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 describe('GoogleMapLinkPipe' , () => { let pipe: GoogleMapLinkPipe; beforeEach(() => { pipe = new GoogleMapLinkPipe(); }); it('create an instance' , () => { expect(pipe).toBeTruthy(); }); describe('transform' , () => { describe('when the first parameter is `true`' , () => { it('should return "https://www.google.com/maps?q=2.34567,12.34567&z=7"' , () => { const firstParameter: StationPosition = { PositionLon: 12.34567 , PositionLat: 2.34567 , GeoHash: 'abcdefg' }; const expectedResult = 'https://www.google.com/maps?q=2.34567,12.34567&z=7' ; const actualResult = pipe.transform(firstParameter); expect(actualResult).toBe(expectedResult); }); }); }); });
測試結果:
測試單元 - LocationStringPipe 最後一個 Pipe ─ LocationStringPipe
的程式碼如下:
1 2 3 4 5 6 7 export class LocationStringPipe implements PipeTransform { transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string { return `${PositionLat} , ${PositionLon} ` ; } }
其驗證項目只需要驗證將傳入的第一個參數的 PositionLat
跟 PositionLong
是否有變成字串並在其中加上逗號即可。
其測試程式碼如下:
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 describe('LocationStringPipe' , () => { let pipe: LocationStringPipe; beforeEach(() => { pipe = new LocationStringPipe(); }); it('create an instance' , () => { const pipe = new LocationStringPipe(); expect(pipe).toBeTruthy(); }); describe('transform' , () => { describe('when the first parameter is `true`' , () => { it('should return "2.34567, 12.34567"' , () => { const firstParameter: StationPosition = { PositionLon: 12.34567 , PositionLat: 2.34567 , GeoHash: 'abcdefg' }; const expectedResult = '2.34567, 12.34567' ; const actualResult = pipe.transform(firstParameter); expect(actualResult).toBe(expectedResult); }); }); }); });
測試結果:
至此, Pipe 的部份就全測完了,相信大家這部份一定沒什麼問題。
而大家應該也有發現,我們在今天在驗 Pipe 的時候跟在驗 Component 的時候有一個滿明顯的不同,那就是我們今天沒有 TestBed
。
其實這是因為我們的這幾個 Pipe 很乾淨,沒有依賴任何其他的 Class ,所以在撰寫測試時,其實就把它當成一般的 Class ,用 new xxxPipe()
的方式產生出實體就行了。
剛剛前面的 Pipe 只是先讓大家熱熱身,抓抓手感,接下來我們要為 ReactiveFormsAutoCompleteSearchingService
撰寫測試,算是今天的重頭戲之一。
雖然 ReactiveFormsAutoCompleteSearchingService
的程式碼也很簡單,但為什麼會是今天的重頭戲呢?
這是因為 ReactiveFormsAutoCompleteSearchingService
有用到我們之前沒有用過的 httpClient
。
先來看看它的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 export class ReactiveFormsAutoCompleteSearchingService { constructor (private httpClient: HttpClient ) { } searchStation(stationName: string ): Observable<MetroStationDTO[]> { let url = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON' ; if (stationName) { url += `&$filter=contains(StationName/Zh_tw,'${stationName} ')` ; } return this .httpClient.get<MetroStationDTO[]>(url); } }
ReactiveFormsAutoCompleteSearchingService
跟上面的 Pipe 一樣,都只有一個函式,不過在這個函式裡我們會需要驗兩個情境,四個案例:
呼叫 searchStation
所帶入的參數是空字串時
該函式會回傳一個 Observable
(單元測試)
要呼叫 httpClient
的 get
函式,並帶入參數 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON
(整合測試)
呼叫 searchStation
所帶入的參數是有效字串時
該函式會回傳一個 Observable
(單元測試)
要呼叫 httpClient
的 get
函式,並帶入參數 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON&$filter=contains(StationName/Zh_tw,'xxx')
(整合測試)
開始撰寫測試之前,我們一樣先把 ReactiveFormsAutoCompleteSearchingService
所依賴的項目準備好:
1 2 3 4 5 6 7 beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ReactiveFormsAutoCompleteSearchingService] }); service = TestBed.inject(ReactiveFormsAutoCompleteSearchingService); });
準備好依賴項目之後,就可以開始撰寫測試程式囉。
看仔細噢!原本 Service 要使用 HttpClient
的話,正常要在模組內引入 HttpClientModule
。
但在撰寫測試時,我們要引入的是 HttpClientTestingModule
這個 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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 describe('searchStation' , () => { describe('When the stationName is a empty string' , () => { const stationName = '' ; it('should return a Observable' , () => { const result = service.searchStation(stationName); expect(result).toBeInstanceOf(Observable); }); it('should call function "get" of the "HttpClient" with the correct API\'s URL' , () => { const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON' ; const httpClient = TestBed.inject(HttpClient); spyOn(httpClient, 'get' ); service.searchStation(stationName); expect(httpClient.get).toHaveBeenCalledWith(apiUrl); }); }); describe('When the stationName is a valid string' , () => { const stationName = 'Leo' ; it('should return a Observable' , () => { const result = service.searchStation(stationName); expect(result).toBeInstanceOf(Observable); }); it('should call function "get" of the "HttpClient" with the correct API\'s URL' , () => { const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON&$filter=contains(StationName/Zh_tw,\'Leo\')' ; const httpClient = TestBed.inject(HttpClient); spyOn(httpClient, 'get' ); service.searchStation(stationName); expect(httpClient.get).toHaveBeenCalledWith(apiUrl); }); }); });
測試結果:
最後要測的是 ReactiveFormsAutoCompleteSearchingComponent
,由於是 Component 的關係,基本上除了 Class 本身之外,我們還要來驗證 Template 的部份。
先來看看 Class 的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 export class ReactiveFormsAutoCompleteSearchingComponent { searchingInputControl = new FormControl(); stations$ = this .searchingInputControl.valueChanges.pipe( startWith('' ), debounceTime(500 ), switchMap(value => this .service.searchStation(value)) ); constructor (private service: ReactiveFormsAutoCompleteSearchingService ) { } }
這個 Component 要驗的情境有:
驗證 searchingInputControl
是不是 FormControl
驗證 stations$
是不是 Observable
驗證 stations$
被訂閱時, ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
會不會被呼叫並傳入空字串
驗證 searchingInputControl
的值變動時, ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
會不會被呼叫並傳入 searchingInputControl
的值
驗證 searchingInputControl
的值快速變動兩次時,ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
是否只被呼叫一次
驗證 searchingInputControl
的值變動兩次的間隔時間超過 500 毫秒時,ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
是否被呼叫兩次
開始測試前,一樣先把依賴的項目準備好:
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 describe('ReactiveFormsAutoCompleteSearchingComponent' , () => { let component: ReactiveFormsAutoCompleteSearchingComponent; let fixture: ComponentFixture<ReactiveFormsAutoCompleteSearchingComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ReactiveFormsAutoCompleteSearchingComponent], providers: [ { provide: ReactiveFormsAutoCompleteSearchingService, useValue: { searchStation: () => EMPTY } } ] }) .compileComponents(); fixture = TestBed.createComponent(ReactiveFormsAutoCompleteSearchingComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create' , () => { expect(component).toBeTruthy(); }); });
從上述程式碼中,大家可能會發現以前從來沒看過的程式碼:
1 2 3 4 5 6 { provide: ReactiveFormsAutoCompleteSearchingService, useValue: { searchStation: () => EMPTY } }
而這也是我們今天文章的主軸, DI 抽換 。
DI 抽換 DI ,也就是 Dependency Injection ,依賴注入 。
這點大家應該知道,而 DI 抽換 是 Angular 提供的一個很有趣的功能,讓我們可以用以下三種方式替換掉想替換的 Provider :
useClass
─ 提供一個繼承於想替換掉的 Provider 的 Class ,然後用新的 Class 取代原本的 Provider
像是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class MyRouter extends Router { } @NgModule ({ providers: [ { provide: Router, useClass: MyRouter } ] }) export class AbcModule { }
useValue
─ 像剛剛在測試程式碼裡所寫的那樣,直接用物件抽換掉想換掉的 Provider
useFactory
─ 用函式來抽換,像是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const abcServiceFactory = () => { return new AbcService(); } @NgModule ({ providers: [ { provide: AbcService, useClass: abcServiceFactory } ] }) export class ABCModule { }
關於這部份,真的要講很細的話可以寫一整篇,不過我今天只是想讓大家知道我們可以透過 DI 抽換的方式,把不可控 的依賴變成可控 的,這樣才能寫出優秀的測試 。
關於 DI 抽換的部分,如果想了解更多可以參考官方的 Dependency providers
文件。
知道 DI 抽換是什麼概念之後,我們就來開始撰寫測試案例吧!
我的測試程式碼如下:
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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 describe('Property searchingInputControl' , () => { it('should be a instance of FormControl' , () => { expect(component.searchingInputControl).toBeInstanceOf(FormControl); }); }); describe('Property stations$' , () => { it('should be a instance of FormControl' , () => { expect(component.stations$).toBeInstanceOf(Observable); }); describe('when it be subscribed' , () => { let service: ReactiveFormsAutoCompleteSearchingService; beforeEach(() => { service = TestBed.inject(ReactiveFormsAutoCompleteSearchingService); spyOn(service, 'searchStation' ).and.returnValue(of([])); }); it('should call function "searchStation" of the service with empty string' , (done ) => { component.stations$.subscribe(() => { expect(service.searchStation).toHaveBeenCalledOnceWith('' ); done(); }); }); describe('when the input value changes' , () => { it('should call function "searchStation" of the service with the value' , (done ) => { const value = 'Leo' component.stations$.subscribe(() => { expect(service.searchStation).toHaveBeenCalledOnceWith(value); done(); }); component.searchingInputControl.patchValue(value); }); }); describe('when the input value changes twice quickly' , () => { it('should call function "searchStation" of the service once with the last value' , (done ) => { const firstValue = 'Leo' const secondValue = 'Chen' component.stations$.subscribe(() => { expect(service.searchStation).toHaveBeenCalledOnceWith(secondValue); done(); }); component.searchingInputControl.patchValue(firstValue); component.searchingInputControl.patchValue(secondValue); }); }); describe('when the input value changes twice slowly' , () => { it('should call function "searchStation" of the service twice' , fakeAsync(() => { const firstValue = 'Leo' const secondValue = 'Chen' component.stations$.subscribe(); component.searchingInputControl.patchValue(firstValue); tick(600 ); component.searchingInputControl.patchValue(secondValue); tick(600 ); expect(service.searchStation).toHaveBeenCalledTimes(2 ); expect(service.searchStation).toHaveBeenCalledWith(firstValue); expect(service.searchStation).toHaveBeenCalledWith(secondValue); })); }); }) });
測試結果:
在上述的測試程式碼中,我們可以看到今天要分享給大家的最後一個技巧:非同步 測試。
Angular 的非同步測試技巧 在驗證非同步事件處理邏輯如 Promise
與 Observable
時,最簡單的方式當然就是直接 then
或是 subscribe
之後再驗證。
而這時我們會在傳入 it
的函式裡,多一個名為 done
的參數 (你要取名為別的名字也可以) ,如此我們就可以讓測試知道我們要等非同步事件完成後再行驗證。
像這樣:
1 2 3 4 5 it('description' , (done ) => { observable.subscribe(() => { done(); }); });
但除了這個方式外,Angular 還有提供另一個方式是是永 fakeAsync
與 tick
的組合。
使用方式是將原本要傳入 it
裡的函式傳入 fakeAsync()
裡並用它來做替代,接著就可以在 it
裡面使用 tick()
這個函式來代表時間的流逝。
例如:
1 2 3 4 5 6 7 it('description' , fakeAsync(() => { tick(300 ) }));
而且這個時間的流逝是假的,又或者是說,有種「時間加速器的概念」。
假設 Do A
到 Assert A
之間相隔十年,用了 tick(10年)
之後,瞬間就過完了十年,厲害吧!
簡直媲美薩諾斯收集完無限寶石之後,一彈指就讓全宇宙的一半人口都灰飛湮滅的帥度
今天差不多就到這邊,訊息量應該滿大的,至於剩下 Template 的測試沒什麼太特別的地方,就讓大家練習做做看囉!
本日小結 今天的重點:
如果被測試的 Class 沒有任何依賴,則只需使用 new XXX()
來產生實體即可( Component 除外)
如果有使用到 HttpClient 的話,撰寫測試時要引入的是 HttpClientTestingModule
,而不是 HttpClientModule
DI 抽換
非同步的處理
以上技巧會在大家實際撰寫測時非常大量的使用,記得要多加練習才會熟能生巧噢!
今天的程式碼會放在 Github - Branch: day25 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!