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

Day25

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

之前的測試都寫得很簡單,正好昨天好好地寫了搜尋輸入框還有呼叫 API ,可以藉由撰寫這個功能的測試來分享一些小技巧給大家。

小提醒:昨天的程式碼大家可以從 Github - Branch: day24 上 Clone 或者是 Fork 下來。

實作開始

這次要撰寫測試的檔案比較多,有三個 Pipe 、 一個 Service 與一個 Component 的測試需要撰寫。

不過雖然檔案比較多,但要撰寫的測試其實不會比較難,相反地,由於我們昨天在開發的時候有把邏輯切到各個 PipeService ,因此凡而在撰寫測試上會顯得更加地好寫。

測試單元 - 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. 當傳入的 valuetrue 時,則回傳
  2. 當傳入的 valuefalse 時,則回傳

夠簡單了吧?

測試程式碼如下:

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`;
}

}

而其驗證項目只需要驗證將傳入的第一個參數的 PositionLatPositionLong 是否有與 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}`;
}

}

其驗證項目只需要驗證將傳入的第一個參數的 PositionLatPositionLong 是否有變成字串並在其中加上逗號即可。

其測試程式碼如下:

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

在驗證非同步事件處理邏輯如 PromiseObservable 時,最簡單的方式當然就是直接 then 或是 subscribe 之後再驗證。

而這時我們會在傳入 it 的函式裡,多一個名為 done 的參數 (你要取名為別的名字也可以) ,如此我們就可以讓測試知道我們要等非同步事件完成後再行驗證。

像這樣:

1
2
3
4
5
it('description', (done) => {
observable.subscribe(() => {
done();
});
});

但除了這個方式外,Angular 還有提供另一個方式是是永 fakeAsynctick 的組合。

使用方式是將原本要傳入 it 裡的函式傳入 fakeAsync() 裡並用它來做替代,接著就可以在 it 裡面使用 tick() 這個函式來代表時間的流逝。

例如:

1
2
3
4
5
6
7
it('description', fakeAsync(() => {
// Do A

tick(300) // ms

// Assert A
}));

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

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

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

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

本日小結

今天的重點:

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

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

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

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

Angular 深入淺出三十天:表單與測試 Day24 - Reactive Forms 進階技巧 - Auto-Complete Searching

Day24

在日常生活中,大家應該滿常看到有些系統的搜尋輸入框是可以在一邊打字的同時,一邊將搜尋結果呈現在一個下拉選單裡,非常地貼心且方便。

當然,這其中其實有很多細節,不過我們今天就專注在前端的表單開發上,來用 Reactive Forms 實作這個搜尋輸入框吧!

沒錯,就算只是個搜尋框,它也是個表單噢!

正好最近六角學院即將舉辦第三屆的前端 & UI 修煉精神時光屋的活動,這次它們與交通部合作,並提供了全國最大的
運輸資料流通服務平台 (TDX) 之交通 API 給大家使用,讓大家可以透過此活動精進自己的實力,非常推薦給大家。

想當初我第一次寫鐵人賽時,也是使用了參加六角舉辦的第一屆前端修煉精神時光屋的素材來寫,雖然這次沒有要參賽,但又跟六角有關係了呢!

總之,藉由這次的機會與交通部提供的 運輸資料流通服務平台 (TDX) 之交通 API ,我們來簡單地做一個可以查詢台北捷運的車站的搜尋輸入框吧!

這次因為有 API 可以使用的關係,會精實很多,如果跟不上的朋友,可能要再多熟悉一下 Angular 噢!

需求規格說明

簡單來說,這個功能會需要一個輸入框與一個表格,當使用者在輸入框裡打字時,表格的內容也會連動呈現出搜尋結果。

由於 Auto-Complete 的搜尋輸入框如果要自己做會需要處理不少細節,又不想安裝 UI 框架佔篇幅,所以我用這個方式來呈現查詢結果。

表格的欄位有以下這些:

  • 車站代號
  • 車站名稱
  • 車站所屬縣市
  • 車站所屬鄉鎮區
  • 假日是否允許自行車進出站
  • 位置

最後呈現結果:

Auto-Complete Searching View

實作開始

首先,如果在需求明確的情況下,我個人習慣會先把畫面準備好。

HTML 的部份大概會長這樣:

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
<p><input type="text" placeholder="請輸入捷運站名稱" /></p>
<table>
<caption>
台北捷運之捷運站查詢結果
</caption>
<thead>
<tr>
<td>車站代號</td>
<td>車站名稱</td>
<td>車站所屬縣市</td>
<td>車站所屬鄉鎮區</td>
<td>假日是否允許自行車進出站</td>
<td>位置</td>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td>
<a target="_blank" href=""></a>
</td>
</tr>
</tbody>
</table>

CSS 的部份大家就自行發揮囉!

畫面看起來會像這樣:

Auto-Complete Searching View

接著我們會需要一個 FormControl 來跟輸入框綁定,所以我們在 .ts 裡新增一個屬性 ─ searchingInputControl

1
2
3
4
5
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

searchingInputControl = new FormControl();

}

別忘了先到 .module.ts 裡引入 FormsModuleReactiveFormsModule 噢!

然後將 searchingInputControl 與畫面輸入框綁定:

1
<p><input type="text" placeholder="請輸入捷運站名稱" [formControl]="searchingInputControl" /></p>

接著我們使用昨天分享過的 valueChanges 來確認是否已正確綁定:

1
2
3
4
5
6
7
8
9
10
11
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

searchingInputControl = new FormControl();

ngOnInit(): void {
this.searchingInputControl.valueChanges.subscribe((value) => {
console.log(value);
});
}

}

結果:

Auto-Complete Searching View

看起來已經有正確的跟搜尋輸入框綁定了,那接下來要怎麼做才好呢?

Service

我們的目的是希望使用者在輸入捷運站名稱的同時,只留下跟使用者的輸入有關聯的捷運站。

因此,我們會需要一支 Service 來幫我們呼叫交通部所提供的 運輸資料流通服務平台 (TDX) 之交通 API ,並把查詢結果顯示到畫面上。

Service 的程式碼大概會長這個樣子:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Injectable()
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);
}
}

上述程式碼中有以下幾個重點:

  1. 要呼叫 API 的話,需要先到 .module.ts 裡引入 HttpClientModule ,才能在 Service 裡使用 HttpClient 來呼叫 API。

  2. MetroStationDTO 是我根據交通部所提供的 運輸資料流通服務平台 (TDX) 之交通 API 裡定義的資料介面,詳細位置需先選擇「軌道」再點選「捷運」,如下圖所示:

TDX API Document

  1. 由於 HTTP MethodGET 的緣故,所以參數是使用 Query Parameters 的方式帶進 URL 之中。

  2. 如果使用者沒有輸入站名時,還帶 $filter 參數會收到伺服器回傳的 Bed Request 錯誤,因此增加一個判斷式 ─ 當傳入的 stationNameTruthy 值時,才帶 $filter 參數。

  3. 參數 $filter 的值該怎麼帶這件事情其實在文件中沒有寫,算是這個文件比較美中不足的地方。好在六角學院的院長 ─ 廖洧杰院長前陣子有開直播課教學,而我猜測院長一定有在那堂課講這件事情,所以去翻了一下該堂直播課的共筆才找到該怎麼帶它的值。

Service 準備好之後,接下來就要將 FormControlvalueChanges 事件與 API 相結合了。

準備好見證神蹟了嗎?

Operators

RxJS 真的是一個很棒的函式庫,它讓我們可以很好地操作非同步資料串流,而且還能讓我們的程式碼非常地簡潔、非常地好閱讀。

就像我們現在需要把使用者的輸入事件與 API 做結合時,用 RxJS 的 Operators 就可以非常完美、漂亮地結合在一起。

就像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

searchingInputControl = new FormControl();

constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

ngOnInit(): void {
this.searchingInputControl.valueChanges.pipe(
startWith(''),
debounceTime(500),
switchMap(value => this.service.searchStation(value))
).subscribe((result) => {
console.log(result);
});
}

}

結果:

Auto-Complete Searching View

我相信在這邊一定會有非常多朋友看傻眼,這是什麼神操作?!這樣就接好了?!

沒錯!這樣就接好了,是不是比你想像中簡單非常多呢?

那這串到底做了什麼事呢?

首先,我希望這個畫面一開始的時候就會先查詢一次,所以我使用 startWith('') 來呼叫查詢 API 。

再者,我希望查詢的間隔不要太過快速,當使用者「可能」已經打完字的時候才查詢,所以我使用 debounceTime(500) 來讓查詢的時間點會在使用者停止打字 500 毫秒後才呼叫查詢 API。

最後,則要將原本是 valueChanges 的 Observable 轉換成 呼叫 API 的 Observable 這件事情 ,所以我使用 switchMap(value => this.service.searchStation(value))

關於 startWith ,大家可以參考官方文件或是 Mike 的文章

關於 debounceTime ,大家可以參考官方文件或是 Mike 的文章

關於 switchMap ,大家可以參考官方文件或是 Mike 的文章

AsyncPipe

接著,我們要將得到的資料綁定到畫面上,而綁定到畫面上的方式大致上有兩種:

  1. 自己訂閱後將資料指定給 Component 的屬性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

searchingInputControl = new FormControl();
stations: MetroStationDTO[] = [];

constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

ngOnInit(): void {
this.searchingInputControl.valueChanges.pipe(
startWith(''),
debounceTime(500),
switchMap(value => this.service.searchStation(value))
).subscribe((stations) => {
this.stations = stations;
});
}

}

