Angular 深入淺出三十天:表單與測試 Day30 - 表單原理

Day30

經過前面二十九天的的練習與學習,相信大家應該在表單的實作上都熟悉了不少,只要不是太複雜、太特別的表單應該也都難不倒你們。

今天是本系列文的最後一天,就讓我們來好好地深入了解一下 Angular 表單會這麼強大的原因吧!

首先,我想趁大家記憶猶新時,先帶大家來看為什麼我們昨天可以用 ControlContainer 來自訂一個可以被 Template Driven Forms 或是 Reactive Forms 所使用的 Component 。

ControlContainer

Diagram

上圖是我根據 Angular 的 Source Code 找出 ControlContainer 的關係所畫的(斜體表抽象類別)。

從圖中我們會發現, ControlContainer 其實只是一個抽象類別,並繼承了另一個抽象類別 AbstractControlDirective ,而 AbstractControlDirective 這個抽象類別其實也被另一個抽象類別 NgControl 所繼承。

NgControl 晚點會提到,此處暫不多做說明。

至於 ControlContainer ,它其實也被 AbstractFormGroupDirectNgFormFormGroupDirectiveFormArrayName 這四個 Directive 所繼承;甚至 AbstractFormGroupDirect 還被 FormGroupNameNgModelGroup 這兩個 Directive 所繼承。

換句話說, Angular 根據 ControlContainer 為基底,做出了以下五個 Directive :

  • FormGroupDirective
  • FormGroupName
  • FormArrayName
  • NgForm
  • NgModelGroup

在這五個 Directive 裡,前面三個是為什麼我們在用 Reactive Forms 的方式來開發表單時,可以在 Template 裡用 [formGroup][formGroupName][formArrayName] 的方式將元素與 FormGroupFormArray 綁定的原因。

大家應該都還記得我們是怎麼將 FormGroupFormArray 綁定到元素上的吧?!

而後面兩個則是為什麼我們在用 Template Driven Forms 的方式來開發表單時,可以在 Template 裡在元素上使用 #XXX="ngForm'#XXX="ngModelGroup" 之後,可以拿到 NgFormNgModelGroup 的實體的原因。

雖然本系列文沒有特別提到 ngModelGroup 的用法,想知道的朋友可以參考官方的 NgModelGroup API 文件

那為什麼我們可以使用 ControlContainer 來自訂元件呢?

其實這正是因為上述五個 Directive 都透過 ControlContainer 這個令牌,把自己註冊到 Angular 的 DI 系統裡,讓想使用它們的類別,可以很方便地透過 Angular 的 DI 系統來找到它們的實體。

像是在昨天的文章裡所分享的那樣(Reactive Forms 的方式):

1
2
3
export class AddressInfoComponent {
constructor(private controlContainer: ControlContainer) { }
}

Template Driven Forms 則是透過 viewProvider 的方式,忘記的話請看昨天的文章

NgControl

Diagram

同樣地, Angular 也根據 NgControl 為基底,做出了以下三個 Directive :

  • FormControlName
  • FormControlDirective
  • NgModel

在這三個 Directive 裡,前面兩個是為什麼我們在用 Reactive Forms 的方式來開發表單時,可以在 Template 裡用 [formControl][formControlName] 的方式將元素與 FormControl 綁定的原因。

大家應該都還記得我們是怎麼將 FormControl 綁定到元素上的吧?!

而最後一個則是為什麼我們在用 Template Driven Forms 的方式來開發表單時,會在 Template 裡在元素上使用 #XXX="ngModel' 之後,可以拿到 NgFormNgModelGroup 的實體的原因。

不過,大家還記不記得我們在第二十八天的時候,是怎麼自訂表單元件的嗎?

沒錯!就是 ControlValueAccessor

上述三個 Directive 實作時,也是透過 DI 拿到實作了 ControlValueAccessor 介面的實體,並在初始化的時候透過 ControlValueAccessor 這個介面,搭建 FormControl 與實作了它的實體之間的溝通管道。

想看原始碼的朋友可以點我看原始碼。

如果想知道 @Self@Optional 裝飾器是幹嘛用的,可以參考這篇很前顯易懂的文章: @Self or @Optional @Host? The visual guide to Angular DI decorators.

但除了 ControlValueAccessor 之外,其實 Angular 還有其他內建的 ValueAccessor:

Diagram

從上圖中我們可以發現,內建的 ValueAccessor 基本上都是繼承於 BaseControlValueAccessor 這個類別,然後分別被 DefaultValueAccessorBuiltInControlValueAccessor 繼承,而只有 DefaultValueAccessor 有實作 ControlValueAccessor 這個介面。

然後 Angular 再基於 BuiltInControlValueAccessor 之上去建立了以下六個 ValueAccessor:

  • NumberValueAccessor
  • RangeValueAccessor
  • RadioControlValueAccessor
  • CheckboxControlValueAccessor
  • SelectControlValueAccessor
  • SelectMultipleControlValueAccessor

而這六個 ValueAccessor 對於我們的表單開發來說是至關重要的存在,沒有了它們,我們就沒辦法在這些元素上綁定我們的表單控制項。

但其實 BaseControlValueAccessorBuiltInControlValueAccessor 本身並沒有什麼比較特別的實作或定義,之所以會有 DefaultValueAccessorBuiltInControlValueAccessor 的區別,是為了在機制上,能夠做到一個優先權判斷的機制。

當我們在使用自訂的 ValueAccessor 時候, DefaultValueAccessor 或是上述六個 ValueAccessor 其實都有可能與我們自訂的 ValueAccessor 同時存在。

