Angular 深入淺出三十天:表單與測試 Day25 - 測試進階技巧 - DI 抽換

Day25

好一陣子沒寫單元測試與整合測試了,大家是否覺得有些生疏了呢?

之前的測試都寫得很簡單,正好昨天好好地寫了搜尋輸入框還有呼叫 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 ,因此我們只要驗證:

  1. 當傳入的 value 為 true 時,則回傳 是 。
  2. 當傳入的 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 "是"', () => {
// 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);
});
});
});
});

測試結果:

Testing Result

測試單元 - 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"', () => {
// 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);
});
});
});
});

測試結果:

Testing Result

測試單元 - 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"', () => {
// 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);
});
});
});
});

測試結果:

Testing Result

至此, Pipe 的部份就全測完了,相信大家這部份一定沒什麼問題。

而大家應該也有發現,我們在今天在驗 Pipe 的時候跟在驗 Component 的時候有一個滿明顯的不同,那就是我們今天沒有 TestBed 。

其實這是因為我們的這幾個 Pipe 很乾淨,沒有依賴任何其他的 Class ,所以在撰寫測試時,其實就把它當成一般的 Class ,用 new xxxPipe() 的方式產生出實體就行了。

ReactiveFormsAutoCompleteSearchingService

剛剛前面的 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 一樣,都只有一個函式,不過在這個函式裡我們會需要驗兩個情境,四個案例:

  1. 呼叫 searchStation 所帶入的參數是空字串時
    1. 該函式會回傳一個 Observable (單元測試)
    2. 要呼叫 httpClient 的 get 函式,並帶入參數 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON (整合測試)
  2. 呼叫 searchStation 所帶入的參數是有效字串時
    1. 該函式會回傳一個 Observable (單元測試)
    2. 要呼叫 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', () => {
// 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);
});
});
});

測試結果:

Testing Result

ReactiveFormsAutoCompleteSearchingComponent

最後要測的是 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 要驗的情境有:

  1. 驗證 searchingInputControl 是不是 FormControl
  2. 驗證 stations$ 是不是 Observable
  3. 驗證 stations$ 被訂閱時, ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 會不會被呼叫並傳入空字串
  4. 驗證 searchingInputControl 的值變動時, ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 會不會被呼叫並傳入 searchingInputControl 的值
  5. 驗證 searchingInputControl 的值快速變動兩次時,ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 是否只被呼叫一次
  6. 驗證 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 :

  1. 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 { }
  2. useValue ─ 像剛剛在測試程式碼裡所寫的那樣,直接用物件抽換掉想換掉的 Provider

  3. 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', () => {
// 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);
}));
});
})
});

測試結果:

Testing Result

在上述的測試程式碼中,我們可以看到今天要分享給大家的最後一個技巧:非同步測試。

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(() => {
// Do A

tick(300) // ms

// Assert A
}));

而且這個時間的流逝是假的,又或者是說,有種「時間加速器的概念」。

假設 Do A 到 Assert A 之間相隔十年,用了 tick(10年) 之後,瞬間就過完了十年,厲害吧!

簡直媲美薩諾斯收集完無限寶石之後,一彈指就讓全宇宙的一半人口都灰飛湮滅的帥度

今天差不多就到這邊,訊息量應該滿大的,至於剩下 Template 的測試沒什麼太特別的地方,就讓大家練習做做看囉!

本日小結

今天的重點:

  1. 如果被測試的 Class 沒有任何依賴,則只需使用 new XXX() 來產生實體即可( Component 除外)
  2. 如果有使用到 HttpClient 的話,撰寫測試時要引入的是 HttpClientTestingModule ,而不是 HttpClientModule
  3. DI 抽換
  4. 非同步的處理

以上技巧會在大家實際撰寫測時非常大量的使用,記得要多加練習才會熟能生巧噢!

今天的程式碼會放在 Github - Branch: day25 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×