然後再綁到畫面上:

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
<table>
<caption>
台北捷運之捷運站查詢結果
</caption>
<thead>
<tr>
<td>車站代號</td>
<td>車站名稱</td>
<td>車站所屬縣市</td>
<td>車站所屬鄉鎮區</td>
<td>假日是否允許自行車進出站</td>
<td>位置</td>
</tr>
</thead>
<tbody>
<tr *ngFor="let station of stations">
<td>{{ station.StationID }}</td>
<td>{{ station.StationName.Zh_tw }}</td>
<td>{{ station.LocationCity }}</td>
<td>{{ station.LocationTown }}</td>
<td>{{ station.BikeAllowOnHoliday }}</td>
<td>
<a target="_blank" [href]="station.StationPosition">
{{ station.StationPosition }}
</a>
</td>
</tr>
</tbody>
</table>
  1. 不要自己訂閱,先將 Observable 準備好並用 Component 的屬性儲存起來:
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) { }

}

然後透過 AsyncPipe 讓 Template 自己訂閱:

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
<table>
<caption>
台北捷運之捷運站查詢結果
</caption>
<thead>
<tr>
<td>車站代號</td>
<td>車站名稱</td>
<td>車站所屬縣市</td>
<td>車站所屬鄉鎮區</td>
<td>假日是否允許自行車進出站</td>
<td>位置</td>
</tr>
</thead>
<tbody>
<tr *ngFor="let station of (stations$ | async) || []">
<td>{{ station.StationID }}</td>
<td>{{ station.StationName.Zh_tw }}</td>
<td>{{ station.LocationCity }}</td>
<td>{{ station.LocationTown }}</td>
<td>{{ station.BikeAllowOnHoliday }}</td>
<td>
<a target="_blank" [href]="station.StationPosition">
{{ station.StationPosition }}
</a>
</td>
</tr>
</tbody>
</table>

就結果來說,這兩個方法基本上都可以,但我個人非常推薦使用第二種方式。

原因是使用第二種的方式一方面可以避免我們在 Component 被 Destroy 時忘記解除訂閱而導致 Memory Leak 的情形,另一方面是 Observable 會比單純資料好用很多。

甚至有時候我們自己訂閱會發生「明明資料就有收到但畫面沒有更新」的詭異狀況。

結果:

Auto-Complete Searching View

Other Pipes

雖然目前運作良好,但還有一些小東西還沒處理完:

  1. 假日是否允許自行車進出站的欄位我想讓它呈現 或是
  2. 位置的欄位我想讓它以 latitude, longitude 的格式呈現。
  3. 連結我想要可以點擊後用新的頁籤打開 Google Map ,並會看到那個捷運站的位置。

以上這三個小東西非常地簡單,我想大家應該也都知道該怎麼做,但是既然都已經到了第二十四天了,這邊我覺得我們要使用 Pipe ,而不是像之前一樣直接寫在 Component 裡。

這是因為,如果像之前的 getErrorMessage 是寫在 Component 裡的話,其實當畫面渲染時,該函式就會被呼叫,不管該值有沒有被改變。

但是使用 Pipe 的話,在該值被改變前,是不會被呼叫第二次的。

再者,使用 Pipe 的話,重用性與可維護性也比較好。

所以我建議大家可以使用 Pipe 來完成最後的小調整。

我個人會建立三個 PipeBooleanInZhTwPipeGoogleMapLinkPipeLocationStringPipe

它們的程式碼如下:

1
2
3
4
5
6
7
8
9
@Pipe({
name: 'booleanInZhTw'
})
export class BooleanInZhTwPipe implements PipeTransform {

transform(value: boolean, ...args: unknown[]): string {
return value ? '是' : '否';
}
}
1
2
3
4
5
6
7
8
9
10
@Pipe({
name: 'googleMapLink'
})
export class GoogleMapLinkPipe implements PipeTransform {

transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `https://www.google.com/maps?q=${PositionLat},${PositionLon}&z=7`;
}

}
1
2
3
4
5
6
7
8
9
10
@Pipe({
name: 'locationString'
})
export class LocationStringPipe implements PipeTransform {

transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `${PositionLat}, ${PositionLon}`;
}

}

最終結果:

Auto-Complete Searching View

本日小結

今天的重點主要是:

  1. 學習如何使用 TDX API
  2. 學習如何使用 RxJS 的 Operator ─ startWithdebounceTimeswitchMapvalueChanges呼叫 API 串聯。
  3. 學習如何使用 AsyncPipe
  4. 學習如何自定 Pipe

今天的練習對於一些剛學 Angular 的朋友來說會滿精實且資訊量有點大的,大家可以多看幾遍,多自己練習、做實驗,相信對大家來說會很有幫助。

關於 RxJS ,如果大家想知道更多資訊,我推薦大家去看 Mike 的打通 RxJS 任督二脈系列文,或者是直接買實體書也行。

雖然今天的實作已經完成了,但還有測試的部份,我們明天來撰寫它吧!

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

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

Angular 深入淺出三十天:表單與測試 Day23 - Reactive Forms 進階技巧 - 欄位連動檢核邏輯

Day23

大家在日常生活中,應該看過滿多表單的某個欄位會隨著另個欄位的改變,而造成該欄位的驗證邏輯需要改變的情況吧?

舉例來說,可能會有個欄位叫做聯絡資訊,使用者可以選擇要填入手機號碼或者是 E-mail ,該欄位再根據使用所選擇的類型來檢核該欄位的值。

今天,我們就來用 Reactive Forms 實作這個欄位,而這個欄位我會實作在我們的被保人表單上,各位就隨意吧!

如果已經忘記被保人表單長怎麼樣的話,可以先回頭複習一下第十一天的文章:Reactive Forms 實作 - 動態表單初體驗

實作開始

首先,我們需要在原本的被保人表單裡新增一個欄位:聯絡資訊。

HTML 的部份大概會長這樣:

1
2
3
4
5
6
7
8
9
10
11
<p>
<label>聯絡資訊:</label>
</p>
<p>
<select>
<option value="">請選擇</option>
<option value="mobile">手機</option>
<option value="email">E-Mail</option>
</select>
<input type="text">
</p>

畫面看起來會像這樣:

Insured View

雖然聯絡資訊是一個欄位,但其實我們需要兩個 FormControl ,一個給下拉選單,一個給實際填值的 input 元素。

因此,我們要在原本的 createInsuredFormGroup 裡多加兩個欄位,像是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required],
contactInfoType: ['', Validators.required],
contactInfo: ['', Validators.required]
});
}

然後將剛剛新增的欄位與畫面的元素綁定:

1
2
3
4
5
6
7
8
<p>
<select formControlName="contactInfoType">
<option value="">請選擇</option>
<option value="mobile">手機</option>
<option value="email">E-Mail</option>
</select>
<input type="text" formControlName="contactInfo">
</p>

接著我們透過把資料印在畫面上的方式來檢查是否已正確綁定,像這樣:

1
<pre>{{ formGroup?.getRawValue() | json }}</pre>

結果:

Insured View

看起來已經有正確跟畫面上的元素綁定了,那接下來要怎麼做才好呢?

valueChanges

FormControl 的父類別 AbstractControl 有個屬性叫做 valueChanges ,它是一個 Observable

我們可以透過訂閱某個 AbstractControlvalueChanges 這個 Observable 來知道該欄位是否已經發生變化,並且做出相應的處理。

因此,我們可以這樣調整 createInsuredFormGroup 裡的實作:

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
private createInsuredFormGroup(): FormGroup {
const contactInfoTypeControl = this.formBuilder.control('', Validators.required);
const contactInfoControl = this.formBuilder.control('', Validators.required);
contactInfoTypeControl.valueChanges.subscribe((value) => {
switch (value) {
case 'mobile':
contactInfoControl.setValidators([Validators.required, Validators.pattern(/$09\d{8}^/)]);
break;
case 'email':
contactInfoControl.setValidators([Validators.required, Validators.email]);
break;
default:
contactInfoControl.setValidators([Validators.required]);
break;
}
contactInfoControl.updateValueAndValidity();
});

return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required],
contactInfoType: contactInfoTypeControl,
contactInfo: contactInfoControl
});
}

上述程式碼中有以下三個要點:

  1. 建立 FormControl 的時候可以藉由 this.formBuilder.control() 的方式建立,也可以直接使用 new FormControl() 建立,這點在前面的文章已經有提過,不過我在這邊再提醒大家一次。

  2. setValidators() 執行完後,記得一定要使用 updateValueAndValidity() 來更新當前欄位的驗證,不然就要等到該欄位的值有改變時才會以新的驗證器來驗證。

  3. 由於 contactInfoType 允許使用者選擇 請選擇 的選項,因此記得在 default 的區塊裡,將 Validators.required 給加回去。

這邊改好之後,我們也順便調整一下 getErrorMessage 的實作,讓使用者可以知道該欄位的驗證有誤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getErrorMessage(key: string, index: number): string {
const formGroup = this.formArray.controls[index];
const formControl = formGroup.get(key);
let errorMessage: string;
if (!formControl || !formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.minlength) {
errorMessage = '姓名至少需兩個字以上';
} else if (formControl.errors.maxlength) {
errorMessage = '姓名至多只能輸入十個字';

// 增加以下兩個判斷
} else if (formControl.errors.pattern) {
errorMessage = '手機號碼格式錯誤';
} else if (formControl.errors.email) {
errorMessage = 'E-mail 格式錯誤';
}

return errorMessage!;
}

這邊要提醒大家的是,由於驗證 E-mail 格式的方式我今天是用 Validators.email 的驗證器來驗,不是之前的 Validators.pattern() ,所以我可以直接用 formControl.errors.email 來判斷。

如果實作時,手機號碼跟 E-mail 都是用 Validators.pattern() 的驗證器來驗的話,就需要進一步去比對 formControl.errors.pattern 裡的 Regular Expression 來分辨究竟是手機號碼的格式錯誤還是 E-mail 的格式錯誤了。

像是這樣:

1
2
3
4
5
6
7
8
} else if (formControl.errors.pattern) {
const requiredPattern = formControl.errors.pattern.requiredPattern;
if (requiredPattern === '/A Regular Expression/') {
errorMessage = '手機號碼格式錯誤';
} else if (requiredPattern === '/B Regular Expression/') {
errorMessage = 'E-mail 格式錯誤';
}
}

如此一來,我們就完成這個欄位的功能囉!

結果:

Insured View

本日小結

今天的重點是學會如何使用 valueChanges 來動態調整相關欄位的驗證邏輯。

雖然是 Observable 是 RxJS 的東西,但今天並沒有太艱難或太複雜的運用,使用上的感覺會跟使用 Promise 的感覺類似,不過我個人認為 RxJS 好玩且強大許多。

關於 RxJS ,如果大家想知道更多資訊,我推薦大家去看 Mike 的打通 RxJS 任督二脈系列文,或者是直接買實體書也行。

雖然今天的實作已經完成了,但因為有調整程式碼的關係,測試程式碼其實也需要相應的調整才不會出錯,此部份就交給大家實作我就不再用篇幅分享實作囉!

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

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

Angular 深入淺出三十天:表單與測試 Day22 - 把 Cypress 變成 TypeScript 版

Day22

平常都用慣 TypeScript 版的 Cypress,但這兩天都用 JavaScript 在寫測試,令我有點不太習慣。

雖然 JS 版或 TS 版的差別並沒有多大,但少了一些開發時期的型別檢查與 Intellisense 還是令人感到彆扭。

因此,我們今天就來分享如何把 JS 版的 Cypress 變成 TS 版吧!

Angular 專案

首先,如果你的專案是 Angular ,預設不會配有任何 E2E 自動化測試工具,如果我們想要在 Angular 的專案使用 Cypress ,可以直接在終端機輸入以下指令:

1
$ ng add @cypress/schematic

等待它執行完成後,你會發現 Angular Schematics 除了幫你裝好 Cypress 之後,也在 package.json 裡的 scripts 區段增加了以下三個指令:

1
2
3
4
5
6
7
{
"scripts": {
"e2e": "ng e2e",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
}

並且在 angular.json 裡的 architect 區段添加了以下設定:

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
{
"cypress-run": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "ng-with-cypress:serve"
},
"configurations": {
"production": {
"devServerTarget": "ng-with-cypress:serve:production"
}
}
},
"cypress-open": {
"builder": "@cypress/schematic:cypress",
"options": {
"watch": true,
"headless": false
}
},
"e2e": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "ng-with-cypress:serve",
"watch": true,
"headless": false
},
"configurations": {
"production": {
"devServerTarget": "ng-with-cypress:serve:production"
}
}
}
}

e2e 的部份如果原本是使用 Protractor ,也會被調整過來。

這段設定的用意是讓 Angular CLI 知道,當我們要執行 cypress runcypress open 或是 ng e2e 的指令時,會連帶啟動 Angular 的服務,方便開發者使用時,不需額外自己啟動。

葛來芬多 Cypress 加 10 分!

此外,不可少的 cypress.json/cypress 資料夾當然也已經新增好了,而且 cypress.json 裡還已經幫我們配置了以下設定:

1
2
3
4
5
6
7
8
9
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200"
}

原本 /cypress 資料夾裡的 .js 檔也都變成了 .ts 檔,至此,我們就成功地把 Cypress 加入的 Angular 專案之中了,是不是超方便、超簡單的?!

Angular + Cypress 真的會把開發者寵壞

想知道什麼是 Angular Schematics 嗎?可以閱讀我的系列文:高效 Coding 術:Angular Schematics 實戰三十天

其他更多資訊,可以參考 Cypress 官方文件:https://docs.cypress.io/guides/migrating-to-cypress/protractor#Recommended-Installation

額外告訴大家一個小故事:其實這個 Schematics 原本不是官方維護的,這個 Schematics 的原身一開始是這個 @briebug/cypress-schematic ,不過後來被官方採用,才改由 Cypress 團隊維護。

衷心感謝所有曾經或正在為 Open Source 貢獻心力的每一個人。

其他類型專案

Angular 專案有 Angular Schematics ,但其他類型的專案或者是單單只有 Cypress 的專案怎辦?

別擔心,其實要做的事情也不會太繁瑣或困難。

首先,我們可以先在專案裡輸入以下指令以安裝 TypeScript :

1
$ npm install typescript --save-dev

or

1
$ yarn add typescript --dev

如果你的專案裡已經有安裝 TypeScript 的話請略過此步驟

然後在 /cypress 資料夾內新增一個 tsconfig.json 檔,並添加以下內容:

1
2
3
4
5
6
7
8
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["**/*.ts"]
}

然後就可以把我們的 .js 檔都改成 .ts 檔,並把所有的 /// <reference types="cypress" /> 都拿掉囉!

不過如果你本來的專案就是 TypeScript 的,這時候你可能會發現你原本非 E2E 測試的 .spec.ts 檔案多了一堆紅色毛毛蟲:

VSCode Capture

然後你將滑鼠游標移到紅色毛毛蟲上, VSCode 會跟你說:

VSCode Capture

但是如果我們實際跑測試的話,又都會通過,那到底為什麼會有紅色毛毛蟲呢?

其實這是因為, VSCode 以為原本非 E2E 測試的 .spec.ts 是 Cypress 的檔案,所以它把原本是 Jasmineexpect()

VSCode Capture

誤認為是 Chaiexpect()

VSCode Capture

那該怎麼辦才好呢?

其實會造成這個狀況是因為 VSCode 它預設會吃 tsconfig.json 的設定,而如果原本根目錄就有 tsconfig.json ,然後又在 /cypress 裡加了 tsconfig.json 的話,就會出現這種狀況。

這時我們只需要在根目錄的 tsconfig.json 加上這個設定就可以恢復正常了:

1
2
3
4
5
6
7
8
9
{
"include": [
"src",
"node_modules/cypress"
],
"exclude": [
"node_modules/cypress"
]
}

如果這部份有遇到問題的話,可以參考我的 Source Code 的設定。

不過別高興地太早,還有一件事情需要我們留意與調整。

自訂 Command

之前在 JS 版本使用自訂 Command 時,自訂的 Command 沒有 Intellisense 很不方便,而且參數也都沒有辦法定義型別,也增加了後續維護的困難度。

而現在我們升級成 TS 版本後,想要享受 TS 所帶來的好處之前,我們需要在我們的 command.ts 檔的開頭增加以下程式碼:

1
2
3
4
5
6
7
declare namespace Cypress {
interface Chainable {
// 這裡面擺放的是自訂 Command 的宣告
// 例如:
fillWith(account: string, password: string): Chainable<string>
}
}

原本的自訂 Command 的區塊也可以一併調整成這樣:

1
2
3
4
Cypress.Commands.add('fillWith', (account: string, password: string) => {
cy.get('#account').type(account);
cy.get('#password').type(password);
})

如此一來,我們在寫測試案例的時候即可享有 Intellisense 與型別檢查的好處囉!

想知道更多可以參考官方的 TypeScript Support 文件

本日小結

今天的重點主要是升級完成後,千萬記得要在 command.ts 加上 namespace 的宣告,這點可能會是很多人會不小心忘記的地方。

此外,也記得將 /// <reference types="cypress" /> 從程式碼中移除,這個語法主要是針對 JS 的,升級 TS 之後有它反而會錯。

我今天的實作程式碼會放在 Github - Branch: day22 上供大家參考,不過雖然該專案是 Angular 專案,但我是使用「其他專案」的方式,所以在測試時會需要自己啟動 Angular 的服務。

同時也建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

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

Angular 深入淺出三十天:表單與測試 Day21 - E2E 測試實作 - 被保人表單

Day21

大家如果對於昨天的 E2E 測試如果沒有什麼問題的話,今天就來為我們的被保人表單撰寫 E2E 測試吧!

實作開始

撰寫測試前的準備昨天有說過了,今天就不再贅述囉!不知道該幹嘛的朋友可以參考昨天實作開始的一開始的做了些什麼事情。

首先我們一樣先建立一個測試檔 insured-form.spec.js,然後打開剛建立的測試檔加上此句語法讓編輯器可以知道我們在寫 Cypress 以方便撰寫測試程式碼:

1
/// <reference types="cypress" />

原理昨天一樣有介紹過了,忘記或不知道的朋友可以複習一下昨天的文章

被保人表單的第一個 E2E 測試的測試案例

接著我們打開剛建立的測試檔,來寫我們的第一個 E2E 測試的測試案例,以驗證我們的環境已準備好。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
describe('Insured Form', () => {
beforeEach(() => {
cy.visit('http://localhost:4200');
cy.get('ul li').contains(title).click();
});

it('have title "Reactive Forms 實作 ─ 被保險人"', () => {
// Arrange
const title = 'Reactive Forms 實作 ─ 被保險人';
// Assert
cy.get('h1').should('have.text', title);
});
});

執行結果:

Testing Result

還記得之前在介紹 Test Runner 的時候有稍稍帶過 contains 這個 Command 嗎?

確切是在第 19 天的文章: 與 Cypress 的初次見面(下)

這次特別使用一次給大家看,因為如果不使用這個方式, CSS Selector 可能就要寫成: cy.get('ul li:last-child > a').click(); ,滿醜的。

當然根據官方的 Best Practice ,直接在上面加個 data-cy="insured-form-page-link" 的屬性是最好的。

原因一樣在第 19 天的文章: 與 Cypress 的初次見面(下) 有說明過,不知道的朋友可以回去複習一下。

撰寫測試案例

藉由第一個測試案例來驗證環境沒問題後,我們就可以正式來寫需求的測試案例了。

複習並整理一下要驗的案例:

  • 要可以新增被保險人
  • 要可以刪除被保險人
  • 輸入正確姓名與選擇年齡後,但沒選擇性別,送出按鈕為 disabled 的狀態
  • 輸入正確姓名與選擇性別後,但沒選擇年齡,送出按鈕為 disabled 的狀態
  • 選擇性別與年齡後,但沒輸入姓名,送出按鈕為 disabled 的狀態