因此,在將我們的表單控制項與元素綁定的時候, Angular 會根據以下的優先權來抓取對應的 ValueAccessor :

  1. CustomValueAccessor
  2. BuiltInValueAccessor
  3. DefaultValueAccessor

如此一來,只要我們沒有自訂 ValueAccessor ,預設就是會使用內建的 ValueAccessor 來搭建表單控制項與元素之間的溝通橋樑。

我覺得 Angular 的開發團隊真的很聰明!

同步與非同步

除了上述的東西之外,其實還有一個比較特別的點,就是關於同步更新與非同步更新的問題。

Day16 - Template Driven Forms vs Reactive Forms 的小結裡,我分享了一個表格,表格裡提到了 Template Driven Forms 的可預測性是非同步的。

而這件事情其實可以在 NgModel原始碼第 222 行 看出一點端倪。

原始碼第 222 行這行是指 NgModel 在初始化的時候,會去設置表單控制項。

而在原始碼第 268 行中可以看到,它會判斷該 NgModel 是不是 _isStandalone ,也就是它是不是單獨存在,還是有被 <form></form> 包住。

如果是單獨存在, NgModel 的行為其實會跟 FormControlDirectiveFormControlName 一樣,因為他們會用一樣的方法設置表單控制項。

但如果不是單獨存在,它會用 NgFormaddControl 來設置表單控制項,這時,它就會是非同步的,因為他必須等到 resolvedPromise 發出 resolver 事件的時候,才會進行設置。

addControl 的實作在原始碼的第 187 行到 196 行

resolvedPromise 的定義則是在原始碼的第 27 行

我其實不太懂 Angular 為什麼要在 NgModel<form></form> 包起來的時候這樣子做,因為 resolvedPromise 其實也沒什麼特別的地方,但它們就讓 NgFormaddControlremoveControladdFormGroupremoveFormGroup 這四個方法要變成非同步的方式處理。

如果大家有興趣,或許找個時間研究一下,說不定,你就幫官方解決了一個問題呢!

本日小結

今天主要是幫本系列文做個結尾,希望能讓大家透過我的分享,更熟悉、更了解 Angular 一點。

也因為分享的關係,其實在撰寫本系列文的同時,我也得到了許多。

以前尚不熟悉的更熟悉了;以前不知道的知道了。

這或許就是所謂的「施比受更有福」吧?!

未來,還會不會再寫鐵人賽還不曉得(目前是不想再寫了,哈哈!)。

但是,寫鐵人賽真的是一個非常好的學習機會。

透過撰寫文章,來疏理自己所學,仔細咀嚼後再回饋給社群、回饋給社會。

如果你還沒寫過鐵人賽,我衷心推薦你這一輩子一定至少要寫一次鐵人賽。

最後,我想感謝訂閱我的文章、閱讀我的文章、喜歡我的文章的你們,謝謝你們不嫌棄,也謝謝你們願意讓我能夠幫到你們。

我也想感謝我的家人們,寫鐵人賽的這段期間,真的是非常地疏於陪伴,謝謝他們的支持(雖然他們看不到)。

感謝大家的收看,我們有緣再見! :)

其他資源

Angular 深入淺出三十天:表單與測試 Day29 - ControlContainer

Day29

昨天跟大家分享了自訂表單元件的作法,但昨天的作法只適用於一個欄位、一個 FormControl

雖然 FormControl 裡是可以設 {} 的值,但如果我們真的想要的是一個可以直接用 [formGroup][formArray] 所使用的元件呢?

沒問題,只要你想要, Angular 都給你

實作開始

大家還記得之前我們做了個「被保人表單」吧?

一開始只有「姓名」、「性別」跟「年齡」這三個欄位,後來我們加了「聯絡資訊」的欄位,這次我們再幫它加個「聯絡地址」的欄位吧!

一般來說,聯絡地址的欄位通常會分成「縣市」、「鄉鎮市區」、「郵遞區號」與「地址」,而「縣市」、「鄉鎮市區」與「郵遞區號」之間會有一些連動邏輯,縣市」與「鄉鎮市區」這兩個欄位也通常會是下拉選單,其他的則是一般的 input 欄位。

首先一樣先把 HTML 準備好,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
<p><label>聯絡地址:</label></p>
<p>
<select>
<option value="">請選擇縣市</option>
</select>
<select>
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號">
<input type="text" place="請輸入地址">
</p>

畫面:

Template View

樣式用 inline 的方式設定是方便教學,小朋友們要盡量少用噢!

接著,在 .ts 裡加入地址的相關欄位的 FormGroupFormControl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const addressInfoFormGroup = this.formBuilder.group({
city: '',
district: '',
zip: '',
address: ''
});

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

然後再將其綁與畫面上元素綁定,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ng-container formGroupName="addressInfo">
<p><label>聯絡地址:</label></p>
<p>
<select formControlName="city">
<option value="">請選擇縣市</option>
</select>
<select formControlName="district">
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號" formControlName="zip">
<input type="text" placeholder="請輸入地址" formControlName="address">
</p>
</ng-container>

連動邏輯的實作就交給大家練習囉,我們今天沒有要著重於此部分的處理。

至此,我們就完成了第一步的準備工作。

ControlContainer

接下來,我們就要將聯絡地址這塊拆成一個獨立的 Component ─ AddressInfoComponent

首先,先將 HTML 搬過去並稍微調整一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ng-container [formGroup]="formGroup">
<p>
<select formControlName="city">
<option value="">請選擇縣市</option>
</select>
<select formControlName="district">
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號" formControlName="zip">
<input type="text" placeholder="請輸入地址" formControlName="address">
</p>
</ng-container>

