好一陣子沒寫單元測試與整合測試了,大家是否覺得有些生疏了呢?
之前的測試都寫得很簡單,正好昨天好好地寫了搜尋輸入框還有呼叫 API ,可以藉由撰寫這個功能的測試來分享一些小技巧給大家。
小提醒:昨天的程式碼大家可以從 Github - Branch: day24 上 Clone 或者是 Fork 下來。
這次要撰寫測試的檔案比較多,有三個 Pipe
、 一個 Service
與一個 Component
的測試需要撰寫。
不過雖然檔案比較多,但要撰寫的測試其實不會比較難,相反地,由於我們昨天在開發的時候有把邏輯切到各個 Pipe
與 Service
,因此凡而在撰寫測試上會顯得更加地好寫。
首先,我們來看看最簡單的 BooleanInZhTwPipe
,其程式碼如下:
export class BooleanInZhTwPipe implements PipeTransform {
transform(value: boolean, ...args: unknown[]): string {
return value ? '是' : '否';
}
}
BooleanInZhTwPipe
只有一個函式 transform
,因此我們只要驗證:
value
為 true
時,則回傳 是
。value
為 false
時,則回傳 否
。夠簡單了吧?
測試程式碼如下:
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 "是"', () => {
// Arrange
const firstParameter = true;
const expectedResult = '是';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
describe('when the first parameter is `false`', () => {
it('should return "否"', () => {
// Arrange
const firstParameter = false;
const expectedResult = '否';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
});
});
測試結果:
GoogleMapLinkPipe
的部份也很簡單,其程式碼如下:
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 相結合即可。
其測試程式碼如下:
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"', () => {
// Arrange
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';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
});
});
測試結果:
最後一個 Pipe ─ LocationStringPipe
的程式碼如下:
export class LocationStringPipe implements PipeTransform {
transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `${PositionLat}, ${PositionLon}`;
}
}
其驗證項目只需要驗證將傳入的第一個參數的 PositionLat
跟 PositionLong
是否有變成字串並在其中加上逗號即可。
其測試程式碼如下:
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"', () => {
// Arrange
const firstParameter: StationPosition = {
PositionLon: 12.34567,
PositionLat: 2.34567,
GeoHash: 'abcdefg'
};
const expectedResult = '2.34567, 12.34567';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
});
});
測試結果:
至此, Pipe 的部份就全測完了,相信大家這部份一定沒什麼問題。
而大家應該也有發現,我們在今天在驗 Pipe 的時候跟在驗 Component 的時候有一個滿明顯的不同,那就是我們今天沒有 TestBed
。
其實這是因為我們的這幾個 Pipe 很乾淨,沒有依賴任何其他的 Class ,所以在撰寫測試時,其實就把它當成一般的 Class ,用 new xxxPipe()
的方式產生出實體就行了。
剛剛前面的 Pipe 只是先讓大家熱熱身,抓抓手感,接下來我們要為 ReactiveFormsAutoCompleteSearchingService
撰寫測試,算是今天的重頭戲之一。
雖然 ReactiveFormsAutoCompleteSearchingService
的程式碼也很簡單,但為什麼會是今天的重頭戲呢?
這是因為 ReactiveFormsAutoCompleteSearchingService
有用到我們之前沒有用過的 httpClient
。
先來看看它的程式碼:
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
所依賴的項目準備好:
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ReactiveFormsAutoCompleteSearchingService]
});
service = TestBed.inject(ReactiveFormsAutoCompleteSearchingService);
});
準備好依賴項目之後,就可以開始撰寫測試程式囉。
看仔細噢!原本 Service 要使用
HttpClient
的話,正常要在模組內引入HttpClientModule
。但在撰寫測試時,我們要引入的是
HttpClientTestingModule
這個 Angular 幫我們準備好專門給撰寫測試所要引入的 Module 。
我的測試程式碼如下:
describe('searchStation', () => {
describe('When the stationName is a empty string', () => {
const stationName = '';
it('should return a Observable', () => {
// Act
const result = service.searchStation(stationName);
// Assert
expect(result).toBeInstanceOf(Observable);
});
it('should call function "get" of the "HttpClient" with the correct API\'s URL', () => {
// Arrange
const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON';
const httpClient = TestBed.inject(HttpClient);
spyOn(httpClient, 'get');
// Act
service.searchStation(stationName);
// Assert
expect(httpClient.get).toHaveBeenCalledWith(apiUrl);
});
});
describe('When the stationName is a valid string', () => {
const stationName = 'Leo';
it('should return a Observable', () => {
// Act
const result = service.searchStation(stationName);
// Assert
expect(result).toBeInstanceOf(Observable);
});
it('should call function "get" of the "HttpClient" with the correct API\'s URL', () => {
// Arrange
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');
// Act
service.searchStation(stationName);
// Assert
expect(httpClient.get).toHaveBeenCalledWith(apiUrl);
});
});
});
測試結果:
最後要測的是 ReactiveFormsAutoCompleteSearchingComponent
,由於是 Component 的關係,基本上除了 Class 本身之外,我們還要來驗證 Template 的部份。
先來看看 Class 的程式碼:
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
是否被呼叫兩次開始測試前,一樣先把依賴的項目準備好:
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();
});
});
從上述程式碼中,大家可能會發現以前從來沒看過的程式碼:
{
provide: ReactiveFormsAutoCompleteSearchingService,
useValue: {
searchStation: () => EMPTY
}
}
而這也是我們今天文章的主軸, DI 抽換 。
DI ,也就是 Dependency Injection ,依賴注入。
這點大家應該知道,而 DI 抽換是 Angular 提供的一個很有趣的功能,讓我們可以用以下三種方式替換掉想替換的 Provider :
useClass
─ 提供一個繼承於想替換掉的 Provider 的 Class ,然後用新的 Class 取代原本的 Provider
像是:
class MyRouter extends Router {
// ...
}
@NgModule({
// ...
providers: [
{
provide: Router,
useClass: MyRouter
}
]
})
export class AbcModule { }
useValue
─ 像剛剛在測試程式碼裡所寫的那樣,直接用物件抽換掉想換掉的 Provider
useFactory
─ 用函式來抽換,像是:
const abcServiceFactory = () => {
return new AbcService();
}
@NgModule({
// ...
providers: [
{
provide: AbcService,
useClass: abcServiceFactory
}
]
})
export class ABCModule { }
關於這部份,真的要講很細的話可以寫一整篇,不過我今天只是想讓大家知道我們可以透過 DI 抽換的方式,把不可控的依賴變成可控的,這樣才能寫出優秀的測試。
關於 DI 抽換的部分,如果想了解更多可以參考官方的 Dependency providers
文件。
知道 DI 抽換是什麼概念之後,我們就來開始撰寫測試案例吧!
我的測試程式碼如下:
describe('Property searchingInputControl', () => {
it('should be a instance of FormControl', () => {
// Assert
expect(component.searchingInputControl).toBeInstanceOf(FormControl);
});
});
describe('Property stations$', () => {
it('should be a instance of FormControl', () => {
// Assert
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) => {
// Act
component.stations$.subscribe(() => {
// Assert
expect(service.searchStation).toHaveBeenCalledOnceWith('');
done();
});
});
describe('when the input value changes', () => {
it('should call function "searchStation" of the service with the value', (done) => {
// Arrange
const value = 'Leo'
// Act
component.stations$.subscribe(() => {
// Assert
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) => {
// Arrange
const firstValue = 'Leo'
const secondValue = 'Chen'
// Act
component.stations$.subscribe(() => {
// Assert
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(() => {
// Arrange
const firstValue = 'Leo'
const secondValue = 'Chen'
// Act
component.stations$.subscribe();
component.searchingInputControl.patchValue(firstValue);
tick(600);
component.searchingInputControl.patchValue(secondValue);
tick(600);
// Assert
expect(service.searchStation).toHaveBeenCalledTimes(2);
expect(service.searchStation).toHaveBeenCalledWith(firstValue);
expect(service.searchStation).toHaveBeenCalledWith(secondValue);
}));
});
})
});
測試結果:
在上述的測試程式碼中,我們可以看到今天要分享給大家的最後一個技巧:非同步測試。
在驗證非同步事件處理邏輯如 Promise
與 Observable
時,最簡單的方式當然就是直接 then
或是 subscribe
之後再驗證。
而這時我們會在傳入 it
的函式裡,多一個名為 done
的參數 (你要取名為別的名字也可以) ,如此我們就可以讓測試知道我們要等非同步事件完成後再行驗證。
像這樣:
it('description', (done) => {
observable.subscribe(() => {
done();
});
});
但除了這個方式外,Angular 還有提供另一個方式是是永 fakeAsync
與 tick
的組合。
使用方式是將原本要傳入 it
裡的函式傳入 fakeAsync()
裡並用它來做替代,接著就可以在 it
裡面使用 tick()
這個函式來代表時間的流逝。
例如:
it('description', fakeAsync(() => {
// Do A
tick(300) // ms
// Assert A
}));
而且這個時間的流逝是假的,又或者是說,有種「時間加速器的概念」。
假設 Do A
到 Assert A
之間相隔十年,用了 tick(10年)
之後,瞬間就過完了十年,厲害吧!
簡直媲美薩諾斯收集完無限寶石之後,一彈指就讓全宇宙的一半人口都灰飛湮滅的帥度
今天差不多就到這邊,訊息量應該滿大的,至於剩下 Template 的測試沒什麼太特別的地方,就讓大家練習做做看囉!
今天的重點:
new XXX()
來產生實體即可( Component 除外)HttpClientTestingModule
,而不是 HttpClientModule
以上技巧會在大家實際撰寫測時非常大量的使用,記得要多加練習才會熟能生巧噢!
今天的程式碼會放在 Github - Branch: day25 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!
Hi Leo,
在後面幾個測試時有用到callback的方式製造異步測試的效果,不過是否可以拿掉callback的done呢(如圖)?因為這次測試的物件能夠subscribe,這樣就不用callback來製造異步了,拿掉後測試的結果也都是正確的
示意圖:
還有一個問題是 : 如果我在最一開始的beforeEach使用fixture.detectChanges(),如圖,會導致callback異步的測試方式失敗,fakeAsync的方式仍會成功,不太曉得為何會有這樣的差異?
示意圖:
在後面幾個測試時有用到callback的方式製造異步測試的效果,不過是否可以拿掉callback的done呢(如圖)?因為這次測試的物件能夠subscribe,這樣就不用callback來製造異步了,拿掉後測試的結果也都是正確的
不建議拿掉。
由於非同步的關係,所以需要 done
來讓測試程式知道該案例是否已經結束。有時候雖然看似結果正常,但可能只是剛好、或湊巧而已。
還有一個問題是 : 如果我在最一開始的beforeEach使用fixture.detectChanges(),如圖,會導致callback異步的測試方式失敗,fakeAsync的方式仍會成功,不太曉得為何會有這樣的差異?
程式碼不夠完整,不太清楚你的問題點在哪裡@@
不建議拿掉。
由於非同步的關係,所以需要 done 來讓測試程式知道該案例是否已經結束。有時候雖然看似結果正常,但可能只是剛好、或湊巧而已。
原來如此~確實這樣才能確保裡面的東西有執行完成
程式碼不夠完整,不太清楚你的問題點在哪裡@@
漏說明程式碼與範例相同,會失敗的程式碼只跟範例差在最初的BeforeEach裡有呼叫fixture.detectChanges(),其餘一樣,我的問題是在於fixture.detectChanges()
這句應該只是初始渲染畫面而已,但加入後會導致失敗,從錯誤訊息來看好像會影響到spyOn的執行,不過fixture.detectChanges()
也比spyOn先執行,為何會影響到也我蠻好奇的(⊙_⊙)?
示意圖:
(component.spec.ts)
就是因為 fixture.detectChanges()
比 spyOn
先執行,所以已經先執行原本的程式導致出錯喔
就是因為 fixture.detectChanges() 比 spyOn 先執行,所以已經先執行原本的程式導致出錯喔
所以圖中所報出來的錯誤訊息是指說呼叫了兩次的意思嗎?一次fixture.detectChanges(),一次測試範例,這樣說對嗎?突然想起前面介紹beforeEach執行順序的重要性XD
示意圖:
不過如果我將那些使用callback異步測試
(done)的方法改成fakeAsync
就不會受到一開始fixture.detectChanges()
的影響,是因為fakeAsync
會重新取代一次,譬如spyOn已經先取代component的函式並收到fixture.detectChanges()
的呼叫(次數一次),但進到fakeAsync
後重新模擬spyOn的行為,所以次數會歸零,不確定這樣想對不對?
示意圖:
所以圖中所報出來的錯誤訊息是指說呼叫了兩次的意思嗎?一次fixture.detectChanges(),一次測試範例,這樣說對嗎
對~
不過如果我將那些使用callback異步測試(done)的方法改成fakeAsync就不會受到一開始fixture.detectChanges()的影響,是因為fakeAsync會重新取代一次,譬如spyOn已經先取代component的函式並收到fixture.detectChanges()的呼叫(次數一次),但進到fakeAsync後重新模擬spyOn的行為,所以次數會歸零,不確定這樣想對不對?
這部份我倒是不怎麼確定,我只確定使用 fakeAsync
的行為就像在 code 裡使用 await/async
一樣@@"
感謝筆者的回答~終於快要完成30天的內容了XD