程式碼如下:

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
describe('Insured Form', () => {
beforeEach(() => {
cy.visit('http://localhost:4200');
cy.get('ul li').contains('Reactive Forms 實作 ─ 被保險人').click();
});

it('have title "Reactive Forms 實作 ─ 被保險人"', () => {
// Arrange
const title = 'Reactive Forms 實作 ─ 被保險人';
// Assert
cy.get('h1').should('have.text', title);
});

it('should can add the insured', () => {
// Arrange
const name = 'Leo';
const gender = 'male';
const age = '18';
// Act
cy.get('[type="button"]').click();
cy.get('#name-0').type(name);
cy.get(`[for="${gender}-0"]`).click();
cy.get('#age-0').select(age);
// Assert
cy.get('[type="submit"]').should('be.enabled');
});

it('should can delete the insured', () => {
// Act
cy.get('[type="button"]').click();
cy.get('fieldset').contains('刪除').click();
// Assert
cy.get('fieldset').should('have.length', 0);
});

it('should can not add the insured when the age is not valid', () => {
// Arrange
const name = 'Leo';
const gender = 'male';
// Act
cy.get('[type="button"]').click();
cy.get('#name-0').type(name);
cy.get(`[for="${gender}-0"]`).click();
// Assert
cy.get('[type="submit"]').should('be.disabled');
});

it('should can not add the insured when the gender is not valid', () => {
// Arrange
const name = 'Leo';
const age = '18';
// Act
cy.get('[type="button"]').click();
cy.get('#name-0').type(name);
cy.get('#age-0').select(age);
// Assert
cy.get('[type="submit"]').should('be.disabled');
});

it('should can not add the insured when the name is not valid', () => {
// Arrange
const gender = 'male';
const age = '18';
// Act
cy.get('[type="button"]').click();
cy.get(`[for="${gender}-0"]`).click();
cy.get('#age-0').select(age);
// Assert
cy.get('[type="submit"]').should('be.disabled');
});
});

執行結果:

Testing Result

大家有覺得昨天寫過一次後,今天再寫一次有比較熟悉一點了嗎?

雖然這次驗的情境比較多,但我覺得如果大多的情境都已經有被整合測試覆蓋到的話,或許只需要驗證第一個情境就好。

不過在現實中,寫整合測試的人不一定跟寫 E2E 測試的人是同一個,所以寫 E2E 的人照著需求規格寫,多驗一點情境也是很好的。

在今天的測試程式碼中,比較值得一提的是使用 cy.select() 的使用,它的參數可以欲選擇選項的 value 值,或者是選項的名稱,更可以是選項的 index ,是非常方便的一個 Command 。

此外,在選年齡時,如果大家不是跟我一樣是點擊 Label ,而是直接點選 Radio Button 的話,記得要使用 cy.check() 的 Command。

Cypress 的錯誤訊息

不過就算寫錯也無所謂,因為 Cypress 這個貼心鬼其實都會跟你說你哪裡寫錯、可以怎麼寫。

例如剛剛說的 cy.select() ,如果我們使用 cy.click() , Cypress 就會跟你說你可以用 cy.select() 來替代唷!而且還會跟你說你寫錯的地方是在哪一行:

Error Message

又或者你使用了 cy.select() ,但忘記帶參數,它也會跟你說你漏了什麼參數:

Error Message

Cypress 真是個貼心鬼

撰寫了兩次的 E2E 測試之後,也累積了不少測試案例,這時候大家應該會發現有一些重複的東西散落在不同的測試檔案之中,又或者會有某些 Hard Code 在測試程式碼裡的東西應該要被抽出來,以利後續維護。

這時我們就可以善用在第 18 天的文章裡曾經提過 fixtures 與 Cypress 的 cypress.json 的配置來達成。

E2E 測試小技巧 ─ 環境變數

舉例來說,如果你的 E2E 的測試專案都是在測同一個網域的網頁,那我們就可以在 cypress.json 加上 baseUrl 的設置:

1
2
3
{
"baseUrl": "http://localhost:4200"
}

如此就可讓我們後續使用 cy.visit()cy.request() 或是 cy.intercept() 時,就可以不用再傳入一樣的字串。

而且這個用法還會有一個好處,就是當需要執行不同環境的測試時,我們可以用像是這樣子的方式來替換掉該變數:

1
$ CYPRESS_BASE_URL=https://product.domain.com cypress run

更多的環境變數小技巧請詳閱官方的 Environment Variables 文件。

E2E 測試小技巧 ─ fixtures

上述提到的環境變數一般常用在會因為測試環境改變時需要改變的值上,但其實還有很多值是不會因為環境改變而改變的,這時就可以用上現在這個小技巧。

這個小技巧其實我也有在第 18 天的文章 ─ 與 Cypress 的初次見面(上) 裡稍微提到過,就是我們可以在 /fixtures 的資料夾底下新增 .json 檔,然後我們可以將值放在裡面,需要的時候再從裡面拿。

像現在我們可以在 /fixtures 裡新增一個 insured-form.json 的檔案,然後內容大概會是這樣:

1
2
3
4
5
6
{
"title": "Reactive Forms 實作 ─ 被保險人",
"name": "Leo",
"gender": "male",
"age": "18"
}

然後在 insured-form.spec.js 就可以改成這樣:

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
import insuredForm from '../fixtures/insured-form.json';

describe('Insured Form', () => {
beforeEach(() => {
cy.visit('');
cy.get('ul li').contains(insuredForm.title).click();
});

it('have title "Reactive Forms 實作 ─ 被保險人"', () => {
// Arrange
const title = insuredForm.title;
// Assert
cy.get('h1').should('have.text', title);
});

it('should can add the insured', () => {
// Arrange
const name = insuredForm.name;
const gender = insuredForm.gender;
const age = insuredForm.age;
// Act
cy.get('[type="button"]').click();
cy.get('#name-0').type(name);
cy.get(`[for="${gender}-0"]`).click();
cy.get('#age-0').select(age);
// Assert
cy.get('[type="submit"]').should('be.enabled');
});

// 以下省略...
});

如此一來,未來當驗證的資料需要改變時,就只要到 /fixtures 裡的 insured-form.json 改就好,維護起來就更加輕鬆愉快囉!

今天我故意沒有用自訂 Command 的技巧來重構我的測試程式碼,大家不妨試著自己自訂看看吧!

本日小結

今天的重點主要是後面的兩個小技巧,這兩個小技巧對於日後大家真的在自己的專案或為公司專案撰寫 E2E 測試會非常有幫助,請務必多加熟悉。

不過平常都用 TypeScript 寫的我覺得很不習慣,明天就來分享怎麼樣把它變成 TypeScript 的版本吧!

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

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

Angular 深入淺出三十天:表單與測試 Day20 - E2E 測試實作 - 登入系統

Day20

經過這兩天的介紹,相信大家對於 Cypress 應該已經有了一定程度的理解,有沒有人已經開始用它來寫測試了呢?

今天就讓我帶著大家用 Cypress 來為我們的表單撰寫 E2E 測試吧!

實作開始

首先先輸入以下命令以啟動 Cypress 的 Test Runner :

1
$ npm run cy:open

或者是

1
$ yarn cy:open

如果你還不知道怎麼安裝 Cypress 或者是為什麼要輸入這個指令的話,請參考第 18 天的文章:與 Cypress 的初次見面(上)

接著就會看到熟悉的小視窗:

Cypress Test Runner Window

準備測試檔

之前在第 18 天的文章有介紹到,這些測試檔是官方產出的範例,如果大家嫌自己刪很麻煩的話,其實這個小視窗有提供一個方法可以一鍵刪除:

Cypress Test Runner Window

按下這個連結之後,它會顯示:

Cypress Test Runner Window

按確定之後,它就會幫你刪光光,非常的方便:

Cypress Test Runner Window

當然如果想留著也無所謂,只是順帶一提這個貼心的小功能。

然後我們就可以按下 + New Spec File 來建立我們的第一個 E2E 測試檔:

Cypress Test Runner Window

由於 E2E 測試是要模擬使用者的行為來操作真實的系統,所以在撰寫測試前,我們先確定要測的系統可以被訪問,待會才能夠使用 Cypress 來訪問它。

當然如果要測的系統有放在網路空間裡最好,沒有的話就在本地端啟動它即可。

第一個 E2E 測試的測試案例

接著我們打開剛建立的測試檔,來寫我們的第一個 E2E 測試的測試案例。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
describe('Login Form', () => {
it('have title "Template Driven Forms 實作 ─ 登入"', () => {
// Arrange
const title = 'Template Driven Forms 實作 ─ 登入';
// Act
cy.visit('http://localhost:4200');
// Assert
cy.get('h1').should('have.text', title);
});
});

執行結果:

Testing Result

雖然大家看我寫得好像很簡單,不過大家在實作時應該會有個疑問:怎麼都沒有 intellisense ?

intellisense 指的是當我們 Coding 時,編輯器會跟我們說有什麼方法可以使用的那個選單,有的人也會叫他 auto-complete 。

其實這是因為少了一句關鍵的語法:

Code Sample

只要大家將這個語法 /// <reference types="cypress" /> 放在檔案開頭,就可以在撰寫測試時有 intellisense 囉!

一開始我也沒注意到它,因為我平常是寫 TypeScript 的版本,所以我去查了一下這是什麼原理,原來這是早期 TypeScript 用來宣告依賴關係的方式,詳細大家可以參考我找到的網路文章:https://willh.gitbook.io/typescript-tutorial/basics/declaration-files#san-xie-xian-zhi-ling

撰寫測試案例

藉由第一個測試案例來驗證環境沒問題後,我們就可以正式來寫需求的測試案例了。