接著在 AddressInfoComponent 裡注入 ControlContainer

1
2
3
4
5
export class AddressInfoComponent {

constructor(private controlContainer: ControlContainer) { }

}

然後加上:

1
2
3
get formGroup(): FormGroup {
return this.controlContainer.control as FormGroup;
}

再回到被保人表單裡,把原本的聯絡地址區塊改成:

1
2
<p><label>聯絡地址:</label></p>
<app-address-info formGroupName="addressInfo"></app-address-info>

至此就大功告成了!是不是超簡單的?!

不過之所以這麼簡單是因為這是 Reactive Forms 的方式,今天的 ControlContainer 不像昨天的 ControlValueAccessor 可以做一次之後,兩種方式都可以使用。

如果今天這個元件是要讓 Template Driven Forms 使用的話,首先要先將 Template 原本用 Reactive Forms 的綁定方式改成使用 Template Driven Forms 的綁定方式,像是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ng-container ngModelGroup="addressInfo">
<p>
<select name="zip" ngModel>
<option value="">請選擇縣市</option>
</select>
<select name="district" >
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號" name="zip" ngModel>
<input type="text" placeholder="請輸入地址" name="address" ngModel>
</p>
</ng-container>

然後也不用在 AddressInfoComponent 裡注入 ControlContainer ,而是改在 AddressInfoComponentMetaDataviewProviders 裡新增以下設定:

1
2
3
4
5
6
7
8
9
10
11
12
@Component({
selector: 'app-address-info',
templateUrl: './address-info.component.html',
styleUrls: ['./address-info.component.scss'],
viewProviders:[
{
provide: ControlContainer,
useExisting: NgForm
}
]
})
export class AddressInfoComponent {

這樣就能直接用 <app-address-info></app-address-info> 的方式使用這個元件了。

大家覺得,是 Reactive Forms 的方式好用,還是 Template Driven Forms 的方式好用呢?

本日小結

今天的重點主要是讓大家知道要怎麼使用 ControlContainer 這個類別來包裝我們的元件,以達到提昇重用性維護性的目的。

雖然麻煩的是,它沒辦法像昨天分享的 ControlValueAccessor 一樣,做好了之後可以適用於 Template Driven FormsReactive Forms ,但好在它的用法其實頗為簡單,主要的差異就只有在 Template Driven Forms 需要靠 viewProvider ,而 Reactive Froms 只要注入就行。

關於 viewProvider 與 provider 的差異,我推薦大家可以去看 Kevin (台灣 Angular GDE)的 [Angular] viewProviders V.S. providers ,我覺得寫得非常的清楚。

此外,如果覺得我分享不好,也可以參考 Kevin 的 [Angular] ControlContainer 的應用

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

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

Angular 深入淺出三十天:表單與測試 Day28 - 自訂表單元件

Day28

經過了這段時間的練習與學習,相信大家應該越來越能體會 Angular 表單的強大與便利。

不過既然 Angular 表單這麼好用,如果能讓自己做的 Component 也像 Angular 表單那樣一般使用該有多好?

因此,今天想要跟大家分享的是 ─ 如何自訂表單元件

應用場景

大家跟我一起想像一下,假設我們今天需要做一個管理平台,在這個管理平台裡,會有很多地方都會需要用到我們昨天做的 DateRangeComponent ,但不一定會是在同一個表單裡,只是剛好也需要 startDateendDate 這兩個欄位,而且畫面與欄位驗證的規則也都是一樣。

例如:

A 頁面是一個查詢訂單系統, B 頁面是查詢會員系統,雖然這兩個頁面的查詢條件可能都不太一樣,但恰好都可以根據起迄日來查詢相應的資料。

這時,我們很有可能就會將我們做好的 DateRangeComponent 做成表單元件,讓 A 跟 B 在使用它的時候,就像使用一般的表單元件一樣輕鬆、自然。

那究竟要怎麼做呢?

ControlValueAccessor

首先要介紹給大家認識的是 ControlValueAccessor ,它是個 Interface ,而它定義了以下四個函式:

1
2
3
4
5
6
interface ControlValueAccessor {
writeValue(obj: any): void
registerOnChange(fn: any): void
registerOnTouched(fn: any): void
setDisabledState(isDisabled: boolean)?: void
}
  • writeValue(obj: any): void表單控件想要將值寫入時,會呼叫此函式
  • registerOnChange(fn: any): void表單控件初始化時會呼叫此函式,並傳入一個回呼函式,讓實作此介面的類別在其值有變動時,使用該回呼函式並傳入欲變動的值
  • registerOnTouched(fn: any): void表單控件初始化時會呼叫此函式,並傳入一個回呼函式,讓實作此介面的類別在失去焦點時,使用該回呼函式以通知表單控件
  • setDisabledState(isDisabled: boolean)?: void ─ 當表單控件的狀態變成 DISABLED 抑或是從 DISABLED 改變成其他狀態時,會呼叫此函式以通知實作此介面的類別

雖然我覺得我說的滿清楚的,但大家應該還是覺得很模糊,對吧?

不要緊,我只是先讓大家有個印象,待會實作時大家就會更加理解了。

實作開始

首先,我們需要另一個 Component 來用我們昨天做好的 DateRangeComponent ,像這樣:

1
2
3
<form *ngIf="formGroup" [formGroup]="formGroup">
<app-date-range formControlName="dateRange"></app-date-range>
</form>

然後在 Component 的 .ts 裡準備好 FormGroup ,像這樣:

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

formGroup: FormGroup | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
this.formGroup = this.formBuilder.group({ dateRange: '' });
}

}