複習並整理一下要驗的案例:

  • 輸入正確格式的帳號與密碼,登入按鈕為 enabled 的狀態
  • 輸入不正確格式的帳號但正確格式的密碼,登入按鈕為 disabled 的狀態
  • 輸入正確格式的帳號但不正確格式的密碼,登入按鈕為 disabled 的狀態

程式碼如下:

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('Login Form', () => {

beforeEach(() => {
cy.visit('http://localhost:4200');
});

it('have title "Template Driven Forms 實作 ─ 登入"', () => {
// Arrange
const title = 'Template Driven Forms 實作 ─ 登入';
// Assert
cy.get('h1').should('have.text', title);
});

context('When typing the correct account and password', () => {
it('should can login', () => {
// Act
cy.get('#account').type('[email protected]');
cy.get('#password').type('12345678');
// Assert
cy.get('button').should('be.enabled');
});
});

context('When typing the incorrect account and the correct password', () => {
it('should can not login', () => {
// Act
cy.get('#account').type('abcdef');
cy.get('#password').type('12345678');
// Assert
cy.get('button').should('be.disabled');
});
});

context('When typing the correct account and the incorrect password', () => {
it('should can not login', () => {
// Act
cy.get('#account').type('[email protected]');
cy.get('#password').type('12345');
// Assert
cy.get('button').should('be.disabled');
});
});
});

執行結果:

Testing Result

大家有沒有覺得寫起來其實跟之前的單元測試與整合測試並沒有什麼太大的差別?

這是因為在撰寫測試的時候,大體上的觀念都是共通且雷同的,只有所使用的語法與 API 不同罷了。

雖然上述測試程式碼只驗了三個情境,但這是因為我覺得其實大多的情境都已經有被整合測試覆蓋到的緣故。

不過在現實情況裡,寫整合測試的人不一定跟寫 E2E 測試的人是同一個,所以就算驗比較完整一點也是很正常的。

E2E 測試小技巧 ─ 自訂 Command

雖說已經寫完測試了,但既然每個測試案例都需要輸入帳號密碼,那我們可以使用自訂 Command 的技巧來重構一下這段程式碼。

首先我們打開在 /support 資料夾底下的 commands.js ,大家應該會看到像這樣被註解起來的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

這些程式碼一樣也是 Cypress 幫我們產生的範例,主要是讓我們知道怎麼做才能自訂 Command

我們可以從中看到,其實有四種方式可以自訂 Command ,不過今天我們只會用到第一種。

想知道其他方式如何使用?請參考官方的 Custom Commands - Examples 文件。

首先取消 login 那一行的註解,並將程式碼改成這樣:

1
2
3
4
Cypress.Commands.add('fillWith', (account, password) => {
cy.get('#account').type(account);
cy.get('#password').type(password);
});

然後我們就能到 login-form.spec.js 裡將測試案例改成這樣:

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
context('When typing the correct account and password', () => {
it('should can login', () => {
// Arrange
const account = '[email protected]';
const password = '12345678';
// Act
cy.fillWith(account, password);
// Assert
cy.get('button').should('be.enabled');
});
});

context('When typing the incorrect account and the correct password', () => {
it('should can not login', () => {
// Arrange
const account = 'abcdef';
const password = '12345678';
// Act
cy.fillWith(account, password);
// Assert
cy.get('button').should('be.disabled');
});
});

context('When typing the correct account and the incorrect password', () => {
it('should can not login', () => {
// Arrange
const account = '[email protected]';
const password = '12345';
// Act
cy.fillWith(account, password);
// Assert
cy.get('button').should('be.disabled');
});
});

這樣看起來是不是更清爽、更好閱讀了呢?

此外,撰寫完測試之後,未來再執行測試時,就不會用同個模式跑,這個模式主要是用來開發測試程式碼用的,未來要再重複執行測試的話,我們一樣可以先在 package.json 裡的 script 區段加上:

1
2
3
{
"cy:run": "cypress run"
}

你也可以取自己喜歡的指令如 "e2e": "cypress run" ,不一定要跟我一樣。

之後就能直接用以下的指令執行 E2E 測試了:

1
$ npm run cy:run

或者是

1
$ yarn cy:run

執行結果:

Testing Result

而且你會發現 Cypress 幫你錄了執行測試時的影片,不妨點開來看看吧!

本日小結

今天的重點主要是在撰寫 E2E 測試的測試案例上與自訂 Command 的部份,不過經歷之前的練習後,大家應該只要稍微熟悉一下就能輕易上手了。

明天我們再來練習用 Cypress 為我們之前寫的動態表單撰寫測試,敬請期待!

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

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

Angular 深入淺出三十天:表單與測試 Day19 - 與 Cypress 的初次見面(下)

Day19

昨天跟大家初步地分享了 Cypress 怎麼安裝、 Cypress 的資料夾結構 、 Cypress Test Runner 有哪些功能、和 Cypress 提供的強大 Dashboard 的服務之後,大家有試著自己玩玩看嗎?

今天我想要分享給大家的是 Cypress 在執行測試時提供的功能。

可能有人會覺得,執行測試就執行測試,哪有什麼功能好分享的?

別急,且聽我娓娓道來。

The Test Runner

Cypress Test Runner Window

昨天介紹到這個 Test Runner 開啟的小視窗,我們可以直接點擊列表中的某個檔案來執行該檔案的測試。

今天我們就來先點擊第一個 todo.spec.js 以執行這個檔案的測試,點擊該檔案之後你會發現, Cypress 用你所指定的瀏覽器打開了一個視窗,接著大家會看到這個畫面:

Cypress Test Runner

Cypress 咻咻咻地就把事情做完了,是不是超快的?!

沒錯,Cypress Test Runner 後來開的這個瀏覽器裡面其實是有玄機的,他不僅僅只是把結果顯示出來而已。

Application Under Test

AUT

Cypress Test Runner 後來開的這個瀏覽器裡其實是一個互動介面,右邊這個區塊是實際受測的頁面,官方將其稱作為 AUT。

這個畫面不僅僅只是顯示而已,它是真實的頁面,所以我們一樣可以打開控制台、檢查元素與查看 CSS 設定。

不過 Cypress 其實是使用 iframe 的方式將其嵌在這個互動介面裡,也因此可能會遇到一些問題,官方有將可能的問題統整在這個文件裡,大家如果有遇到類似的問題可以到這個文件裡查詢。

而如果測試的語法有誤,它也會直接將錯誤訊息顯示在同一個地方:

AUT

網址列

AUT

在 AUT 的上方的網址列顯示的是當然是 AUT 的網址,不過該網址列是不能輸入的,點擊只會直接開新頁面以瀏覽該網頁而已。

Viewport

AUT Viewport

右上方所顯示的則是當前這個 AUT 所使用的視窗大小,單位是 px 。

而大小旁邊的 % 數指的是當前我們所看到的大小是實際受測大小的縮放比例。

如果想知道更多細節的話可以直接點它,它會跟你說要怎麼設置:

AUT Viewport

The Selector Playground

再來要介紹的是一個非常好用的功能,在網址列左方有一顆 icon 是準心的按鈕:

Selector Playground

點下去之後會出現這一列工具列:

Selector Playground

並且整個 AUT 會進入一個像是平常我們滑鼠右鍵檢查元素的模式,只要我們鼠標指到 AUT 任一元素上面,該元素就會變成藍色,並且會有個小小的 tooltip 跟你說這個元素的 CSS Selector 是什麼:

Selector Playground

點擊下去之後你會發現,上面的輸入框會變成你剛剛點擊的元素的 CSS Selector :

Selector Playground

這時我們可以點選右邊的複製按鈕以直接複製 cy.get('[data-test=new-todo]') 這一串文字:

Selector Playground

而複製按鈕旁邊的按鈕叫做 Print to console

Selector Playground

點擊這顆按鈕可以將被選取元素的資訊印到控制台裡面,印出來的資料長得會像是這樣:

Selector Playground

輸入框左邊的 cy.get 其實是可以切換的,點擊它會出現另外一個選項 cy.contains

Selector Playground

cy.contains 的功用是透過直接比對元素內是否有該字串來找尋元素,我們如果直接在輸入框輸入 Walk ,就會看到 Cypress 直接幫我們選取了 Walk the dog 這個代辦事項的元素:

Selector Playground

而輸入框右邊的數字也會讓我們知道該選擇器一共選取到了多少個元素:

Selector Playground

是不是非常地方便?我覺得這個工具會讓我們在撰寫測試案例的時候輕鬆許多,尤其如果你是需要撰寫 E2E 測試的 QA ,這個工具對你的幫助真的非常巨大,再也不用纏著前端工程師問該元素要怎麼選取,直接用這個工具就能輕鬆搞定!

而且這個工具所提供的 CSS Selector 可是有玄機在裡頭的!

Cypress 預設會照著以下順序的優先權來提供 CSS Selector 給我們:

  1. 是否擁有屬性 ─ data-cy
  2. 是否擁有屬性 ─ data-test
  3. 是否擁有屬性 ─ data-testidi 真的是小寫!,不是我打錯噢!)
  4. 是否擁有屬性 ─ id
  5. 是否擁有屬性 ─ class
  6. tag 名稱(該元素的名稱,如 inputdiv
  7. 其他屬性名稱
  8. 偽元素選擇器 ─ nth-child

因此,只要該元素有優先權比較高的選擇器,不用擔心你會得到像是 div > div > div > div > p 之類的 CSS Selector ,只要前端工程師在開發時有加上高優先權選取器,都會有著事半功倍的效果。

講到這裡其實一定會有人擔心:這樣不就要每個元素都加?這樣不就會需要加很多?

其實:

  1. 只要關鍵元素有加,讓寫 E2E 測試的人方便選取並與頁面互動即可。
  2. 如果寫 E2E 測試是必然,那增加方便選取的屬性名稱也是必然的。

謎之聲:不然你來寫 E2E 測試阿?!

不過這個優先權,官方其實是有提供 API 以供我們在有需要的時候加以調整,雖然我覺得並不是很必要,但有需要的人可以參考官方的 Selector Playground API 文件。

而基於這個優先權,官方也提供 Best Practice Guide - Selecting Elements 給大家,希望大家未來在實作時能盡量照這個方式實作。

Command Log

接下來是左半邊的重點 ─ Command Log :

Command Log

同樣地,這邊所顯示的東西也不僅僅只是單純的顯示結果,它一樣是個可以互動的介面。

Command Log

上方藍色的連結是受測檔案的位置,我們可以直接點擊它, Cypress 會幫我們用我們設定的 File Opener 來打開它。

如果想知道怎麼設定 File Opener 的話,我昨天其實有介紹過,大家可以回頭看一下昨天的文章。

Command Log

圖中列表裡,黑字且可點擊收合的項目,是我們在 .spec.js 裡所寫的 describe 或是 context

context 在 cypress 裡的功用與 describe 等同,唯一不同的大概就是在語意上,官方範例中的 context 會在 describe ,但 describe 不會在 context 裡。

而每個前面有勾勾 icon 的項目,則是我們在 .spec.js 裡所寫的每一個 it ,也就是每一個測試案例。

點擊任一個測試案例後,我們可以在它展開的區塊中看到該測試案例的所執行的 Command :

Command Log

而且你會發現,Cypress 還會告訴你這些 Command 是在 beforebeforeEachafterEachafter 這些 Hooks 中執行的,還是在 it 中執行的( TEST BODY 指的就是在 it 中執行的 Command ):

Command Log

此外,當我們的滑鼠游標滑到任一 Command 上,或是點擊任一 Command 時, Cypress 會直接幫我們還原在執行該 Command 時,頁面當前的狀況( DOM Snapshot ):

Command Log

就像坐上了時光機一樣!

Cypress 預設會幫我們每個測試案例都保留 50 筆的 DOM Snapshot 以供我們進行時空旅行

不過一旦測試案例一多,這其實是件很吃記憶體的事情。

因此, Cypress 也有提供設定讓我們可以調整保留的筆數,透過在 cypress.json 或者是 cypress.env.jsonnumTestsKeptInMemory 調小即可。

想調大當然也是沒有問題的!(笑)

還有,大家有沒有發現在滑鼠游標滑過每一個 Command 的時候,每一行 Command 開頭的數字會變成一顆圖釘?

這是因為 Cypress 有提供釘選的功能。

當我們想透過時空旅行去觀看某一個 Command 執行時的狀況,除了將滑鼠游標滑到該 Command 上之外,點擊該 Command 會將當前 AUT 的內容釘選在當下的那個狀況:

Command Log

同時, Cypress 也會幫我們資訊印出來,以供我們使用:

Command Log

如果覺得藍藍的很礙事,我們可以點擊這顆按鈕把它關掉:

Command Log

如果要恢復則再點擊一次即可。

而如果要解除釘選,則可以按旁邊這顆按鈕:

Command Log

也可以透過再次點擊當前釘選的 Command 或者是釘選其他的 Command 來解除對當前 Command 的釘選。

此外,如果釘選到的 Command 是有狀態切換效果的,像是 Checkbox 、 Radio button ,還會有 Before / After 的 Toggle Button :

Command Log

如此一來我們就可以知道在該行 Command 執行完之後,狀態有沒有如我們所預期的改變:

Command Log

Test Status Menu

Test Status Menu

最後,則是左上方的這個 Status Menu。

Test Status Menu

最左邊 < Test 的部分按下去之後,會把一開始的那個小視窗叫出來,這時我們可以再選擇其他的測試檔案來執行。

不過 Cypress 在這個模式下,一次只能執行一個檔案噢!

Test Status Menu

而旁邊這個勾勾跟叉叉應該不用我說大家應該都知道,是指通過測試跟沒通過測試的數量,右邊那個灰色圈圈則是指略過的測試數量。

Test Status Menu

再旁邊的數字則是執行完這個檔案的所有測試所耗費的時間,相信大家也都知道。

Test Status Menu

右邊這個橘色有上下箭頭的 icon ,可以用來切換在執行測試時,如果測試案例已經長到超過螢幕高度,是否要自動 Scroll 的功能:

Test Status Menu

最右邊的則是是重新執行所有測試案例的按鈕,沒什麼特別的:

Test Status Menu

不過,其實還有一個小秘密不知道大家有沒有發現,其實 View All TestDisabled Auto-scrollingRun All Tests 這三個按鈕是有快捷鍵的!

雖然不是什麼大不了的事情,但我覺得 Cypress 在很多小細節都很細心、很貼心,所以 Cypress 這個 E2E 自動化測試工具才會這麼好用!

本日小結

今天的重點主要是想讓大家清楚地知道和了解這個 Cypress Test Runner 提供了些什麼功能,相信之後在後續使用 Cypress 寫 E2E 測試的時候,大家會比較知道怎麼 debug 、或者是比較知道我在做什麼,而且使用上也一定會比較熟悉且順手。

明天我們就要開始正式用 Cypress 寫 E2E 測試囉,敬請期待!

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

Angular 深入淺出三十天:表單與測試 Day18 - 與 Cypress 的初次見面(上)

Day18

昨天跟大家分享了 Cypress 有多厲害之後,大家有沒有很期待呢?

這兩天就讓我來跟大家介紹 Cypress 到底有多厲害吧!

由於 Cypress 的功能非常地豐富且強大,所以我打算分成兩篇來介紹它,希望可以讓大家感受到它的魅力。

安裝 Cypress

要開始使用它之前,當然要先安裝它囉!

首先,我們新增一個空的資料夾,然後在終端機中輸入以下指令以進入該資料夾:

1
$ cd /your/project/path

接著輸入以下指令以完成初始化:

1
$ npm init

大家也可以選擇現有的專案,只要有 package.json 這個檔案即可。

然後輸入以下指令以安裝

1
$ npm install cypress --save-dev

安裝完成之後,你會發現你的專案除了 package.json 有變動之外,就沒有其他變動了。

別擔心!這不是因為你做錯了什麼,純粹就是 Cypress 剛安裝完就是這樣,接著我們可以先打開 package.json ,並且在 scripts 的區段加上這個指令:

1
2
3
4
5
{
"scripts": {
"cy:open": "cypress open"
}
}

大家不一定要像我一樣叫 cy:open ,可以自己取自己想要的名字,只要後面的 cypress open 不變即可。

修改好並儲存後,我們就可以在終端機裡以下指令以啟動 Cypress :

1
$ npm run cy:open

或者是

1
$ yarn cy:open

初次執行時,Cypress 會知道你這個專案第一次執行它:

Terminal capture

然後幫你產生出 cypress.json 與名為 cypress 的資料夾,並幫你開啟一個長這樣的小視窗:

Cypress window

這就表示我們順利完成 Cypress 的安裝囉!是不是超簡單的呢?!

資料夾結構介紹

接下來我們先來看看 Cypress 到底幫我們產生了些什麼檔案。

cypress.json

當大家點開它的時候應該會嚇一跳,因為剛開始時,它裡面就只有這樣:

1
{}

第一次使用 Cypress 的朋友應該會多少覺得有點錯愕,不知道這個檔案到底要用來幹嘛。

其實這個檔案是 Cypress 的設置檔,有關 Cypress 全局的配置都會在這裡設定。

那到底有哪些設定可以配置呢?

關於這點大家其實可以看官方的 Configuration 文件,裡面寫得非常清楚,我就不再贅述。

而且裡面的設定非常地多,雖然不一定都會用到,但也由此可見 Cypress 的功能是多麼地強大。

接下來,點開 cypress 資料夾後你會發現裡面還有四個名為 fixturesintegrationpluginssupports 的資料夾。

fixtures

這個資料夾主要是用來放一些在撰寫測試案例時,可能會常用到或共用的資料。例如:固定會輸入的帳密、固定會驗證的使用者資訊等等,並以 JSON 的形式存在。

後續使用時,大多會在 .spec.js 裡用像這樣子的方式直接引用:

1
const requiredExample = require('../../fixtures/example');

integration

這個資料夾是我們用來擺放 .spec.js 的地方。

比較值得一提的是,由於 Cypress 可以平行地執行多個不同的 .spec.js,所以我們在寫測試案例時可以善加利用此點,將不同系統或不會有依賴的測試分成不同的 .spec.js 來撰寫。

不過要反向思考的是,不同的測試檔之間就更不可以有依賴關係了。

plugins

這個資料夾裡有一個 index.js ,當我們需要用到一些外掛模組的時候,就會需要到這裡面來設定,例如我們可能會需要在驗證重設密碼這個功能時,要到信箱裡去確認是否有收到信、點開重設密碼的連結等等。

這點如果真的要仔細介紹起來可能會需要花一到兩篇的篇幅,所以如果大家有興趣的話,可以直接看官網的 Write a Plugin 來學習怎麼樣撰寫與使用 Plugin。

此外,官方也有列出它們精選的 Plugin 供大家參考與使用。

support

在 Cypress 裡,我們都是使用 cy.xxx 的方式來操作 Cypress 提供的 API ,而這些 API 在 Cypress 我們叫做 Command

雖然 Cypress 有提供許多的 Command 讓我們使用,不過我們其實也可以自定我們想要的 Command ,令我們在寫測試時更加地方便與輕鬆。

而這個資料夾就是用來擺放這些我們自定 Command 的地方。

打開資料夾後我們會看到裡面有兩個檔案 ─ index.jscommands.js ,其中的 commands.js 裡就是我們自定 Command 的地方。

index.js 則是用來 import 我們自定 Command 的檔案,執行時 Cypress 會自己從這裡去找到測試案例所需要用到的 Command ,不用特別在測試案例裡 import 。

在檔案命名上,當然也不一定要叫 commands.js ,你可以自己取你想要的檔名,只要記得在 index.js 裡 import 即可。

除了自定 Command 外,其實我們還可以覆寫既有的 Command ,語法大家可以參考官方的 Custom Commands 文件,後續我也會再分享給大家。

介紹完 Cypress 的資料夾結構後,我們回頭來看看 Cypress 打開的小視窗是什麼玩意兒吧!

Cypress Test Runner

Cypress Test Runner Window

這個小視窗其實是 Cypress Test Runner 所開啟的一個小視窗,一般開發時我們會使用 cypress open 的指令來啟動這個 Test Runner 的小視窗以便開發。

現在大家看到畫面中會有許多檔案,而這些檔案其實都是位於 /cypress/integration 之中, Test Runner 啟動時會幫我們把它們抓出來並顯示在上圖的列表中。

當我們想要測試某一個檔案裡的測試案例時,就只要點擊該檔案的名稱即可。

大家可以先點點看、玩玩看,點擊之後會做的事情我會在明天將它們更仔細地介紹給大家。

而這個 Test Runner 的小視窗其實有滿多滿強大的功能,例如在小視窗的右上角有個下拉選單可以選擇想要測試的瀏覽器:

Cypress Test Runner Window

這些瀏覽器是 Cypress 自動從你的作業系統中抓取的,只要你的作業系統有安裝 Cypress 所支援的瀏覽器,它就會成為這個下拉選單的選項。

想知道更多更詳細的資訊可以參考官方的 Launching Browsers 文件。

此外, 小視窗的上方有三個頁籤: TestsRunsSettings ,當前的畫面所顯示的是 Tests 的頁籤,我們點擊 Settings 的頁籤之後會看到以下畫面:

Cypress Test Runner Window

這邊比較重要且常用的會是 Configuration 的區塊,我們一樣點開它之後會看到許多設定:

Cypress Test Runner Window

我覺得這個功能非常地方便,因為這邊所顯示的設定是我們可以在 cypress.json 或者是 cypress.env.json 裡配置的所有設定。

有了這個之後,不用再為了找有哪些設定可以使用去看官網文件,甚至也透過不同顏色來得知當前的配置是來自於哪裡,非常地貼心!

至於 Configuration 後面的區塊, Node.js Version 是可以顯示目前所執行的 node 的版本:

Cypress Test Runner Window

細節可參考官方的 Configuration - Node Version 文件。

Proxy Settings 則是顯示目前的 Proxy 相關設定:

Cypress Test Runner Window

想知道如何設定 Proxy 可參考官方的 Proxy Configuration 文件。

File Opener Preference 則是可以設定你想要用什麼軟體來開啟測試檔(因為在測試中可以透過點擊檔名來開啟該檔案):

Cypress Test Runner Window

詳細請參考官方的 IDE Integration - File Opener Preference 文件。

最後 Experiments 則是顯示目前尚在實驗階段的設定:

Cypress Test Runner Window

詳細請參考官方的 Experiments 文件。

Runs 是做什麼用的呢?

Cypress Test Runner Window

這邊是如果你有使用它們家的 Dashboard 服務的話,可以直接在這邊看到歷史的執行結果。

Dashboard 服務?這又是什麼東西?

Cypress Dashboard

Cypress Dashboard 是 Cypress 它們家所提供的服務,主要是方便我們在使用 Cypress 來執行完自動化的 E2E 的測試後,把結果上傳到這裡來,已供後續觀測與整合第三方工具所用。

這又是一個很強大的工具,如果要介紹它又得花一整篇的文章,不過由於官網其實有非常詳盡的說明與影片,而且大家其實可以自己登入進去玩玩看,連結在這裡:https://dashboard.cypress.io/login

至於這個服務的收費方式,大家可以看這邊:https://dashboard.cypress.io/organizations/48db8376-6414-489b-b988-92233f50e335/pricing

想知道更詳細的資訊可以看官方的 Dashboard Introduction 文件。

不過值得一提的是,其實有個開源的工具叫做 Sorry Cypress ,它可以說是免費版的 Cypress Dashboard ,主要的核心功能如平行執行測試儲存歷史執行結果等都有,不過在介面的設計上比較陽春一些些、最重要的是需要自己架設、自己設定。

我覺得這就像我們自己架設 Jenkins 與使用別人所提供的 CI 工具如 Circle CI 的概念很像,就看我們的需求來決定要選擇哪一種工具囉!

本日小結

今天主要是跟大家介紹 Cypress 的基礎功能面,我想讓大家在對它有個基本的了解之後,接下來我們在使用時會比較知道它在幹嘛、有哪些功能,不會忽然講到一個功能之後要忽然中斷原本的進度來解釋它。

而明天的文章會著重在 Cypress 的 Test Runner 上(今天只有介紹到一小部分的功能),敬請期待!!

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

Angular 深入淺出三十天:表單與測試 Day17 - E2E 自動化測試工具簡介

Day17

在這個各種前端框架、開發工具層出不窮、百花齊放、百鳥齊鳴的美好時代, E2E 自動化測試工具的選擇自然也很多。

今天我們會先來初步了解一下目前有哪些 E2E 自動化測試工具,讓大家在未來需要時,能夠以最短的時間找到最貼近自己需求的工具。

Selenium

Selenium 的 logo

Selenium 是老牌的測試工具,出道已久且頗富盛名的它擁有豐富的 API 與衍生的工具軟體,可使用許多種語言撰寫,如:C#、JavaScript、Java、 Python 、 Ruby 。

主要是藉由 W3C WebDriver 所提供的 API (以前叫 Selenium WebDriver)。

TestCafe

TestCafe 的 logo

TestCafe 的主打是安裝與設置快速,且可以使用相對於 Selenium 來說,較少、較簡潔的程式碼來做到相同的操作。

主要原因是因為他們並不是以 W3C WebDriver 為基底,而是基於 Node.js 之上所開發的。

最強大的地方在於他們支援幾乎是目前市面上所有的瀏覽器, Chrome 跟 Firefox 我就不提了,其他還有 IE 、 Edge 、 Safari 、 Opera ,是至是跨瀏覽器的測試工具平台 BrowserStackLambdaTest

NightWatch

NightWatch 的 logo

NightWatch 也是用 Node.js 所寫的,不過跟 TestCafe 不一樣的是,雖然是用 Node.js 所寫,但其底層還是使用 W3C WebDriver API 來驅動瀏覽器執行操作。

不過它們家也是說它們可以在一分鐘內快速完成設定並啟動服務,有興趣的朋友可以試試看。

不過它們家的貓頭鷹 Logo 很可愛!

Puppeteer

Puppeteer 的 logo

Puppeteer 也是一個基於 Node.js 所開發的 E2E 測試工具,不過他是直接透過 Chrome 的 DevTools Protocol 來操控 Chrome 或 Chromium ,而且它預設會跑在 Headless 的模式下,非常方便。

除此之外,它所主打的功能有:

  • 可以產生出所測試頁面的螢幕擷圖和 PDF
  • 可以抓取 SPA (Single Page Application) 並將其元素都渲染出來

Angular 有個 SSG (Static Site Generation) 的框架 Scully 就是基於這件事情上所做出來的。

  • 自動化表單送出、UI測試、鍵盤輸入等事件
  • 使用最新版本的自動化測試環境、 JavaScript 並直接在最新版本的 Chrome 裡執行測試
  • 提供 Timeline trace 的功能以幫助診斷效能問題
  • 可以用來測試 Chrome 的 Extension

它們還有提供一個線上的 Playground ,大家有興趣可以玩玩看。

WebDriverIO

WebDriverIO 的 logo

WebDriverIO 號稱是下一個世代的 E2E 測試工具,它既可以使用 WebDriver 來達到跨瀏覽器測試的功能,也能像 Puppeteer 那樣使用 Chrome DevTools Protocol ,非常厲害。

Protractor

Protractor 的 logo

Protractor 是為 Angular 量身打造的 E2E 測試工具,而其根本也是使用 WebDriver 來驅動瀏覽器。

身為 Angular 御用的 E2E 測試工具以及 Angular 生態圈的一員,它的方便之處在於新增 Angular 專案時,一定也會連帶地將 Protractor 也給配置妥當。

不過隨著 Angular 征戰多年,Angular 在今年五月於 Angular v12 版本推出時宣布, 在 Angular v12 之後,Protractor 將不會再內建在新專案中,而預計將會在 Angular v15 時(大概是 2022 年尾), Angular 團隊會正式終結 Protractor 。

而目前 Angular 的官方團隊正在積極尋找其他的 E2E 測試框架夥伴,像上面有介紹到的 TestCafe 、 WebDriverIO 與稍後會介紹的 Cypress 都名列其中。

關於 Angular E2E 與 Protractor 的計畫,想要知道詳細情況的朋友可以閱讀官方的 RFC(請求意見稿)

想要知道 Angular v12 更新了什麼,可以參考我的部落格文章

Cypress

Cypress 的 logo

Cypress 有一句非常有趣的標語,叫做:

Test your code, not your patience.

大概是知道大家寫 E2E 測試時都寫的滿痛苦嗎?

此外,它還有一句標語叫做:

A test runner built for humans.

這是因為它們的主張 ─ 開發者友善,不管你是 QA 還是一般工程師都是一樣。

而且它還覺得它有七個地方跟別的 E2E 自動化測試工具不一樣:

  1. 它不使用 Selenium 框架

    因為它認為大多數的 E2E 測試工具都是基於 Selenium 的框架下所運作,這就是為什麼它們都會有相同的問題。

  2. 它專注於非常出色地進行 E2E 測試

    因為它們只想專注地在為 Web Application 撰寫 E2E 測試時,提供做出色的開發者體驗。

  3. 它適用於任何前端框架或是網站

    只要是可以在瀏覽器上跑的網頁它都可以測試。(不過我想應該是要它有支援的瀏覽器才行)。

  4. 它只用 JavaScript 來撰寫

    官方的原意是,因其測試程式碼是在瀏覽器上所執行,所以除了使用 JS 外,不需和任何的語言或是驅動程式綁定。

    不過我覺得這邊有一個隱含的意思,就是只要是可以編譯成 JavaScript 的,它都可以接受,就像是我個人目前是使用 TypeScript 來撰寫它,但其他的語言我就沒試過了。

  5. 它是一個 All-in-one 的框架

    就像 Angular 一樣不需自己去整合各個工具或函式庫,只要安裝 Cypress ,其他的它會幫我們搞定。

  6. 它適用於開發者和 QA

    它們想讓測試驅動開發這件事情變得更加容易,也意即它們的測試將會又好寫、寫得又快。

  7. 它執行地比其他框架要快的多

    官網的原意我覺得跟執行速度比較有關的地方是它可以併行運作並自動負載平衡這件事情。

至於瀏覽器支援度的部份,除了 Chrome 跟 FireFox 之外,也支援 Edge 、 Brave 甚至是 Electron

這麼多 E2E 自動化測試工具,你有比較喜歡哪一個嗎?

除了上述介紹的這七種 E2E 自動化測試工具之外,我相信一定還有其他的 E2E 自動化測試工具是我沒有介紹到的,不過族繁不及備載,如有遺珠之憾還請多加見諒。

本日小結

大家有沒有發現,其實大多數的測試框架都是透過 W3C WebDriver 來進行操作或者是驗證,比較特別一點的則是使用 Chrome 的 DevTools Protocol ,甚至是兩個都可以用。

但在這些 E2E 自動化測試工具裡,最特別的就是 Cypress ,而它其實也是我這次系列文要分享給大家的 E2E 自動化測試工具,後續的 E2E 測試也都將會分享如何使用 Cypress 來撰寫。

明天我更進一步地分享如何使用 Cypress,除了讓大家更進一步地了解這個框架之外,也讓大家如果在閱讀後續文章有任何不懂的地方可以回來複習。

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

Angular 深入淺出三十天:表單與測試 Day16 - Template Driven Forms vs Reactive Forms

Day16

這段期間,我們用 Template Driven FormsReactive Forms 各自做了一個登入表單(靜態)與被保人表單(動態),而且我們也都為這些表單寫了單元測試整合測試,大家應該對於這兩種開發表單的方式有一定的認知與體會。

因此,我們今天來將這兩種開發表單的方式做個小結與比較,順便沉澱一下這段時間的學習成果。

Template Driven Forms vs Reactive Forms

一般我們在對比這兩個開發表單的方式時,會用以下三個面向來分析優劣:

  1. 開發難易度
  2. 維護難易度
  3. 測試難易度

開發難易度

開發難易度指的是,開發者分別使用這兩種開發表單的方式開發同一個表單時的難易程度。

而這段時間,我也讓大家跟著我一起分別使用這兩種開發表單的方式開發了兩個表單,大家可以自己在心裡比較看看。

Template Driven Forms

Template Driven Forms 的方式很接近前端原始寫法,資料的驗證與限制都是使用 HTML 原生的表單機制,只是再額外加上 Angular 的資料綁定機制範本語法來處理,對於剛開始使用框架的初學者較為友善。

就像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
<input
type="email"
name="account"
id="account"
required
pattern="\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b"
#accountNgModel="ngModel"
[ngModel]="account"
(ngModelChange)="
accountValueChange(accountNgModel.value, accountNgModel.errors)
"
/>

Reactive Forms

Reactive Forms 的方式則是直接用程式來創建與操作表單物件 ─ FormGroup ,然後再把相應的 FormControl 綁定到畫面上的表單欄位。

就像這樣

1
2
3
4
5
const data = {
account: ['', [ /* Validators... */]]
password: ['', [ /* Validators... */]]
};
const formGroup = this.formBuilder.group(data);
1
2
3
4
5
6
7
8
9
10
11
12
<form [formGroup]="formGroup">
<input
type="email"
id="account"
formControlName="account"
/>
<input
type="password"
id="password"
formControlName="password"
/>
</form>

Template Driven Forms 相比,大部分的程式新手會覺得較為抽象,且會有相對來說比較多較困難、較不習慣的觀念要熟悉,學習成本較高

此外,如果有遇到更複雜的連動邏輯、更動態的表單,會比較需要對 RxJS 有更進一步的認知。

開發難易度小結

大部分剛學 Angular 的朋友應該都會覺得 Template Driven Forms 比較簡單,不過雖然一開始做的時候好像很簡單且自然,但表單一複雜起來,那沱 HTML 實在是會有點慘不忍睹。

這還是因為我們這段時間所做的表單其實很陽春、很簡單,如果我們要做的是有著更複雜的連動邏輯、更動態的表單,光想到要處理一堆事情就頭皮發麻、冷汗狂流。

Reactive Forms 初接觸時好像感覺很難,但隨著熟悉度的提升,大家一定會覺得它越來越好用,用它來開發又快又輕鬆,尤其當要做的表單越複雜、越動態時,更能體會它的美好。

我自己一開始使用 Angular 的時候也是只會使用 Template Driven Forms 的開發方式來開發,甚至還實作過頗為複雜的動態表單。直到我學會了使用 Reactive Forms 的開發方式之後,才發現之前做的表單有多麼可怕。

維護難易度

我這邊的維護難易度主要指的是以擴充性重用性這兩種面向來比較這兩種開發方式的難易程度。

Template Driven Forms

擴充性來說,假如我們一起開發過被保人表單需要新增一個產品欄位,並增加年齡與產品之間的互動邏輯時,除了要在 Template 上新增一個產品欄位與其必須的驗證之外,在 Component 裡也需要加上相應欄位有變動時,與其他欄位的互動、提示訊息的邏輯。

重用性來說,假如今天有同樣的表單欄位與驗證邏輯要在別的地方使用,但是畫面可能會長得很不一樣,抑或是只是其他表單裡的其中幾個欄位,這時為了要重用也會需調整不少程式碼。

好一點的情況可能只需要將這些欄位包裝起來並增加 input/output 的邏輯,差一點的情況大概就連重用都很困難,只能盡量把能抽的邏輯抽離,又或者把他們抽成最小最不會有影響的 Component 來使用。

Reactive Forms

擴充性來說,不管是要新增欄位還是調整結構,由於 Reactive Forms 本身就是用程式來建立表單,所以基本上都只需要在程式裡處理好,而 Template 就只是很簡單的增加該增加的欄位、並給予相應的綁定而已,如 formContorlName="xxx" ,輕鬆自在。

重用性來說,這件事在 Reactive Forms 看來更是小菜一碟。本來就是用程式建立表單的它,本身基本就具備非常良好的重用性,就算要把原本的表單抽成最小單位的 FormControl ,也只是像樂高積木一樣,需要的時候再組合起來就好。

維護難易度小結

簡單來說, Template Driven Forms 的開發方式有點像在煮義大利麵,煮完之後就很難去分離,雖然麵依然是麵、醬汁依然是醬汁,但麵已飽富醬汁、醬汁也難以再還原回原本的食材。

Reactive Forms 就像是樂高積木,具有豐富的可變性與卓越的彈性,你想要怎麼組合都可以,就算拼成樂高版的義大利麵,也是說拆就拆、說散就散。

雖然整體來說還是要看個人的功力,但就同一個人用這兩種方法來比較的話,應該還是會有差不多的結果。

測試難易度

測試嚴格來說應該是維護中的一環,因為每當程式碼有調整時,或者是需求有調整時,都有可能會影響到測試。

不過此處特別提出來比較主要是想要只在撰寫測試這件事情上來比較這兩種方式的難易度,尤其是我們這段時間總計寫了八篇的測試,大家應該會比較能感同身受。

Template Driven Forms

Template Driven Forms 在撰寫測試上也因為其方式很接近前端原始寫法的關係,我覺得還算好寫,只要檢查元素的屬性與其值即可。

但由於 Template Driven Forms 比較不可控,且其更新時機是非同步且比較不可預測的關係,造成想要把它的測試寫得很好並不容易。

就拿我們寫過的測試來說,我們在第七天第十三天時,都有著過相同的問題,而這問題,說不定其實是我寫不好,並不是框架本身的問題。

所以說,在某些特定情境下的測試案例,要寫得好其實並不容易。

Reactive Forms

Reactive Forms 的更新時機基本上是同步且可預測的,有什麼變化就可以直接驗證到,畢竟它本身就是用程式來建立表單,可控性很高。

同樣地拿我們寫過的測試來說,相信大家應該都沒有遇到什麼問題,不知道大家是否也覺得它的測試案例相對好寫呢?

測試難易度小結

撰寫測試的難易度其實很大程度地影響了開發人員是否會持續撰寫或維護測試程式的意願。

所以對開發人員來說,當然是越容易越好。

本日小結

今天主要是想明確地讓大家知道 Template Driven FormsReactive Forms 之間的不同與更清楚地對比,讓大家未來在遇到需要製作表單的情境時,可以根據需求來選擇最適合的方式。

下表總結了 Template Driven FormsReactive Forms 的不同之處:

  Reactive Forms Template Driven Forms
表單模型的設置 清楚的,在 Component 裡建立 隱晦的,用 Directive 建立
資料模型 有結構性且不變的 鬆散且容易改變
可預測性 同步的 非同步的
表單驗證方式 用函式驗證 Directive 驗證

雖說這段時間有分享如何使用 Template Driven Forms 的方式來開發表單,不過我個人在遇到要製作表單的情境時,其實都是選擇用 Reactive Forms 的方式來開發,因為實在是真的太好寫了!

此外,我們的後續文章也將不會再分享 Template Driven Forms 的開發方式,而是會用 Reactive Forms 的方式來分享更多更進階的用法,讓大家可以因應更複雜、更動態的情境。

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

Your browser is out-of-date!

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

×