接著打開昨天做的 DateRangeComponent ,並在 implements 的後方加上 ControlValueAccessor ,像這樣:

1
2
3
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
}

這時你應該會發現 DateRangeComponent 出現了一條紅色毛毛蟲,當你把滑鼠游標移到上面的時候,它說:

The Tooltip

這是因為我們為 DateRangeComponent 加上實作 ControlValueAccessor 的宣告後,編輯器提醒我們要記得實作 ControlValueAccessor 的四個函式,才符合該介面的定義。

這就像是我們如果想要 Cosplay 鋼鐵人,但我什麼盔甲都沒穿就說自己是鋼鐵人,別人只會覺得滿臉問號。

但只要我們戴上了頭盔,別人就會知道你在扮演鋼鐵人。

所以我們就在 DateRangeComponent 裡加上以下四個函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export class DateRangeComponent implements OnInit, ControlValueAccessor {

// ...

writeValue(obj: any): void {
console.log('writeValue', obj);
}

registerOnChange(fn: any): void {
console.log('registerOnChange', fn);
}
registerOnTouched(fn: any): void {
console.log('registerOnTouched', fn);
}

setDisabledState(isDisabled: boolean): void {
console.log('setDisabledState', isDisabled);
}

}

接下來,我們需要在 DateRangeComponentMetaData 裡的 providers 裡加入一些設定,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component({
selector: 'app-date-range',
templateUrl: './date-range.component.html',
styleUrls: ['./date-range.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateRangeComponent),
multi: true
}
]
})
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
}

我們之前其實也曾經在第二十五天的文章 ─ 測試進階技巧 - DI 抽換裡用過類似的技巧。

簡單來說,這個設定是為了讓表單可以透過 NG_VALUE_ACCESSOR 這個 InjectionToken 取得我們這個實作了 ControlValueAccessor 介面的 DateRangeComponent 實體。

想知道什麼是 InjectionToken 的朋友,可以參考 Mike 的 [Angular 大師之路] Day 23 - 認識 InjectionToken

想知道 useExistinguseValueuseClassuseFactory 有哪裡不一樣的,也可以參考 Mike 的 [Angular 大師之路] Day 20 - 在 @NgModule 的 providers: [] 自由更換注入內容 (1)[Angular 大師之路] Day 21 - 在 @NgModule 的 providers: [] 自由更換注入內容 (2)

forwardRef() 的部份,我覺得官網的 Dependency injection in action - Break circularities with a forward class reference 講得比較清楚。

最後的 multi: true ,可以參考林穎平 EP 的 [Day 8] 所以我說那個 multi 是? ,如果想要更深入的了解其原理,他也寫了一篇 [Day 10] 深度看一下 Angular 建立 multi provider 的機制(真的很深入)

至此,我們就可以儲存檔案來看一下初始化完後會印出的 Log :

Log

接著我們在使用 DateRangeComponent 的 Component 裡加上以下程式碼以觀察其運作結果:

1
2
3
4
5
6
7
8
9
10
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
dateRange: ''
});
setTimeout(() => {
console.log('---- 3秒後 ----');
this.formGroup?.setValue({ dateRange: 'Leo' });
this.formGroup?.disable();
}, 3000);
}

然後我們會發現:

Log

這樣大家有比較了解一開始關於 ControlValueAccessor 各函式的說明了嗎?

如果用圖示的話,現在的結構大概像這樣:

Image 1

如果我們設值給 FormControl 時,則會觸發 ControlValueAccessor 的函式 writeValue

Image 2

如果我們 disableenable 了該 FormControl ,則會觸發 ControlValueAccessor 的函式 setDisabledState

Image 3

而如果使用者改動了自訂的表單元件的值,則我們自訂的表單元件應該要呼叫透過初始化時所觸發的 registerOnChange 所傳入的 fn 去通知 FormControl

Image 4

讀萬卷書不如行萬里路。接下來,我們把剩下的實作做完就會更了解這其中的運作流程了!

首先,先加工一下使用 DateRangeComponent 的 Component :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export class ReactiveFormsDateRangeComponent implements OnInit {

formGroup: FormGroup | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
const date = new Date();
this.formGroup = this.formBuilder.group({
dateRange: `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
});
}

enable(): void {
this.formGroup?.enable();
}

disable(): void {
this.formGroup?.disable();
}

}

Template 的部份也加工一下:

1
2
3
4
5
6
7
8
<form *ngIf="formGroup" [formGroup]="formGroup">
<app-date-range formControlName="dateRange"></app-date-range>
<p>
<button type="button" [disabled]="formGroup.disabled" (click)="disable()">DISABLE</button>
<button type="button" [disabled]="formGroup.enabled" (click)="enable()">ENABLE</button>
</p>
</form>
<pre>{{ formGroup?.getRawValue() | json }}</pre>

然後把 DateRangeComponent 改成這樣:

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
export class DateRangeComponent implements OnInit, ControlValueAccessor {

formGroup: FormGroup | undefined;

fnFormRegisterOnChange: ((dateString: string) => void) | undefined;
fnFormRegisterOnTouched: (() => void) | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
this.formGroup = this.formBuilder.group({
startDate: ['', [Validators.required, Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
}, { validators: dateRangeValidator });

this.formGroup.valueChanges.subscribe(({ startDate, endDate }) => {
let dateString = startDate;
if (endDate) {
dateString += `, ${endDate}`;
}
if (this.formGroup?.errors) {
dateString = '';
}
if (this.fnFormRegisterOnChange) {
this.fnFormRegisterOnChange(dateString);
}
});
}

writeValue(dateRangeString: string): void {
const [startDate, endDate] = dateRangeString.split(', ');
this.formGroup?.patchValue({ startDate, endDate }, {
emitEvent: false
});
}

registerOnChange(fn: (dateRangeString: string) => void): void {
this.fnFormRegisterOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.fnFormRegisterOnTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.formGroup?.disable();
} else {
this.formGroup?.enable();
}
}

}

結果:

Result

對了,這樣的作法不僅僅只適用於 Reactive Forms 噢!大家可以在使用 DateRangeComponent 的時候用 Template Driven Forms 的方式試試看,也是行得通的唷!

本日小結

今天的實作練習應該滿好玩的吧?

我能理解大家第一次碰到的時候都會比較難以理解,記得我第一次碰到的時候,也只是複製人家的程式碼然後貼上而已,根本就不是了解其運作原理。

因此,希望我今天的文章能讓大家可以不僅僅只是複製貼上,而是對於其流程與原理有所掌握與理解。

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

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

Angular 深入淺出三十天:表單與測試 Day27 - Reactive Forms 進階技巧 - 跨欄位驗證

Day27

今天想要跟大家分享的是跨欄位驗證的小技巧,這個小技巧其實沒有多厲害或多特別,只是可能滿多人剛好不知道原來可以這樣用。

而我們在 Day 23 - Reactive Forms 進階技巧 - 欄位連動檢核邏輯 所分享過欄位連動檢核邏輯的部份,就某方面來說,其實也可以使用這種方式來做,但究竟要適不適合、要不要使用,我覺得一切都還是要看需求、看想給使用者什麼樣的使用體驗來決定。

畢竟系統是為了服務需求而存在,至於能不能做到、能不能解決問題就看工程師的功力囉。

實作開始

言歸正傳,我們今天要做的功能是起迄日日期欄位檢核

感謝我的朋友 ─ Joseph 所提供的案例讓我多活了一天

規格需求

詳細規格需求如下:

  • 起日
    • 必填,驗證有誤時需顯示錯誤訊息: 此欄位必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
  • 迄日
    • 非必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
    • 迄日不可早於起日,驗證有誤時需顯示錯誤訊息: 迄日不可早於起日
    • 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息: 迄日不可晚於起日超過七天
  • 以上驗證皆需在使用者輸入時動態檢查

準備畫面

接下來我們先把畫面準備好, HTML 如下:

1
2
3
4
5
6
7
8
9
10
<form>
<p>
<label for="start-date">起日:</label>
<input type="text" id="start-date" placeholder="yyyy-mm-dd">
</p>
<p>
<label for="end-date">迄日:</label>
<input type="text" id="end-date" placeholder="yyyy-mm-dd">
</p>
</form>

畫面應該會長這樣:

Template View

我知道一般大家在實作的時候會用漂亮的 UI 套件,不過我們現在主要聚焦在功能面,所以欄位的部份我只用簡單的 <input type="text"> 的方式實作。

其實我本來想至少用 <input type="date"> 來實作的,但它會害我們無法判斷使用者到底有沒有輸入值,所以最後還是放棄了使用它的打算。

準備 FormGroup

接著把 Reactive Forms 的 FormGroup 也準備好:

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

formGroup: FormGroup | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
this.formGroup = this.formBuilder.group({
startDate: [
'', [
Validators.required,
Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
]
],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
});
}

}

其實這邊的 Validators.requiredValidators.pattern(/^\d{4}-\d{2}-\d{2}$/) 可以不加,只不過後續就要把判斷寫在另一個地方,看大家想要稍稍彈性一點,還是直接寫死在另外一個地方都可以。

然後綁定到 Template 的表單上:

1
2
3
4
5
6
7
8
9
10
<form *ngIf="formGroup" [formGroup]="formGroup">
<p>
<label for="start-date">起日:</label>
<input type="text" id="start-date" placeholder="yyyy-mm-dd" formControlName="startDate">
</p>
<p>
<label for="end-date">迄日:</label>
<input type="text" id="end-date" placeholder="yyyy-mm-dd" formControlName="endDate">
</p>
</form>

再次提醒大家,在使用 Reactive Forms 的方式來開發表單時,請記得到 .module.ts 裡的引入 FormsModuleReactiveFormsModule

大家不要覺得我像老頭子一樣囉哩囉嗦的,都已經做了幾次的練習了還要一直提醒大家記得引入 FormsModuleReactiveFormsModule

相信我,如果我沒提醒,一定會有很多還不是很熟悉的朋友會卡住。

所以大家互相體諒包容一下,熟悉的朋友快速略過就好。

自訂驗證器 ─ dateRangeValidator

接著我們來用昨天分享過的自訂驗證器的的技巧來自訂一個名為 dateRangeValidator 的驗證器,程式碼如下:

1
2
3
4
export const dateRangeValidator: ValidatorFn = (formGroup) => {
console.log(formGroup.value);
return null
};

先把它掛在 FormGroup 上:

1
2
3
4
5
6
7
8
9
this.formGroup = this.formBuilder.group({
startDate: [
'', [
Validators.required,
Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
]
],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
}, { validators: dateRangeValidator });

然後我們就可以在控制台裡看到 ─ 當 FormGroup 裡的欄位的值有變動時,就會觸發我們自訂的驗證器:

Template View

一開始的四個 { startDate: '', endDate: '' } 是 FormGroup 在初始化的時後所觸發的。

其實今天要做的這個功能最關鍵、最重要的兩件事情就是:

  1. 實作自訂驗證器
  2. 把它掛在 FormGroup 上

所以我們已經做完了,今天的文章就分享到這邊。

謎之音:喂!你給我回來!(抓回)

接下來在實作驗證器之前,我想先制定該驗證器在驗證有誤時,所要回傳的 ValidationErrors 格式。

制定驗證器的 ValidationErrors

之所以想要先制定 ValidationErrors 的格式,一方面是因為待會實作驗證邏輯的時候需要用到;另一方面則是因為這個格式如果訂得好,後續實作時會輕鬆許多。

我的預期是這樣:

1
2
3
4
5
6
{
dateRange: {
startDate: null | ValidationErrors;
endDate: null | ValidationErrors;
}
}

這樣制定的意思是,如果起日欄位沒有錯誤,則 dateRange.startDate 的值會是 null ;而如果迄日欄位沒有錯誤,則 dateRange.endDate 的值會是 null ;又如果兩個欄位都沒錯誤,則該驗證器就會直接回傳 null

反之,如果起日欄位有錯誤,則 dateRange.startDate 的值會是我們接下來要制定的錯誤;迄日欄位亦然。

如此一來,如果驗證有誤時,我們比較能夠從驗證器所回傳的 ValidationErrors 來解析是哪個欄位有誤。

dateRange.startDatedateRange.endDate 究竟會有哪些錯誤呢?

先複習一下規格:

  • 起日
    • 必填,驗證有誤時需顯示錯誤訊息: 此欄位必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
  • 迄日
    • 非必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
    • 迄日不可早於起日,驗證有誤時需顯示錯誤訊息: 迄日不可早於起日
    • 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息: 迄日不可晚於起日超過七天

除了必填與日期格式的部份已經用官方提供的 Validator 外,其他的錯誤應該就剩下:

  1. 不存在的日期:

    1
    2
    3
    {
    inexistentDate: true
    }
  2. 迄日早於起日:

    1
    2
    3
    {
    lessThanStartDate: true;
    }
  3. 迄日晚於起日七天

    1
    2
    3
    4
    5
    6
    {
    greaterThanStartDate: {
    actualGreater: 8
    requiredGreater: 7
    }
    }

以上格式是我自訂的,大家可以不用跟我一樣沒關係。

接著我們可以把錯誤訊息稍微訂個 type ,以便後續使用:

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
export type DateRangeValidationErrors = {
dateRange: {
startDate: null | DateErrors;
endDate: null | DateErrors;
}
};

export type DateErrors =
| RequiredError
| PatternError
| InexistentDateError
| LessThanStartDateError
| GreaterThanStartDateError
| ValidationErrors;

export type RequiredError = {
required: true;
};

export type PatternError = {
pattern: {
actualValue: string;
requiredPattern: string;
}
};

export type InexistentDateError = {
inexistentDate: true;
};

export type LessThanStartDateError = {
lessThanStartDate: true;
};

export type GreaterThanStartDateError = {
greaterThanStartDate: {
actualGreater: number;
requiredGreater: number;
}
};

如此一來,我們差不多就可以開始來寫驗證器的邏輯囉!

實作驗證器的邏輯

首先,我們先處理判斷使用者所輸入的日期是否真實存在的邏輯。

舉例來說,大家覺得 2021-02-29 這個日期是存在的嗎?大家應該翻一下年曆就會知道,今年不是閏年,所以二月不會有第二十九天。

但是如果單純用 Date 來判斷,它其實可以算得出來:

1
2
console.log(new Date('2021-02-29'));
// Mon Mar 01 2021 08:00:00 GMT+0800 (Taipei Standard Time)

2021-02-31 呢?

1
2
console.log(new Date('2021-02-31'));
// Mon Mar 03 2021 08:00:00 GMT+0800 (Taipei Standard Time)

為什麼會這樣呢?

以上述例子來說, 用字串來建立 Date 的時候,它只會幫我們驗證兩件事情:

  1. 月份不可以超過 12
  2. 日期不可以超過 31

只要合乎上述這兩件事情,它就不會是 Invalid Date

那年份呢?我很無聊的幫大家試了一下,可以到 275759 年唷!

為了處理這件事情,我很偷懶的 Google 了一下大家的解法,最後借用了 Summer。桑莫。夏天JavaScript:檢查日期是否存在文中的程式碼,並且稍稍調整了一下以符合我的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const isDateExist = (dateString: string) => {
const dateObj = dateString.split('-'); // yyyy-mm-dd

//列出12個月,每月最大日期限制
const limitInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

const theYear = parseInt(dateObj[0]);
const theMonth = parseInt(dateObj[1]);
const theDay = parseInt(dateObj[2]);
const isLeap = new Date(theYear, 2, 0).getDate() === 29; // 是否為閏年?

if (isLeap) {
// 若為閏年,最大日期限制改為 29
limitInMonth[1] = 29;
}

// 月份不可以大於 12, 並比對該日是否超過每個月份最大日期限制
return theMonth < 12 && theDay <= limitInMonth[theMonth - 1];
}

感謝每一個願意分享的朋友。

準備萬全之後,再來就是把驗證器的判斷邏輯補完:

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
export const dateRangeValidator: ValidatorFn = (formGroup) => {
const startDateControl = formGroup.get('startDate')!;
const endDateControl = formGroup.get('endDate')!;

let errors: DateRangeValidationErrors = {
dateRange: {
startDate: null,
endDate: null,
}
};

if (startDateControl.errors) {
errors.dateRange.startDate = startDateControl.errors;
} else if (!isDateExist(startDateControl.value)) {
errors.dateRange.startDate = { inexistentDate: true };
}

if (endDateControl.errors) {
errors.dateRange.endDate = endDateControl.errors;
} else if (endDateControl.value) {
if (!isDateExist(endDateControl.value)) {
errors.dateRange.endDate = { inexistentDate: true };
} else if (!errors.dateRange.startDate) {
const startDateTimeStamp = new Date(startDateControl.value).getTime();
const endDateTimeStamp = new Date(endDateControl.value).getTime();
const dayInMilliseconds = 24 * 60 * 60 * 1000;
const duration = 7 * dayInMilliseconds;
if (endDateTimeStamp < startDateTimeStamp) {
errors.dateRange.endDate = { lessThanStartDate: true };
} else if (endDateTimeStamp - duration > startDateTimeStamp) {
errors.dateRange.endDate = {
greaterThanStartDate: {
actualGreater: (endDateTimeStamp - startDateTimeStamp) / dayInMilliseconds,
requiredGreater: 7
}
}
}
}
}

if (!errors.dateRange.startDate && !errors.dateRange.endDate) {
return null;
}
return errors;
};

結果:

Template View

看起來效果不錯,接下來就是把錯誤訊息接上囉!

ErrorMessagePipe

關於錯誤訊息的部份,今天就不把邏輯寫到 Component 的 .ts 裡了,來做個 ErrorMessagePipe 吧!

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Pipe({
name: 'errorMessage',
})
export class ErrorMessagePipe implements PipeTransform {
transform(errors: null | DateErrors, ...args: unknown[]): string {
if (errors) {
if ((errors as RequiredError).required) {
return '此欄位必填';
} else if ((errors as PatternError).pattern) {
return '日期格式不正確';
} else if ((errors as InexistentDateError).inexistentDate) {
return '此日期不存在';
} else if ((errors as LessThanStartDateError).lessThanStartDate) {
return '迄日不可早於起日';
} else if ((errors as GreaterThanStartDateError).greaterThanStartDate) {
return '迄日不可晚於起日超過七天';
}
}
return '';
}
}

接著再到 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
30
31
32
33
<h1>Reactive Forms 進階技巧 ─ 跨欄位驗證</h1>
<form *ngIf="formGroup" [formGroup]="formGroup">
<p>
<label for="start-date">起日:</label>
<input
type="text"
id="start-date"
placeholder="yyyy-mm-dd"
formControlName="startDate"
/>
<span
class="error-message"
*ngIf="formGroup.errors && formGroup.dirty"
>
{{ formGroup.errors.dateRange.startDate | errorMessage }}
</span>
</p>
<p>
<label for="end-date">迄日:</label>
<input
type="text"
id="end-date"
placeholder="yyyy-mm-dd"
formControlName="endDate"
/>
<span
class="error-message"
*ngIf="formGroup.errors && formGroup.dirty"
>
{{ formGroup.errors.dateRange.endDate | errorMessage }}
</span>
</p>
</form>

最終成果:

Template View

本日小結

今天主要想告訴大家的是 FormGroupFormArray 以及 FormControl 其實都可以設定 ValidatorAsyncValidator ,不管是在初始化時就設定還是初始化後再動態設定都沒問題。

但可能是因為沒有遇過需要用到的場景,所以滿多對 Reactive Forms 還不太熟的朋友還是會不知道。

雖說今天主要想讓大家的知道的是 FormGroup 上也可以設定 ValidatorAsyncValidator ,但寫著寫著又不知不覺寫了很多東西,希望這些東西都有幫助到大家。

此外, Template Driven Forms 當然也是可以跨欄位驗證,不過由於之前已經說過不會再分享 Template Driven Forms 的關係,所以有興趣的朋友可以參考官方的 Form Validation - Adding cross-validation to template-driven forms 的文件。

早知道就不要說不再分享,害自己少了好多篇可以寫,失策!

對了,測試大家可以練習寫寫看,我就不實作給大家看囉!

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

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

Angular 深入淺出三十天:表單與測試 Day26 - 進階表單開發技巧 - 自訂驗證器

Day26

之前在開發表單的時候,我們都是使用 Angular 所提供的驗證器來驗證表單欄位裡的值是否符合我們的需求。

雖然 Angular 已經這麼貼心地提供了這麼多驗證了,但每個國家、地區的人文風土民情都不同,還有太多太多需要我們自己自訂規則才能符合需求的情況。

因此,今天我們就一起來看看要怎麼自訂驗證器吧!

自訂驗證器的型別

既然要自訂驗證器,就不能不知道驗證器的型別與其定義。

其實之前在第三天的文章:Reactive Forms 實作 - 以登入為例 裡就有提到驗證器的型別。

ValidatorFn

驗證器的型別是 ValidatorFn ,其原始碼定義如下:

1
2
3
interface ValidatorFn {
(control: AbstractControl): ValidationErrors | null
}

從定義中我們可以知道,驗證器其實就只是一個函式,該函式會傳入一個型別為 AbstractControl 的參數來讓我們在函式中判定該欄位的值是否符合我們的需求。

如果驗證結果符合需求,那就回傳 null ,代表沒有任何的錯誤;如果驗證結果不符合需求那就回傳一個型別為 ValidationErrors 的錯誤。

ValidationErrors 又是什麼呢?

ValidationErrors

ValidationErrors 之前最早是在第二天的文章: Template Driven Forms 實作 - 以登入為例 裡登場。

其原始碼定義如下:

1
2
3
type ValidationErrors = {
[key: string]: any;
};

沒錯,你沒看錯,就是這麼簡單!

從定義上看起來,基本上只要是個物件,就符合該型別的要求,而這也是因為滿足客製的條件,讓使用 Angular 的開發者有程度的規範但擁有盡可能大的彈性。

不過雖然大家可以隨意自訂,但我非常建議大家在自訂的時候可以參考官方的驗證器。

舉例來說,官方的 Validators.required 驗證器在驗證有誤時,會回傳的 ValidationErrors 是:

1
{ required: true }

Validators.pattern 驗證器在驗證有誤時,會回傳的 ValidationErrors 是:

1
2
3
4
{
actualValue: 'xxx',
requiredPattern: 'xxx'
}

Validators.minlengthValidators.minlength 驗證器在驗證有誤時,則會回傳:

1
2
3
4
{
actualLength: 1,
requiredLength: 2
}

我們從中不難發現官方會在 ValidationErrors 中,回傳該欄位當前的狀態以及需求的狀態;而物件的屬性名稱也會按照 actual 加上 XXXX 以及 required 加上 XXXX 的方式來命名。

雖然具體上還是要看實際需求,但我個人覺得我們自己在自訂驗證器的 ValidationErrors 時,也可以照著這個規則來處理。

一方面,整個系統會比較一致;另一方面,也不需要多寫太多額外的程式來處理我們自訂的錯誤。

舉個例子,如果我們想自訂一個欄位的值只能是 Leo 的驗證器,其程式碼可能會像是這樣:

1
2
3
4
5
6
7
8
9
10
export leoValidator: ValidatorFn = (control: AbstractControl) => {
const isLeo = control.value === 'Leo';
if (isLeo) {
return null
}
return {
actualValue: control.value,
requiredValue: 'Leo'
};
};

如此我們就在需要用到它時,直接像這樣使用即可:

1
new FormControl('', leoValidator);

又或者是彈性一點,讓使用它的人來決定該欄位的值只能是什麼,其程式碼應該會像是這樣:

1
2
3
4
5
6
7
8
9
10
11
export function nameValidator(name: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === name) {
return null
}
return {
actualValue: control.value,
requiredValue: name
};
};
}

然後就可以像這樣使用:

1
new FormControl('', nameValidator('Leo'));

不過, ValidatorFn 是用同步的方式來執行驗證,萬一遇到需要非同步驗證的情況要怎麼辦?

非同步驗證器

大家應該都滿喜歡玩遊戲的吧?!

謎之音:不要自己愛玩就認為別人都愛玩

絕大多數的遊戲,尤其是線上遊戲,在取名時不能夠取跟別人相同的名字,而當取到跟別人一樣的名字時,系統會提示「此名稱已被使用」之類的錯誤訊息。

面對這種應用場景,相信大部分的朋友可能會是使用 valueChanges 來訂閱欄位的變化事件,當使用者輸入名稱時,會呼叫 API 讓後端來幫忙驗證該名字是否已被使用,然後再根據回傳結果來決定是否顯示錯誤訊息。

畢竟前端不可能事先取得幾千、幾萬甚至是幾十萬、幾百萬的名字再一一比對吧?

不過如果有這樣的應用場景,我個人覺得還滿適合使用非同步驗證器來處理的。

順帶一提,這只是我個人舉例,不代表真實應用情況。

AsyncValidator

1
2
3
4
5
6
7
interface AsyncValidator extends Validator {
validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null>

// inherited from forms/Validator
validate(control: AbstractControl): ValidationErrors | null
registerOnValidatorChange(fn: () => void)?: void
}

從上述定義可以看出,跟同步的驗證器所不一樣的是,我們在自訂非同步的驗證器時,不是直接製作一個符合 AsyncValidatorFn 定義的函式

而是要用一個可被注入的 Class 來實作 AsyncValidator 這個介面。

就像下面這個官網的範例一樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Injectable({ providedIn: 'root' })
export class UniqueAlterEgoValidator implements AsyncValidator {
constructor(private heroesService: HeroesService) {}

validate(
ctrl: AbstractControl
): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
return this.heroesService.isAlterEgoTaken(ctrl.value).pipe(
map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),
catchError(() => of(null))
);
}
}

使用方式

為我們欄位設定非同步驗證器的方式也非常地簡單。

FormControl 來說, 我們可以用這樣子的方式來設定:

1
new FormControl('', [/* 一般驗證器 */], [/* 非同步驗證器 */]);

簡單來說,不論是用 Reactive Forms 的哪種方式建立欄位,非同步驗證器都是放在一般驗證器後面就對了!

本日小結

希望透過今天的分享,能讓大家可以初步掌握自訂驗證器的技巧。雖然在實務上,大家不一定遇的到需要使用非同步驗證器的場景,但如果真的有需要用到又忘記怎麼做時,至少有這篇文章在,隨時都可以回來查詢。

此外,雖然沒有分享自訂 Template Driven Forms 驗證器的方式,但大家可以自行參考官方的 Form Validation - Adding custom validators to template-driven forms 文件。

而在非同步驗證器的部份,官網也有提到一些優化非同步驗證器的技巧,大家可以參考官方的 Form Validation - Optimizing performance of async validators 文件。

以上,就是今天的文章,如果有任何的問題或是回饋,還請麻煩留言給我讓我知道,感激不盡!

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

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

Your browser is out-of-date!

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

×