Angular 深入淺出三十天:表單與測試 Day14 - 單元測試實作 - 被保人 by Reactive Forms

Day14

今天我們要來為我們用 Reactive Forms 所撰寫的被保人表單寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第十一天的文章: Reactive Forms 實作 - 動態表單初體驗。

實作開始

複習一下目前的程式碼:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
export class ReactiveFormsAsyncInsuredComponent implements OnInit {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 用以取得 FormArray
*
* @readonly
* @type {FormArray}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}

/**
* 綁定在送出按鈕上,判斷表單是不是無效
*
* @readonly
* @type {boolean}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get isFormInvalid(): boolean {
return this.formArray.controls.length === 0 || this.formGroup!.invalid;
}

/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
}

/**
* 新增被保人
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
addInsured(): void {
const formGroup = this.createInsuredFormGroup();
this.formArray.push(formGroup);
}

/**
* 刪除被保人
*
* @param {number} index
* @memberof ReactiveFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.formArray.removeAt(index);
}

/**
* 送出表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
submit(): void {
// do login...
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
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 = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 建立被保人的表單
*
* @private
* @return {*} {FormGroup}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
}

以目前的程式碼來看,我們要驗的單元一共有以下這些函式:

  • formArray
  • isFormInvalid
  • ngOnInit
  • addInsured
  • deleteInsured
  • getErrorMessage

以下就按照順序來撰寫測試吧!

開始撰寫測試案例前,記得先處理好依賴,如果忘記的話,可以先回到第六天的文章複習,我就不再贅述囉!

不過今天的測試案例幾乎都建立在 ngOnInit 被觸發後的情況之下,所以這次我打算直接把 fixture.detectChanges() 放在一開始的 beforeEach 裡,這樣就不用在每個測試案例加了。

像這樣:

1
2
3
4
beforeEach(() => {
// 其他省略
fixture.detectChanges();
});

測試單元 - formArray

這個單元很單純,基本只要驗在 ngOnInit 被觸發後,可以取得 formArray 即可。

程式碼如下:

1
2
3
4
5
6
7
8
describe('formArray', () => {
it('should get the FormArray from the FormGroup after "ngOnInit" being trigger', () => {
// Act
const formArray = component.formGroup?.get('insuredList') as FormArray;
// Assert
expect(component.formArray).toBe(formArray);
});
});

測試結果:

testing result

測試單元 - isFormInvalid

這個單元基本上要測三個狀況:

  1. formArray 裡的 controls 的長度為 0 時,回傳 true
  2. formGroup 裡有任何 errors 時,回傳 true
  3. formArray 裡的 controls 的長度不為 0 且 formGroup 裡也沒有任何 errors 時,回傳 false

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
describe('isFormInvalid', () => {
it('should be true when there are not any insureds', () => {
// Act
const expectedResult = component.isFormInvalid;
// Assert
expect(expectedResult).toBe(true);
});

it('should be true when there are any errors', () => {
// Arrange
const formControl = new FormControl('', Validators.required);
component.formArray.push(formControl);
// Act
const expectedResult = component.isFormInvalid;
// Assert
expect(expectedResult).toBe(true);
});

it('should be false when there are not any errors', () => {
// Arrange
const formControl = new FormControl('');
component.formArray.push(formControl);
// Act
const expectedResult = component.isFormInvalid;
// Assert
expect(expectedResult).toBe(false);
});
});

測試結果:

testing result

測試單元 - ngOnInit

ngOnInit 要驗證的情況也很簡單,就是看執行完有沒有順利地把 formGroup 建立出來。

不過要驗證到什麼地步就看個人了,例如我們可以很簡單地這樣子驗:

1
2
3
4
5
6
7
8
describe('ngOnInit', () => {
it('should initialize property "formGroup"', () => {
// Act
fixture.detectChanges();
// Assert
expect(component.formGroup).toBeTruthy();
});
});

也可以驗稍微仔細一點:

1
2
3
4
5
6
7
8
describe('ngOnInit', () => {
it('should initialize property "formGroup"', () => {
// Act
fixture.detectChanges();
// Assert
expect(component.formGroup).toBeInstanceOf(FormGroup);
});
});

驗得越粗糙,測試對你的單元保護力越低;反之則越高。所以就看你想要提供給你要測的單元怎麼樣的保護。

測試結果:

testing result

測試單元 - addInsured & deleteInsured

這兩個單元就更沒難度了,一個只是驗證執行後, formArray 的長度有沒有增加;另一個則是減少 formArray 的長度。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('addInsured', () => {
it('should push a "formGroup" into the "formArray"', () => {
// Act
component.addInsured();
// Assert
expect(component.formArray.length).toBe(1);
});
});

describe('deleteInsured', () => {
it('should remove the "formGroup" from the "formArray" by the index', () => {
// Arrange
const index = 0;
const formGroup = new FormGroup({});
component.formArray.push(formGroup);
// Act
component.deleteInsured(index);
// Assert
expect(component.formArray.length).toBe(0);
});
});

測試結果:

testing result

我知道一定有人會有一個疑問:「為什麼測 deleteInsured 的時候, Arrange 的部分不直接用 component.addInsured() 就好,還要自己敲?」。

這是因為我們要做到測試隔離,大家還記得嗎?不記得的趕快回去翻第五天的文章:如何寫出優秀的測試?

大家可以想想,如果今天我們真的使用了 component.addInsured() ,之後哪一天 addInsured 這個函式被改壞了不就也連帶導致了 deleteInsured 這個不相干的測試也會跑失敗嗎?

雖然廣義一點來講,一個跑失敗跟兩個跑失敗貌似沒什麼區別,都是失敗。但在實質意義上來說就差很多,這點務必請大家銘記在心。

測試單元 - getErrorMessage

最後是大家都非常熟悉的 getErrorMessage ,有沒有一種整天都在測這個案例的感覺?

雖然前面都測得比較隨便粗糙,我們這個單元測仔細一點好了。

要驗證的項目如下:

  • 如果用錯誤的 key 值導致找不到對應的 FormControl ,則回傳空字串。
  • 如果該欄位沒有任何錯誤,則回傳空字串。
  • 如果該欄位的 pristine 為 true,則回傳空字串。
  • 如果該欄位的有 required 的錯誤,則回傳 此欄位必填
  • 如果該欄位的有 minlength 的錯誤,則回傳 姓名至少需兩個字以上
  • 如果該欄位的有 maxlength 的錯誤,則回傳 姓名至多只能輸入十個字

程式碼如下:

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
79
80
81
82
83
describe('getErrorMessage', () => {
let formGroup: FormGroup;

beforeEach(() => {
const nameControl = new FormControl('', [
Validators.required,
Validators.minLength(2),
Validators.maxLength(10)
]);
formGroup = new FormGroup({
name: nameControl,
});
component.formArray.push(formGroup);
});

it('should return empty string with the wrong key', () => {
// Arrange
const key = 'leo'
const index = 0;
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('');
});

it('should return empty string when the "formControl" without errors', () => {
// Arrange
const key = 'name'
const index = 0;
formGroup.get(key)?.setValue('Leo');
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('');
});

it('should return empty string when property "pristine" of the "formControl" is `true`', () => {
// Arrange
const key = 'name'
const index = 0;
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('');
});

it('should return "此欄位必填" when the "formControl" has the required error', () => {
// Arrange
const key = 'name'
const index = 0;
formGroup.get(key)?.markAsDirty();
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('此欄位必填');
});

it('should return "姓名至少需兩個字以上" when the "formControl" has the min-length error', () => {
// Arrange
const key = 'name'
const index = 0;
const formControl = formGroup.get(key)!;
formControl.setValue('A')
formControl.markAsDirty();
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('姓名至少需兩個字以上');
});

it('should return "姓名至多只能輸入十個字" when the "formControl" has the max-length error', () => {
// Arrange
const key = 'name'
const index = 0;
const formControl = formGroup.get(key)!;
formControl.setValue('ABCDEF123456')
formControl.markAsDirty();
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('姓名至多只能輸入十個字');
});
});

測試結果:

testing result

今天所有測試的結果:

testing result

本日小結

跟昨天一樣的是,其實測試手法大致上差不多就這些,當然更複雜的情境會用到其他的手法,但目前主要還是以讓大家多熟悉、多練習為主,後面才會提到更複雜的情況。

我個人覺得,提高撰寫測試的功力不外乎就是練習以及多跟他人交流,所以如果在公司沒人可以幫你 code review 或是你也不會幫其他人 code review 的話,是很可惜的一件事。

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day13 - 整合測試實作 - 被保人 by Template Driven Forms

Day13

昨天幫我們用 Template Driven Forms 所撰寫的被保人表單寫完單元測試之後,今天則是要來為它寫整合測試。

大家還記得整合測試的目標是要測什麼嗎?我幫大家複習一下:

整合測試的測試目標是要測是兩個或是兩個以上的類別之間的互動是否符合我們的預期。

實作開始

首先我們先增加一個 Integration testing 的區塊,有關於整合測試的程式碼接下來都會放在這裡面,至於昨天的就放在 Unit testing 的區塊:

1
2
3
4
5
6
7
8
9
10
11
describe('TemplateDrivenFormsAsyncInsuredComponent', () => {
// 其他省略...

describe('Unit testing', () => {
// 昨天寫的單元測試...
});

describe('Integration testing', () => {
// 今天要寫的整合測試
});
});

跟之前樣先打開 .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
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
<form>
<fieldset *ngFor="let insured of insuredList; let index = index; trackBy: trackByIndex">
<legend>被保人</legend>
<p>
<label [for]="'name-' + index">姓名:</label>
<input
type="text"
[name]="'name-' + index"
[id]="'name-' + index"
required
maxlength="10"
minlength="2"
#nameNgModel="ngModel"
[ngModel]="insured.name"
(ngModelChange)="insuredNameChange(nameNgModel.control, insured)"
/>
<span class="error-message">{{ insured.nameErrorMessage }}</span>
</p>
<p>
性別:
<input
type="radio"
[name]="'gender-' + index"
[id]="'male-' + index"
value="male"
required
[(ngModel)]="insured.gender"
>
<label [for]="'male-' + index">男</label>
<input
type="radio"
[name]="'gender-' + index"
[id]="'female-' + index"
value="female"
required
[(ngModel)]="insured.gender"
>
<label [for]="'female-' + index">女</label>
</p>
<p>
<label [for]="'age-' + index">年齡:</label>
<select
[name]="'age-' + index"
[id]="'age-' + index"
required
#ageNgModel="ngModel"
[ngModel]="insured.age"
(ngModelChange)="insuredAgeChange(ageNgModel.control, insured)"
>
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ insured.ageErrorMessage }}</span>
</p>
<p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
</fieldset>
<p>
<button type="button" (click)="addInsured()">新增被保險人</button>
<button type="submit" [disabled]="isFormInvalid">送出</button>
</p>
</form>

大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:

  • 姓名欄位
    • 屬性 type 的值要是 text
    • 屬性 name 的值要是 name-N
    • 屬性 minlength 的值要是 2
    • 屬性 maxlength 的值要是 10
    • 要有屬性 required
    • 要將被保人的屬性 name 的值綁定到此欄位上
    • 此欄位的值如果有變動,要能觸發函式 insuredNameChange
  • 性別欄位
    • 屬性 type 的值要是 radio
    • 屬性 name 的值要是 gender-N
    • 要有屬性 required
    • 要將被保人的屬性 gender 的值綁定到此欄位上
  • 年齡欄位
    • 屬性 name 的值要是 age-N
    • 要有屬性 required
    • 要將被保人的屬性 age 的值綁定到此欄位上
    • 此欄位的值如果有變動,要能觸發函式 insuredAgeChange
  • 錯誤訊息
    • 要將被保人的的屬性 nameErrorMessage 的值綁定到畫面上
    • 要將被保人的的屬性 ageErrorMessage 的值綁定到畫面上
  • 新增被保人按鈕
    • 按下按鈕要能觸發函式 addInsured
  • 刪除被保人按鈕
    • 按下按鈕要能觸發函式 deleteInsured
  • 送出按鈕
    • 屬性 type 的值要是 submit
    • 沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
    • 任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
    • 當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit

把要測的項目都列出來之後,有沒有覺得要測的項目很多阿?哈哈!

再次跟大家說明,雖然上面這些項目有些其實並不真的屬於整合測試的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼。

此外,開始之前也別忘記先做以下程式碼所展示的前置作業:

1
2
3
4
5
6
7
8
9
10
describe('Integration testing', () => {
let compiledComponent: HTMLElement;

beforeEach(() => {
fixture.detectChanges();
compiledComponent = fixture.nativeElement;
});

// 案例寫在這邊
});

姓名欄位的驗證

複習一下姓名欄位的驗證項目:

  • 屬性 type 的值要是 text
  • 屬性 name 的值要是 name-N
  • 屬性 minlength 的值要是 2
  • 屬性 maxlength 的值要是 10
  • 要有屬性 required
  • 要將被保人的屬性 name 的值綁定到此欄位上
  • 此欄位的值如果有變動,要能觸發函式 insuredNameChange

接下來就把姓名欄位要驗證的項目寫成測試案例:

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
79
80
81
describe('the insured fields', () => {
beforeEach(() => {
component.insuredList = [{
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
fixture.detectChanges();
});

describe('the name input field', () => {
const key = 'name-0'
let nameInputElement: HTMLInputElement;

beforeEach(() => {
nameInputElement = compiledComponent.querySelector(`#${key}`)!;
});

it('should have attribute "type" and the value is "text"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'text';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "name-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = key;
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "minlength" and the value is "2"', () => {
// Arrange
const attributeName = 'minlength';
const attributeValue = '2';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "maxlength" and the value is "10"', () => {
// Arrange
const attributeName = 'maxlength';
const attributeValue = '10';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(nameInputElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "name"', () => {
// Arrange
const name = 'whatever';
// Act
component.insuredList[0].name = name;
fixture.detectChanges();
// Assert
expect(nameInputElement.getAttribute('ng-reflect-model')).toBe(name);
});

it('should trigger function "insuredNameChange" when the value be changed', () => {
// Arrange
spyOn(component, 'insuredNameChange');
const nameFormControl = component.nameNgModelRefList.get(0)!.control;
// Act
nameInputElement.value = 'whatever';
nameInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.insuredNameChange).toHaveBeenCalledWith(nameFormControl, component.insuredList[0]);
});
});
});

測試結果:

testing result

這段程式碼中有幾個重點:

  1. 為了之後測其他欄位,我多新增了一個 test insured fields 的 describe 。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 beforeEach 來做。

  2. should have attribute "name" and the value is "name-0" 這個測試案例要記得我們在 Template 綁定時是用 [name] 的方式綁定,所以在驗證的時候是抓 ng-reflect-name ,如果單純抓 name 來驗是會報錯的噢!

  3. should trigger function "insuredNameChange" when the value be changed 最後這個測試案例比較特別,不知道大家還記不記得上次寫這裡的時候,我有介紹過關於 Spy 的事情與怎麼用 @ViewChild 抓 Template 中的 nameFormControl ?

如果不記得的話,趕快回去第七天的文章複習一下!

上次用的 @ViewChild 是抓取單一的元素,但這次是複數的怎辦?

答案是 ─ @ViewChildren 。

有沒有一種寫 Angular 還可以學英文的感覺?

只要我們像這樣在程式碼中加上這個 Angular 的裝飾器:

1
2
3
4
export class TemplateDrivenFormsAsyncInsuredComponent {
@ViewChildren('nameNgModel') nameNgModelRefList!: QueryList<NgModel>;
// ...
}

Angular 就會在每次渲染完畫面之後,幫我們抓取有在 HTML 的屬性中加上 #nameNgModel 的所有元素,而抓出來的元素會用 Angular 所包裝的類別 ─ QueryList 包起來,以利我們使用。

性別欄位的驗證

性別欄位的驗證項目如下:

  • 男生
    • 屬性 type 的值要是 radio
    • 屬性 name 的值要是 male-N
    • 屬性 value 的值要是 male
    • 要有屬性 required
    • 要將被保人的屬性 gender 的值綁定到此欄位上
  • 女生
    • 屬性 type 的值要是 radio
    • 屬性 name 的值要是 female-N
    • 屬性 value 的值要是 female
    • 要有屬性 required
    • 要將被保人的屬性 gender 的值綁定到此欄位上

測試程式碼如下:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
describe('the gender radio buttons', () => {
let radioButtonElement: HTMLInputElement;

describe('male', () => {
beforeEach(() => {
radioButtonElement = compiledComponent.querySelector(`#male-0`)!;
});

it('should have attribute "type" and the value is "radio"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'radio';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "gender-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = 'gender-0';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "value" and the value is "male"', () => {
// Arrange
const attributeName = 'value';
const attributeValue = 'male';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(radioButtonElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "gender"', () => {
// Arrange
const gender = 'male';
// Act
component.insuredList[0].gender = gender;
fixture.detectChanges();
// Assert
expect(radioButtonElement.getAttribute('ng-reflect-model')).toBe(gender);
});
});

describe('female', () => {
beforeEach(() => {
radioButtonElement = compiledComponent.querySelector(`#female-0`)!;
});

it('should have attribute "type" and the value is "radio"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'radio';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "gender-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = 'gender-0';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "value" and the value is "female"', () => {
// Arrange
const attributeName = 'value';
const attributeValue = 'female';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(radioButtonElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "gender"', () => {
// Arrange
const gender = 'female';
// Act
component.insuredList[0].gender = gender;
fixture.detectChanges();
// Assert
expect(radioButtonElement.getAttribute('ng-reflect-model')).toBe(gender);
});
});
});

這邊的測試雖然簡單,但我還是遇到了一個問題:「怎麼驗雙向綁定裡,關於 ngModelChange 的部份」。

我的預期是我點擊了某個性別的單選鈕之後,它會把值指定給被保人的 gender 欄位。

但我試了好幾種驗法,也查了老半天資料,就是沒辦法成功(攤手),如果有朋友成功驗出來,請麻煩在下方留言分享一下,感謝!

測試結果:

testing result

年齡欄位的驗證

年齡欄位的驗證項目如下:

  • 屬性 name 的值要是 age-N
  • 要有屬性 required
  • 要將被保人的屬性 age 的值綁定到此欄位上
  • 此欄位的值如果有變動,要能觸發函式 insuredAgeChange

程式碼如下:

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
describe('the age field', () => {
const key = 'age-0'
let ageSelectElement: HTMLSelectElement;

beforeEach(() => {
ageSelectElement = compiledComponent.querySelector(`#${key}`)!;
});

it('should have attribute "name" and the value is "age-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = key;
// Assert
expect(ageSelectElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(ageSelectElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "age"', () => {
// Arrange
const age = '18';
// Act
component.insuredList[0].age = age;
fixture.detectChanges();
// Assert
expect(ageSelectElement.getAttribute('ng-reflect-model')).toBe(age);
});

it('should trigger function "insuredAgeChange" when the value be changed', () => {
// Arrange
spyOn(component, 'insuredAgeChange');
const ageNgModel = component.ageNgModelRefList.get(0)!;
// Act
ageSelectElement.value = '18';
ageSelectElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.insuredAgeChange).toHaveBeenCalledWith(ageNgModel.value, ageNgModel.errors, component.insuredList[0]);
});
});

年齡欄位的驗證跟姓名的驗證有 87% 像,複製過來再稍微調整一下即可。

測試結果:

testing result

錯誤訊息的驗證

錯誤訊息要驗證的項目是:

  • 要將被保人的屬性 nameErrorMessage 的值綁定到畫面上
  • 要將被保人的屬性 ageErrorMessage 的值綁定到畫面上

測試程式碼如下:

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
describe('Error Messages', () => {
it('should binding the value of the insured\'s property "nameErrorMessage" in the template', () => {
// Arrange
const insured = component.insuredList[0];
const errorMessage = 'account error';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
insured.nameErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should binding the value of the insured\'s property "ageErrorMessage" in the template', () => {
// Arrange
const insured = component.insuredList[0];
const errorMessage = 'password error';
const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
// Act
insured.ageErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});

錯誤訊息的驗證也非常簡單,大家應該都能輕鬆驗證!

測試結果:

testing result

刪除按鈕的驗證

刪除被保人按鈕要驗證的是:按下按鈕要能觸發函式 deleteInsured 。這部份大家只要使用 Spy 的技巧來驗證即可,也是頗為簡單。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
describe('Delete insured button', () => {
it('should trigger function `deleteInsured` after being clicked', () => {
// Arrange
const index = 0;
const deleteButtonElement = compiledComponent.querySelector('fieldset button[type="button"]') as HTMLElement;
spyOn(component, 'deleteInsured');
// Act
deleteButtonElement.click();
// Assert
expect(component.deleteInsured).toHaveBeenCalledWith(index);
});
});

測試結果:

testing result

新增被保人按鈕的驗證

新增被保人按鈕要驗證的是:按下按鈕要能觸發函式 addInsured ,跟刪除被保人的按鈕要驗證的項目幾乎是一模一樣,複製過來稍微修改一下即可。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
describe('add insured button', () => {
it('should trigger function `addInsured` after being clicked', () => {
// Arrange
const addButtonElement = compiledComponent.querySelector('p:last-child button[type="button"]') as HTMLElement;
spyOn(component, 'addInsured');
// Act
addButtonElement.click();
// Assert
expect(component.addInsured).toHaveBeenCalled();
});
});

測試結果:

testing result

送出按鈕的驗證

最後,送出按鈕要驗證的項目是:

  • 屬性 type 的值要是 submit
  • 沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
  • 任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit

程式碼如下:

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
describe('submit button', () => {
let buttonElement: HTMLButtonElement;

beforeEach(() => {
buttonElement = compiledComponent.querySelector('button[type="submit"]') as HTMLButtonElement;
});

it('should be existing', () => {
// Assert
expect(buttonElement).toBeTruthy();
});

it('should be disabled when "insuredList" is empty array', () => {
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(true);
});

it('should be disabled when there ara any verifying errors that insured\'s data', () => {
// Arrange
component.insuredList = [{
name: 'A',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
compiledComponent.querySelector('button[type="submit"]')
// Act
fixture.detectChanges();
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(true);
})

it('should be enabled when there ara any verifying errors that insured\'s data', () => {
// Arrange
component.insuredList = [{
name: 'Leo',
gender: 'male',
age: '18',
nameErrorMessage: '',
ageErrorMessage: ''
}];
// Act
fixture.detectChanges();
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(false);
})
});

測試結果:

testing result

咦?怎麼會有 Error 咧?原來這個問題跟上次我們寫登入表單的整合測試所遇到的情況一樣。

所以我們目前先在這個案例的 it 的前面加上一個 x ,代表我們要 ignore 這個案例的意思,像這樣:

1
2
3
xit('should be disabled when there ara any verifying errors that insured\'s data', () => {
// 省略...
})

測試結果:

testing result

至此,我們就完成了整合測試的部份囉!

今天所有的測試結果:

testing result

本日小結

其實今天用所有用到的測試手法與概念都在之前的的文章就已經分享過了,今天主要是讓大家練習,提昇撰寫測試的熟悉度。

明天我們要為用 Reactive Forms 所撰寫的被保人表單來撰寫單元測試,我覺得大家可以在看我的文章之前先自己寫寫看,之後再參考我的文章,一定會有更多的收穫!

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day12 - 單元測試實作 - 被保人 by Template Driven Forms

Day12

今天我們要來為我們用 Template Driven Forms 所撰寫的被保人表單寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第十天的文章: Template Driven Forms 實作 - 動態表單初體驗。

此外,由於許多同樣的事情已在第六天的文章:單元測試實作 - 登入系統 by Template Driven Forms 講過了,例如前置作業的部份,我就不再重複贅述囉!

實作開始

個人習慣要撰寫測試時的第一件事情,就是先把目標類別的依賴都先準備好,例如我們的被保人表單至少會需要 FormsModule 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { TestBed } from '@angular/core/testing';
import { TemplateDrivenFormsAsyncInsuredComponent } from './template-driven-forms-async-insured.component';

describe('TemplateDrivenFormsAsyncInsuredComponent', () => {
let component: TemplateDrivenFormsAsyncInsuredComponent;
let fixture: ComponentFixture<TemplateDrivenFormsAsyncInsuredComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TemplateDrivenFormsAsyncInsuredComponent],
imports: [FormsModule]
}).compileComponents();

fixture = TestBed.createComponent(TemplateDrivenFormsAsyncInsuredComponent);
component = fixture.componentInstance;
});

it('should create', () => {
expect(component).toBeTruthy();
});
});

加完之後使用 ng test 的指令將測試程式啟動起來,應該要能通過我們的第一個測試案例 should create。

雖然有些人可能會發現,當前這個階段如果不加不會報錯,其實這是因為我們的程式一開始沒有任何表單(空陣列),一旦後續測試時加了之後一定會報錯噢!

再次幫大家複習:單元測試主要是要用來驗證單個類別的函式其實際執行結果是否符合我們預期的執行結果。

開始前先打開 .ts 來看一下要寫哪些案例:

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
79
80
81
82
83
84
85
86
87
export class TemplateDrivenFormsAsyncInsuredComponent {

// 被保險人清單
insuredList: Insured[] = [];

/**
* 根據索引來重新渲染有更改的節點
* 詳情請參考官方文件:https://angular.tw/api/common/NgForOf
*
* @param {string} index
* @return {*} {number}
* @memberof AppComponent
*/
trackByIndex(index: number): number {
return index;
}

/**
* 綁定在姓名欄位上,當使用者改變被保險人的姓名時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} name
* @param {ValidationErrors | null} errors
* @param {Insured} insured
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredNameChange(name: string, errors: ValidationErrors | null, insured: Insured): void {
insured.name = name;
insured.nameErrorMessage = this.getErrorMessage(errors);
}

/**
* 綁定在年齡欄位上,當使用者改變被保險人的年齡時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} age
* @param {ValidationErrors | null} errors
* @param {Insured} insured
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredAgeChange(age: string, errors: ValidationErrors | null, insured: Insured): void {
insured.age = age;
insured.ageErrorMessage = this.getErrorMessage(errors);
}

/**
* 新增被保險人
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
addInsured(): void {
const insured: Insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
this.insuredList.push(insured);
}

/**
* 刪除被保險人
*
* @param {number} index
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.insuredList.splice(index, 1);
}

/**
* 根據 FormControl 的 errors 屬性取得相應的錯誤訊息
*
* @private
* @param {ValidationErrors | null} errors - FormControl 的 errors
* @return {*} {string}
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
private getErrorMessage(errors: ValidationErrors | null): string {
let errorMessage = '';
if (errors?.required) {
errorMessage = '此欄位必填';
} else if (errors?.minlength) {
errorMessage = '姓名至少需兩個字以上';
}
return errorMessage;
}
}

以目前的程式碼來看,我們要測的單元有 trackByIndex 、 insuredNameChange 、 insuredAgeChange 、 addInsured 與 deleteInsured 這五個,接下來我們照順序先從 trackByIndex 來寫好了。

測試單元 - trackByIndex

這個測試單元非常簡單,不多說直接看程式碼:

1
2
3
4
5
6
7
8
describe('trackByIndex', () => {
it('should just return the index', () => {
// Arrange
const index = 0;
// Assert
expect(component.trackByIndex(index)).toBe(index);
})
});

測試結果:

testing result

測試單元 - insuredNameChange

接下來要測的單元是 insuredNameChange ,要測的案例有:

  1. 會將傳入的 name 的值賦值給傳入的 insured 裡的 name 。
  2. 如果傳入的 errors 有 required 欄位,則會將錯誤訊息 此欄位必填 賦值給傳入的 insured 裡的 nameErrorMessage 。
  3. 如果傳入的 errors 有 minlength 欄位,則會將錯誤訊息 姓名至少需兩個字以上 賦值傳入的 insured 裡的 nameErrorMessage 。

程式碼如下:

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
describe('insuredNameChange', () => {
let insured: Insured;

beforeEach(() => {
insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
});

it('should assign the value of the formControl to property "name" of the insured', () => {
// Arrange
const value = 'Leo';
const errors = null;
// Act
component.insuredNameChange(value, errors, insured);
// Assert
expect(insured.name).toBe(value);
});

it('should assign error message "此欄位必填" to property "nameErrorMessage" of the insured when the value of the formControl is empty string', () => {
// Arrange
const value = '';
const errors = { required: true };
const errorMessage = '此欄位必填';
// Act
component.insuredNameChange(value, errors, insured);
// Assert
expect(insured.nameErrorMessage).toBe(errorMessage);
});

it('should assign error message "姓名至少需兩個字以上" to property "nameErrorMessage" of the insured when the value\;s length of the formControl less than 2', () => {
// Arrange
const value = 'L';
const errors = {
minlength: {
actualLength: 1,
requiredLength: 2
}
};
const errorMessage = '姓名至少需兩個字以上';
// Act
component.insuredNameChange(value, errors, insured);
// Assert
expect(insured.nameErrorMessage).toBe(errorMessage);
});
});

這邊的程式碼大家應該都還算熟悉,比較特別需要提醒的是,記得要把初始化這件事寫在 beforeEach 裡,讓每個測試案例在執行之前都能拿到重新初始化過後的值,避免與其他的測試案例共用同個資料或物件。

測試結果:

testing result

測試單元 - insuredAgeChange

下個要測的單元是 insuredAgeChange ,基本上跟 insuredNameChange 相似度高達 87% ,要測試的案例有:

  1. 會將傳入的 age 的值賦值給傳入的 insured 裡的 name 。
  2. 如果傳入的 errors 有 required 欄位,則會將錯誤訊息 此欄位必填 賦值給傳入的 insured 裡的 nameErrorMessage 。

程式碼如下:

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
describe('insuredAgeChange', () => {
let insured: Insured;

beforeEach(() => {
insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
});

it('should assign the value of the formControl to property "age" of the insured', () => {
// Arrange
const age = '18';
const errors = null;
// Act
component.insuredAgeChange(age, errors, insured);
// Assert
expect(insured.age).toBe(age);
});

it('should assign error message "此欄位必填" to property "ageErrorMessage" of the insured when the value of the formControl is empty string', () => {
// Arrange
const age = '';
const errors = { required: true };
const errorMessage = '此欄位必填';
// Act
component.insuredAgeChange(age, errors, insured);
// Assert
expect(insured.ageErrorMessage).toBe(errorMessage);
});
});

測試結果:

testing result

測試單元 - addInsured

這個單元的測試也是相當簡單,基本上只要驗證執行後會新增一個被保人表單的資料即可。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('addInsured', () => {
it('should add a new insured data into property "insuredList" after being triggered', () => {
// Arrange
const expectResult: Insured[] = [{
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
// Act
component.addInsured();
// Assert
expect(component.insuredList).toEqual(expectResult);
});
});

測試結果:

testing result

雖然很間單,但大家有沒有注意到,在這我裡我不是用 toBe 而是用 toEqual 來驗證?

toBe 的比較一般會用在原始型別的對比上,但如果今天要對比的是物件就要改成用 toEqual 來驗證了。

如果不知道為什麼的朋友,可能要先複習一下 JS 的核心概念囉!

參考文件:MDN 官方文件 - 理解相等比較模型。

測試結果:

testing result

測試單元 - deleteInsured

最後一個單元也非常簡單,基本上只要驗證能將被保人的資料從 insuredList 中刪除即可。

程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('deleteInsured', () => {
it('should delete the insured data by the index after being triggered', () => {
// Arrange
component.insuredList = [{
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
// Act
component.deleteInsured(0);
// Assert
expect(component.insuredList).toEqual([]);
});
});

測試結果:

testing result

今天所有的測試結果:

testing result

本日小結

不知道大家有沒有覺得今天的單元測試很簡單,甚至是有點無聊了呢?

與一開始還沒接觸時相比,是不是覺得其實寫單元測試也沒花多少時間,而且更可以保證程式碼的品質呢?

沒錯,這一切都會隨著熟練度的提升而變得愈加容易!

今天的學習重點主要是:

  1. 清楚 toBe 與 toEqual 的差別。
  2. 測試隔離
  3. 練習寫測試

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day11 - Reactive Forms 實作 - 動態表單初體驗

Day11

今天要來用 Reactive Forms 的方式再來實作一次昨天的表單。

具體的規格需求跟昨天差不多,如下所示:

  • 被保險人的欄位:
    • 姓名(文字輸入框)
      • 最少需要填寫兩個字,如驗證有誤則顯示錯誤訊息姓名至少需兩個字以上
      • 最多只能填寫十個字,如驗證有誤則顯示錯誤訊息姓名最多只能十個字
    • 性別(單選)
      • 選項:男性、女性
    • 年齡(下拉選單)
      • 選項: 18 歲、 20 歲、 70 歲、 75 歲
  • 以上欄位皆為必填,如驗證有誤則顯示錯誤訊息此欄位為必填
  • 以上驗證皆需在使用者輸入時動態檢查
  • 按下新增被保險人按鈕可以新增被保險人
  • 按下刪除被保險人按鈕可以刪除被保險人
  • 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態

規格需求看清楚之後,我們就來開始實作吧!

實作開始

首先我們一樣先準備好基本的 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
29
30
31
32
33
<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input type="text" id="name" formControlName="name" />
<span class="error-message">{{ getErrorMessage("name") }}</span>
</p>
<p>
性別:
<input type="radio" id="male" value="male" formControlName="gender" />
<label for="male">男</label>
<input type="radio" id="female" value="female" formControlName="gender" />
<label for="female">女</label>
</p>
<p>
<label for="age">年齡:</label>
<select id="age" formControlName="age">
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ getErrorMessage("age") }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

未經美化的畫面跟昨天長得一樣:

Template view

接著跟昨天一樣先把它當成靜態表單來準備相關的屬性與方法:

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
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
selector: 'app-template-driven-forms-async-insured',
templateUrl: './template-driven-forms-async-insured.component.html',
styleUrls: ['./template-driven-forms-async-insured.component.scss']
})
export class TemplateDrivenFormsAsyncInsuredComponent {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
getErrorMessage(key: string): string {
const formControl = this.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 = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 綁定在表單上,當按下送出按鈕時會觸發此函式
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
submit(): void {
// do submit...
}
}

準備好相關的屬性和方法之後,我們直接把他們跟 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
34
35
36
37
<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input
type="text"
id="name"
formControlName="name"
/>
<span class="error-message">{{ getErrorMessage('name') }}</span>
</p>
<p>
性別:
<input type="radio" id="male" value="male" formControlName="gender">
<label for="male">男</label>
<input type="radio" id="female" value="female" formControlName="gender">
<label for="female">女</label>
</p>
<p>
<label for="age">年齡:</label>
<select id="age" formControlName="age">
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ getErrorMessage('age') }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

目前為止,大體上跟我們上次的實作差不多,應該沒有什麼難度。

不過這次綁定 FormControl 的方式,我改成用 formControlName="name" ,而不是上次的 [formControl]="nameControl" ,大家可以自行選用喜歡的方式。

如果大家在這邊有遇到問題,可以檢查看看自己有沒有引入 FormsModule 與 ReactiveFormsModule ,我就不再贅述囉。

目前的結果:

result

有了基本的互動效果之後,我們就可以開始來思考怎麼樣把這個表單變成動態的。

跟昨天一樣的是,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來表達這些被保人的資料,也就是說,我們現在的 FormGroup 要從 1 個變成 N 個。

之前曾經提到,我們如果從資料面來看, {} 代表表單,也就是 FormGroup; '' 代表表單裡的子欄位,也就是 FormControl ;那 [] 呢?

答案是 ─ FormArray !

不過 FormArray 不能直接跟 form 元素綁定,唯一可以跟 form 元素綁定的只有 FormGroup ,所以 FormArray 一定要在 FormGroup 裡面,就像這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});

這邊要注意的是, FormArray 一定要透過 FormBuilder 或是 FormArray 的建構式來建立,像上面示範的那樣,或是這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: new FormArray([])
});

絕對不能偷懶寫成這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: []
});

這樣的話,就會變成普通的 FormControl 囉!切記切記!

接著我們就可以將原本的程式碼修改成用陣列的方式,並把新增被保人、刪除被保人與判斷表單是否有效的函式都補上:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@Component({
// 省略...
})
export class AppComponent implements OnInit {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 用以取得 FormArray
*
* @readonly
* @type {FormArray}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}

/**
* 綁定在送出按鈕上,判斷表單是不是無效
*
* @readonly
* @type {boolean}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get isFormInvalid(): boolean {
return this.formArray.controls.length === 0 || this.formGroup!.invalid;
}

/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
}

/**
* 新增被保人
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
addInsured(): void {
const formGroup = this.createInsuredFormGroup();
this.formArray.push(formGroup);
}

/**
* 刪除被保人
*
* @param {number} index
* @memberof ReactiveFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.formArray.controls.splice(index, 1);
this.formArray.updateValueAndValidity();
}

/**
* 送出表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
submit(): void {
// do login...
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
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 = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 建立被保人的表單
*
* @private
* @return {*} {FormGroup}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
}

接著我們到 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
<legend>被保人</legend>
<p>
<label [for]="'name-' + index">姓名:</label>
<input type="text" [id]="'name-' + index" formControlName="name" />
<span class="error">{{ getErrorMessage("name", index) }}</span>
</p>
<p>
性別:
<input
type="radio"
[id]="'male-' + index"
value="male"
formControlName="gender"
/>
<label [for]="'male-' + index">男</label>
<input
type="radio"
[id]="'female-' + index"
value="female"
formControlName="gender"
/>
<label [for]="'female-' + index">女</label>
</p>
<p>
<label [for]="'age-' + index">年齡:</label>
<select name="age" [id]="'age-' + index" formControlName="age">
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error">{{ getErrorMessage("age", index) }}</span>
</p>
<p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
</fieldset>
</ng-container>
<p>
<button type="button" (click)="addInsured()">新增被保險人</button>
<button type="submit" [disabled]="isFormInvalid">送出</button>
</p>
</form>

初次看到這種綁定方式的 Angular 初學者可能會傻眼,不過靜下心來看之後你會發現,其實這只是我們所建立的 FormGroup 裡的階層關係,這樣綁定 Angular 才能從一層層的表單之中開始往下找。

如果我們把其他的 HTML 都拿掉的話其實會清楚很多:

1
2
3
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<!-- 其他省略 -->
</form>

最外層的這個大家應該都知道,就是我們在 .ts 裡的 formGroup 。

1
2
3
4
5
6
7
8
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<!-- 其他省略 -->
</ng-container>
</form>

而這裡呢,就像我們寫靜態表單的時候,會從 FormGroup 裡根據對應的 key 值找到對應的 FormControl 一樣,這裡則是把對應的 FormArray 找出來。

然後再用 *ngFor 的方式,把 FormArray 底下的 AbstractControl 都迴圈出來。

關於 AbstractControl ,它其實是一個抽象類別,而 FormGroup 、 FormArray 與 FormControl 這三種類型其實都繼承於這個類別,所以大家不知道有沒有注意到,一般我們在 .ts 裡使用的時候,我們會特別用 as FormControl 或是 as FormArray 的方式來讓編譯器知道現在取得的物件實體是什麼型別,以便後續使用。

想知道更多 AbstractControl 的資訊的話,請參考官方 API 文件: https://angular.io/api/forms/AbstractControl 。

1
2
3
4
5
6
7
8
9
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
</fieldset>
</ng-container>
</form>

最後再用索引值 index 找出對應的 FormGroup 。

而要做這件事情其實要有相對應的階層關係的 HTML 來幫忙,但因為我的 HTML 的階層關係少一層,所以我才會用 ng-container 多做一層階層,好讓我的表單可以順利綁上去。

如果今天你做的 HTML 的階層數是足夠的,就可以不用用 ng-container 多做一層階層,例如把上面的 HTML 改成這樣其實也可以:

1
2
3
4
5
6
7
8
9
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<div
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
</fieldset>
</div>
</form>

不過用 ng-container 的好處是這個元素並不會真的出現在畫面上,大家可以視情況斟酌使用。

改完之後就大功告成囉!來看看最後的結果:

result

本日小結

今天的學習重點主要是在圍繞在 FormArray 上,因為多了這個階層的關係,所以在與 Template 的綁定上看起來會較為複雜一點點。

話雖如此,大家可以拿今天的 template 與昨天的 template 互相比較一下,除了 for 與 id 這兩個屬性因為天生侷限的關係真的沒辦法之外,但 name 的部份就不用再去處理了,還是很方便的。

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day10 - Template Driven Forms 實作 - 動態表單初體驗

Day10

今天要來用 Template Driven Forms 的方式實作一個很簡易的動態表單,使用上有點像是保險業者的系統,可以新增多名被保人,也可以編輯與刪除被保人。

具體的規格需求如下:

  • 被保險人的欄位:
    • 姓名(文字輸入框)
      • 最少需要填寫兩個字,如驗證有誤則顯示錯誤訊息姓名至少需兩個字以上
      • 最多只能填寫十個字
    • 性別(單選)
      • 選項:男性、女性
    • 年齡(下拉選單)
      • 選項: 18 歲、 20 歲、 70 歲、 75 歲
  • 以上欄位皆為必填,如驗證有誤則顯示錯誤訊息此欄位為必填
  • 以上驗證皆需在使用者輸入時動態檢查
  • 按下新增被保險人按鈕可以新增被保險人
  • 按下刪除被保險人按鈕可以刪除被保險人
  • 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態

規格需求看清楚之後,我們就來開始實作吧!

開始實作

首先我們先準備好基本的 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
29
30
31
32
33
34
35
36
37
38
39
40
<form>
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input
type="text"
name="name"
id="name"
required
maxlength="10"
minlength="2"
/>
<span class="error-message"></span>
</p>
<p>
性別:
<input type="radio" name="gender" id="male" value="male">
<label for="male">男</label>
<input type="radio" name="gender" id="female" value="female">
<label for="female">女</label>
</p>
<p>
<label for="age">年齡:</label>
<select name="age" id="age" required>
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message"></span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

未經美化的畫面應該會長這樣:

Template view

基本的 HTML 準備好之後,我建議對於 Angular 還沒那麼熟悉的朋友先不要一口氣就想要直接把它做成動態的,先把它當成靜態表單來做會比較簡單一些。

因此,我們先準備相關的屬性與方法:

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
import { Component } from '@angular/core';
import { ValidationErrors } from '@angular/forms';

@Component({
selector: 'app-template-driven-forms-async-insured',
templateUrl: './template-driven-forms-async-insured.component.html',
styleUrls: ['./template-driven-forms-async-insured.component.scss']
})
export class TemplateDrivenFormsAsyncInsuredComponent {

// 綁在姓名欄位上
name = '';

// 綁在性別欄位上
gender = '';

// 綁在年齡欄位上
age = '';

// 姓名欄位的錯誤訊息
nameErrorMessage = '';

// 年齡欄位的錯誤訊息
ageErrorMessage = '';

/**
* 綁定在姓名欄位上,當使用者改變被保險人的姓名時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} name
* @param {ValidationErrors | null} errors
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredNameChange(name: string, errors: ValidationErrors | null): void {
this.name = name;
this.nameErrorMessage = this.getErrorMessage(errors);
}

/**
* 綁定在年齡欄位上,當使用者改變被保險人的年齡時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} age
* @param {ValidationErrors | null} errors
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredAgeChange(age: string, errors: ValidationErrors | null): void {
this.age = age;
this.ageErrorMessage = this.getErrorMessage(errors);
}

/**
* 綁定在表單上,當按下送出按鈕時會觸發此函式
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
submit(): void {
// do submit...
}

/**
* 根據 FormControl 的 errors 屬性取得相應的錯誤訊息
*
* @private
* @param {ValidationErrors | null} errors - FormControl 的 errors
* @return {*} {string}
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
private getErrorMessage(errors: ValidationErrors | null): string {
let errorMessage = '';
if (errors?.required) {
errorMessage = '此欄位必填';
} else if (errors?.minlength) {
errorMessage = '姓名至少需兩個字以上';
}
return errorMessage;
}
}

準備好相關的屬性和方法之後,我們直接把他們跟 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
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
<form (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input
type="text"
name="name"
id="name"
required
maxlength="10"
minlength="2"
#nameNgModel="ngModel"
[ngModel]="name"
(ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors)"
/>
<span class="error-message">{{ nameErrorMessage }}</span>
</p>
<p>
性別:
<input
type="radio"
name="gender"
id="male"
value="male"
required
[(ngModel)]="gender"
>
<label for="male">男</label>
<input
type="radio"
name="gender"
id="female"
value="female"
required
[(ngModel)]="gender"
>
<label for="female">女</label>
</p>
<p>
<label for="age">年齡:</label>
<select
name="age"
id="age"
required
#ageNgModel="ngModel"
[ngModel]="age"
(ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors)"
>
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ ageErrorMessage }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

從目前的程式碼應該不難發現,大體上跟我們第二天的實作內容差不多、結構也差不多,應該沒有什麼難度。

如果大家在這邊有遇到問題,大致上可以檢查看看自己有沒有引入 FormsModule ,抑或者是表單欄位上是否有 name 屬性,我就不再贅述囉。

目前的結果:

result

有了基本的互動效果之後,我們就可以開始來思考怎麼樣把這個表單變成動態的。

相信大家一定知道,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來存放這些被保人的資料,所以我們可以先將這些我們需要的資料欄位定義一個型別以便後續使用。

像是這樣:

1
2
3
4
5
6
7
export type Insured = {
name: string;
gender: string;
age: number;
nameErrorMessage: string;
ageErrorMessage: string;
};

或者是這樣:

1
2
3
4
5
6
7
export interface Insured {
name: string;
gender: string;
age: number;
nameErrorMessage: string;
ageErrorMessage: string;
};

甚至是這樣:

1
2
3
4
5
6
7
export class Insured {
name: string;
gender: string;
age: string;
nameErrorMessage: string;
ageErrorMessage: string;
};

這三種定義型別的方式基本上都可以,我就不多解釋他們之間的差異了,我個人近期是滿喜歡用第一種的。

接著我們就可以將原本那些單個的屬性拿掉,改成用陣列的方式,像是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 以上省略...
import { Insured } from './insured.type';

@Component({
// 省略...
})
export class TemplateDrivenFormsAsyncInsuredComponent {

// 被保險人清單
insuredList: Insured[] = [];

// 以下這些都可以移除
// name = '';
// gender = '';
// age = '';
// nameErrorMessage = '';
// ageErrorMessage = '';

// 以下省略...
}

這些單個的屬性移除掉之後,原本有使用到它們的部分就會壞掉,所以我們要將它們改為使用傳進來的被保人的資料,像這樣:

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
// 以上省略...
import { Insured } from './insured.type';

@Component({
// 省略...
})
export class TemplateDrivenFormsAsyncInsuredComponent {

// 被保險人清單
insuredList: Insured[] = [];

/**
* 綁定在姓名欄位上,當使用者改變被保險人的姓名時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} name
* @param {ValidationErrors | null} errors
* @param {Insured} insured
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredNameChange(name: string, errors: ValidationErrors | null, insured: Insured): void {
insured.name = name;
insured.nameErrorMessage = this.getErrorMessage(errors);
}

/**
* 綁定在年齡欄位上,當使用者改變被保險人的年齡時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} age
* @param {ValidationErrors | null} errors
* @param {Insured} insured
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredAgeChange(age: string, errors: ValidationErrors | null, insured: Insured): void {
insured.age = age;
insured.ageErrorMessage = this.getErrorMessage(errors);
}

// 以下省略...
}

接著我們就可以到 Template 裡,將所有被保人的資料用 *ngFor 的方式迴圈出來,並將原本用單個屬性綁定的部份也改為綁定迴圈出來的被保人資料:

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
<form (ngSubmit)="submit()">
<!-- 將所有被保人的資料迴圈出來 -->
<fieldset *ngFor="let insured of insuredList">
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<!-- 改為綁定被迴圈出來的被保人資料,並將其傳入函式內 -->
<input
type="text"
name="name"
id="name"
required
maxlength="10"
minlength="2"
#nameNgModel="ngModel"
[ngModel]="insured.name"
(ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
/>
<span class="error-message">{{ insured.nameErrorMessage }}</span>
</p>
<p>
性別:
<!-- 改為綁定被迴圈出來的被保人資料 -->
<input
type="radio"
name="gender"
id="male"
value="male"
required
[(ngModel)]="insured.gender"
>
<label for="male">男</label>
<input
type="radio"
name="gender"
id="female"
value="female"
required
[(ngModel)]="insured.gender"
>
<label for="female">女</label>
</p>
<p>
<label for="age">年齡:</label>
<!-- 改為綁定被迴圈出來的被保人資料,並將其傳入函式內 -->
<select
name="age"
id="age"
required
#ageNgModel="ngModel"
[ngModel]="insured.age"
(ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors, insured)"
>
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ insured.ageErrorMessage }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

接著我們就可以儲存以查看目前的結果:

result

咦?!怎麼表單欄位不見了?!

別緊張,這是因為 insuredList 現在是個空陣列呀!

接下來我們再加個新增被保險人與刪除被保險人的函式:

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
/**
* 新增被保險人
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
addInsured(): void {
const insured: Insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
this.insuredList.push(insured);
}

/**
* 刪除被保險人
*
* @param {number} index
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.insuredList.splice(index, 1);
}

然後把它們綁定到按鈕上,並且在 *ngFor 裡新增索引的宣告,以供刪除時使用 :

1
2
3
4
5
6
7
8
9
10
<form (ngSubmit)="submit()">
<fieldset *ngFor="let insured of insuredList; let index = index">
<!-- 中間省略... -->
<p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
</fieldset>
<p>
<button type="button" (click)="addInsured()">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

結果:

result

雖然我們的表單就差不多快完成了,但其實我們的表單目前有兩個問題,不曉得大家有沒有發現?

問題一

thinking

對專業的前端工程師來說,我們做出來的表單一定要讓人家有良好的使用者體驗。

為此,我們通常會使用一些 HTML 的屬性來讓我們的表單更為人性化,像是在 label 上加 for 。

但問題來了, for 要跟 id 搭配使用,但 id 一整頁只會有一個,而我們可能會有 N 個被保險人,怎辦?

這時候我們可以善用陣列的索引值來幫我們達成這個目的,像是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
<label [for]="'name-' + index">姓名:</label>
<input
type="text"
[name]="'name-' + index"
[id]="'name-' + index"
required
maxlength="10"
minlength="2"
#nameNgModel="ngModel"
[ngModel]="insured.name"
(ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
/>

我知道很醜,但沒辦法,這是天生的侷限。

對了, name 屬性也要噢!因為表單裡的 name 也是唯一性的。

問題二

thinking

這個問題是因為在畫面重新渲染完之後, NgForm 裡面 Key 值為 xxx-0 的 NgModel 們就不見了,只留下 xxx-1 的 NgModel 們。在這之後如果再按新增被保人時,由於新增的那一筆的索引是 1 ,就又會把原本留下的 Key 值為 xxx-1 的 NgModel 們蓋掉,導致大家現在所看到的情況。

thinking

解決方式其實說難不難,因為其實 *ngFor 有個 trackBy 的參數,只要傳入這個參數就可以解決這個問題。但說簡單也不簡單,不知道原因跟解法的人就會卡上一段時間。

其實我一開始也卡住,還跟社群的人求救,進而引出一大串的討論(笑)。

方式是先在 .ts 裡加一個函式:

1
2
3
4
5
6
7
8
9
10
/**
* 根據索引來重新渲染有更改的節點
*
* @param {string} index
* @return {*} {number}
* @memberof AppComponent
*/
trackByIndex(index: number): number {
return index;
}

然後在 *ngFor 的後面加上:

1
<fieldset *ngFor="let insured of insuredList; let index = index; trackBy: trackByIndex">

這樣就可以解決我們的問題了!

最後,我們就剩以下兩項事情還沒做:

  • 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態

這兩件事情基本上可以看成同一件事情 ─ 判斷表單是否無效。

怎麼判斷呢?

大家記不記得上次有用到一個類別叫做 NgForm ,當表單內的驗證有誤時, NgForm 的屬性 invalid 就會為 true 。

所以我們一樣可以利用它來幫我們判斷,像這樣:

1
2
3
4
5
6
7
8
9
<form #form="ngForm" (ngSubmit)="submit()">
<fieldset *ngFor="let insured of insuredList; let index = index">
<!-- 中間省略... -->
</fieldset>
<p>
<button type="button" (click)="addInsured()">新增被保險人</button>
<button type="submit" [disabled]="insuredList.length === 0 || form.invalid">送出</button>
</p>
</form>

結果:

result

本日小結

今天的學習重點主要是在練習如何讓靜態的表單變成動態,雖然沒有多複雜,但可能也是會難倒大部分的初學者。

其實大體上的邏輯跟實作登入時是差不多的,大家之所以會卡住主要可能會是因為不知道如何讓靜態表單變成動態,而以 Template Driven Forms 的方式來說,滿多程式碼都會綁在 Template 上,大家在實作時要看清楚才不會出錯。

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day09 - 整合測試實作 - 登入系統 by Reactive Forms

Day9

昨天幫我們用 Reactive Forms 所撰寫的登入系統寫完單元測試之後,今天則是要來為它寫整合測試。

再次幫大家複習一下整合測試的測試目標:

整合測試的測試目標是要測試兩個或是兩個以上的類別之間的互動是否符合我們的預期。

如果對於整合測試在測什麼還沒有概念的話,建議大家先回到第七天的文章複習一下:整合測試實作 - 登入系統 by Template Driven Forms 。

實作開始

跟上次一樣先增加一個 describe 的區塊,有關於整合測試的程式碼接下來都會放在這裡面:

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
import { TestBed } from '@angular/core/testing';

import { AppComponent } from './app.component';

describe('AppComponent', () => {
let component: AppComponent;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [
FormsModule,
ReactiveFormsModule
]
}).compileComponents();

const fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
});

describe('Unit testing', () => {
// 昨天寫的單元測試...
});

describe('Integration testing', () => {
// 今天要寫的整合測試
});
});

一般我們不會特別將單元測試跟整合測試的程式碼分開檔案來寫,只會用測試集合將其區隔。

上次有提到整合測試跟畫面會比較有相關,但這次因為我們有使用到第二個類別 FormBuilder ,所以我們先來看 xxxx.component.ts 的程式碼:

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

// 以上省略...

constructor(private formBuilder: FormBuilder) {}

ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: [
'',
[
Validators.required,
Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
]
],
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(16)]
]
});
}

// 以下省略...
}

以整合測試要驗證的項目來說,這邊其實可以驗在 ngOnInit 被呼叫時, formBuilder 的 group 函式有沒有被呼叫,像是這樣:

1
2
3
4
5
6
7
8
9
it('should call function "group" of the "FormBuilder" when function "ngOnInit" be trigger', () => {
// Arrange
const formBuilder = TestBed.inject(FormBuilder);
spyOn(formBuilder, 'group');
// Act
fixture.detectChanges();
// Assert
expect(formBuilder.group).toHaveBeenCalled();
});

不過我個人覺得這個測試案例在這裡沒啥必要,一方面是因為我們在單元測試已經有驗過 FormGroup 了, 另一方面則是因為在這裡我們其實並不在意 FormBuilder 的互動,只要 FormGroup 那邊的測試有符合預期即可。

因為 FormGroup 除了可以用 FormBuilder 來產生實體之外,也可以直接用 new FormGroup() 的方式來產生實體。

接著我們回來看畫面的部分,目前的程式碼大致上應該會長這樣:

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
<form
*ngIf="formGroup"
[formGroup]="formGroup"
(ngSubmit)="login()"
>
<p>
<label for="account">帳號:</label>
<input
type="email"
id="account"
[formControl]="accountControl"
/>
<span class="error-message">{{ getErrorMessage(accountControl) }}</span>
</p>
<p>
<label for="password">密碼:</label>
<input
type="password"
id="password"
[formControl]="passwordControl"
/>
<span class="error-message">{{ getErrorMessage(passwordControl) }}</span>
</p>
<p>
<button type="submit" [disabled]="formGroup.invalid">登入</button>
</p>
</form>

大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:

  • 帳號欄位
    • 屬性 type 的值要是 email
    • 要將 accountControl 綁定到此欄位上
  • 密碼欄位
    • 屬性 type 的值要是 password
    • 要將 passwordControl 綁定到此欄位上
  • 錯誤訊息
    • 要將帳號欄位的錯誤訊息綁定到畫面上
    • 要將密碼欄位的錯誤訊息綁定到畫面上
  • 登入按鈕
    • 屬性 type 的值要是 submit
    • 當表單是無效的狀態時,要有屬性 disabled
    • 當表單是有效的狀態時,沒有屬性 disabled
    • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

列完之後大家有沒有發現,跟上次測 Template Driven Forms 的時候相比,要驗證的項目少了很多對吧?!

某方面來說,這是因為我們把一些原本是在這時候驗的項目轉移到單元測試上的緣故;另一方面是,有些項目可以多驗一些不同的狀況,容我後續遇到時再加以說明。

帳號欄位的驗證

跟上次一樣先來驗證帳號欄位,複習一下帳號欄位的驗證項目:

  • 屬性 type 的值要是 email
  • 要將 accountControl 綁定到此欄位上

然後把帳號欄位要驗證的項目寫成測試案例:

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
describe('Account input field', () => {
let accountInputElement: HTMLInputElement;

beforeEach(() => {
accountInputElement = compiledComponent.querySelector('#account')!;
});

it('should have attribute "type" and the value is "email"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'email';
// Assert
expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should binding with formControl "accountControl"', () => {
// Arrange
const account = 'whatever';
// Act
component.accountControl.patchValue(account);
fixture.detectChanges();
// Assert
expect(accountInputElement.value).toBe(account);
});
});

測試結果:

testing result

在這些測試案例裡,比較特別需要說明的是: should binding with formControl "accountControl" 這個測試案例,怎麼說呢?

大家有沒有發現,這個測試案例跟上一個測試案例的驗證方式不太一樣?上一個是用 getAttribute 的方式,而這測試案例卻不是?

在講原因之前,要先跟大家報告的是,其實將 FormControl 綁定到某個表單欄位上的方法有以下兩種:

  1. 直接用某個 FormControl 的實體綁定,使用方式是在該欄位用屬性綁定的方式綁定時體,如: [formControl]="accountControl"(也就是我目前使用的方式)。
  2. 使用該欄位在 FormGroup 內所對應的 Key Name 來綁定,如: [formControlName]="'account'" 或者是 formControlName="account" 。

[formControlName]="'account'" 與 formControlName="account" 之間的差別在,前者在 Angular 裡叫做屬性綁定,意思是可以將其跟某個 Component 的屬性綁定;後者就只是在該元素上多加了一個自定的 HTML 的屬性,其值是寫死的。

如果是使用第二種的方式去將 FormControl 綁定到某個表單欄位上的話,在寫測試時可以很簡單的只用 getAttribute 的方式驗證。但是如果是使用第一種方式的話,就必須用我上面程式碼所示範的方式拐著彎驗,如果用 getAttribute 的方式來驗的話,只會取得 '[Object Object]' 這種沒有辦法進一步驗證的字串。

密碼欄位的驗證

至於密碼欄位的部分,也跟帳號欄位差不多,其驗證項目如下:

  • 屬性 type 的值要是 password
  • 要將 passwordControl 綁定到此欄位上

測試程式碼如下:

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
describe('Password input field', () => {
let passwordInputElement: HTMLInputElement;

beforeEach(() => {
passwordInputElement = compiledComponent.querySelector('#password')!;
});

it('should have attribute "type" and the value is "password"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'password';
// Assert
expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should binding with formControl "passwordControl"', () => {
// Arrange
const password = 'whatever';
// Act
component.passwordControl.patchValue(password);
fixture.detectChanges();
// Assert
expect(passwordInputElement.value).toBe(password);
});
});

測試結果:

testing result

錯誤訊息的驗證

錯誤訊息要驗證的項目是:

  • 要將帳號欄位的錯誤訊息綁定到畫面上
  • 要將密碼欄位的錯誤訊息綁定到畫面上

為什麼這兩個項目的敘述感覺起來很籠統呢?

這是因為在我們原本的程式碼中,我們沒有特別用變數來儲存該欄位的錯誤訊息,而是直接讓 Template 在渲染畫面的時候,直接用該欄位的 formControl 的 errors 來取得對應的錯誤訊息,所以我們在驗證的時候就不能用上次的方式驗,具體請看我的測試程式碼:

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
describe('Error Message', () => {
it('should binding error message "格式有誤,請重新輸入" with the error of "accountControl"', () => {
// Arrange
const errorMessage = '格式有誤,請重新輸入';
const targetElement = compiledComponent.querySelector('#account + .error-message');
// Act
component.accountControl.setValue('abc');
component.accountControl.markAsDirty();
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should binding error message "密碼長度最短不得低於8碼" with the error of "passwordControl"', () => {
// Arrange
const errorMessage = '密碼長度最短不得低於8碼';
const targetElement = compiledComponent.querySelector('#password + .error-message');
// Act
component.passwordControl.setValue('abc');
component.passwordControl.markAsDirty();
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});

從程式碼中可以看到,這邊要先將值設給對應的 formControl 並且 markAsDirty() 之後,才能抓取到正確的錯誤訊息。

這其實是因為在我們的程式碼裡, formControl 的狀態如果是 pristine 的話,會回傳空字串。

雖然我這邊目前是用各自欄位才會有的錯誤訊息來表示驗了兩種不同欄位,但其實是可以分成兩個欄位,然後將所有的情況都驗一遍。

不過這樣就會跟單元測試有點重疊,這部份大家可以自行斟酌。

測試結果:

testing result

登入按鈕的驗證

最後是登入按鈕的驗證,它的驗證項目是:

  • 屬性 type 的值要是 submit
  • 當表單是無效的狀態時,要有屬性 disabled
  • 當表單是有效的狀態時,沒有屬性 disabled
  • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

程式碼如下:

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
describe('Login button', () => {
let buttonElement: HTMLButtonElement;

beforeEach(() => {
buttonElement = compiledComponent.querySelector('button')!;
});

it('should have attribute "type" and the value is "submit"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'submit';
// Assert
expect(buttonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "disabled" when the form\'s status is invalid', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(true);
});

describe('When the form\'s status is valid', () => {
beforeEach(() => {
component.formGroup?.setValue({
account: '[email protected]',
password: '12345678'
});
fixture.detectChanges();
});

it('should not have attribute "disabled"', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(false);
});

it('should trigger function "login" when being clicked', () => {
// Arrange
spyOn(component, 'login');
// Act
buttonElement.click();
// Assert
expect(component.login).toHaveBeenCalled();
});
});
});

測試結果:

testing result

這次沒有任何預期外的狀況,不像上次剛好遇到奇怪的問題,搞不好這又是 Reactive Forms 的另一個優點呢!(笑)。

至此,我們已經完成了第一個里程碑:用 Template Driven Forms 的方式與用 Reactive Forms 的方式各自實作一個登入系統,並且也都為它們寫了單元測試以及整合測試,相信大家對於如何使用 Angular 製作表單與撰寫測試都有了長足的進步。

明天開始就要邁入下一個里程碑:用 Template Driven Forms 的方式與用 Reactive Forms 的方式各自實作一個動態的表單,並且也要都為它們寫單元測試以及整合測試,敬請期待(壞笑)。

本日小結

今天的重點主要有以下兩點:

  1. 學習如何正確驗證「將 formControl 綁定到表單欄位上」,並了解用不同的綁定方式在驗證上會有哪些差異。
  2. 學習如何正確驗證「直接用該欄位的 formControl 的 errors 來取得對應的錯誤訊息」的情況。

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day08 - 單元測試實作 - 登入系統 by Reactive Forms

Day8

今天我們要來為我們用 Reactive Forms 所撰寫的登入系統寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第三天的文章: Reactive Forms 實作 - 以登入為例。

實作開始

前置作業基本上都跟第六天的文章:單元測試實作 - 登入系統 by Template Driven Forms 相同,今天就不會再贅述,大家如果忘記怎麼做可以先回去複習一下。

目前的程式碼:

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
export class AppComponent {
formGroup: FormGroup | undefined;

get accountControl(): FormControl {
return this.formGroup!.get('account') as FormControl;
}

get passwordControl(): FormControl {
return this.formGroup!.get('password') as FormControl;
}

constructor(private formBuilder: FormBuilder) {}

ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: [
'',
[
Validators.required,
Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
]
],
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(16)]
]
});
}

getErrorMessage(formControl: FormControl): string {
let errorMessage = '';
if (!formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.pattern) {
errorMessage = '格式有誤,請重新輸入';
} else if (formControl.errors.minlength) {
errorMessage = '密碼長度最短不得低於8碼';
} else if (formControl.errors.maxlength) {
errorMessage = '密碼長度最長不得超過16碼';
}
return errorMessage;
}

login(): void {
// do login...
}
}

以目前的程式碼來看,基本上我們只要驗 getErrorMessage 這個函式,不過我們其實也能驗 ngOnInit 這個 Angular Component Lifecycle Hook 的執行結果,畢竟它也是個函式,我們一樣可以寫測試去驗證這個函式的執行結果是否符合我們的預期。

關於 Angular Component Lifecycle Hook ,如果想知道更多可以閱讀官方文件: Component Lifecycle hooks

測試單元 - getErrorMessage

我們一樣先加一個 describe ,表明在這裡面的測試案例都是在測 getErrorMessage 這個函式:

1
2
3
4
5
6
7
describe('AppComponent', () => {
// ...

describe('getErrorMessage', () => {
// 這裡面的測試案例都是要測這個函式
});
});

接著統整一下這個 getErrorMessage 的函式裡會遇到的情況:

  1. 如果傳入的 formControl 裡沒有任何 error ,則會取得空字串。
  2. 如果傳入的 formControl 的屬性 pristine 的值為 true ,則會取得空字串。
  3. 如果傳入的 formControl 裡有必填的錯誤: required ,則會取得錯誤訊息 此欄位必填。
  4. 如果傳入的 formControl 裡有格式的錯誤: pattern ,則會取得錯誤訊息 格式有誤,請重新輸入。
  5. 如果傳入的 formControl 裡有最小長度的錯誤: minlength ,則會取得錯誤訊息 密碼長度最短不得低於8碼。
  6. 如果傳入的 formControl 裡有最大長度的錯誤: maxlength ,則會取得錯誤訊息 密碼長度最長不得超過16碼。

統整完之後,就可以將上述情況寫成測試案例:

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
describe('getErrorMessage', () => {
it('should get empty string when the value is correct', () => {
// Arrange
const formControl = new FormControl('');
const expectedMessage = '';
// Act
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});

it('should get empty string when the value is empty string but the form control is pristine', () => {
// Arrange
const formControl = new FormControl('', [Validators.required]);
const expectedMessage = '';
// Act
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});

it('should get "此欄位必填" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('', [Validators.required]);
const expectedMessage = '此欄位必填';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});

it('should get "格式有誤,請重新輸入" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('whatever', [Validators.pattern('/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi')]);
const expectedMessage = '格式有誤,請重新輸入';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});

it('should get "密碼長度最短不得低於8碼" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('abc', [Validators.minLength(8)]);
const expectedMessage = '密碼長度最短不得低於8碼';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});

it('should get "密碼長度最長不得超過16碼" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('12345678901234567', [Validators.maxLength(16)]);
const expectedMessage = '密碼長度最長不得超過16碼';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
});

從上面的程式碼中可以看出,我這次寫單元測試的策略是:讓每個案例自己配置足以驗證該案例的 formControl 與其必須的 Validators 即可。

也就是說,當我需要驗證 此欄位必填 的錯誤訊息時,我只需要配置 Validators.required 給 formControl ;當我需要驗證 密碼長度最短不得低於8碼 的錯誤訊息時,我只需要配置 Validators.minlength(8) 給 formControl ,依此類推。

會這樣寫是因為我們只需要專注在什麼樣子的 errors 會得到什麼樣子的錯誤訊息上面,當然大家也可以每次都幫 formControl 配置最完整的 Validators ,這兩個方法我覺得都可以。

此外,由於我們這次有判斷 formControl 的狀態: pristine ,因此在寫測試的時候要特別留意,記得要先 markAsDirty 之後才能測試噢!

上一次寫單元測試的文章: 單元測試實作 - 登入系統 by Template Driven Forms。

測試結果:

testing result

測試單元 - ngOnInit

再來是 ngOnInit 的部份, ngOnInit 要驗證的項目跟 formGroup 滿相關,所以我打算用 formGroup 當測試集合的名稱,具體要驗證的項目有:

  1. 在 ngOnInit 執行之前, formGroup 是 undefined 的狀況。
  2. 在 ngOnInit 執行之後,
    1. formGroup 是類型為 FormGroup 的實體。
    2. formGroup 裡要有兩個 FormControl 。
      1. accountFormControl
        • 要有必填的驗證
        • 要有 Email 格式的驗證
      2. passwordFormControl
        • 要有必填的驗證
        • 要有字串最小長度為 8 的驗證
        • 要有字串最大長度為 16 的驗證

程式碼如下:

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
describe('formGroup', () => {
it('should be undefined before init', () => {
// Assert
expect(component.formGroup).toBeFalsy();
});

describe('after ngInit', () => {

beforeEach(() => {
fixture.detectChanges();
});

it('should be instance of FormGroup', () => {
// Assert
expect(component.formGroup).toBeInstanceOf(FormGroup);
});

it('should have 2 form controls', () => {
// Arrange
const formControls = component.formGroup!.controls;
const controlLength = Object.keys(formControls).length;
// Assert
expect(controlLength).toBe(2);
});

describe('accountFormControl', () => {

it('should have the required validator', () => {
// Arrange
const error = component.accountControl.errors!;
// Assert
expect(error.required).toBe(true);
});

it('should have the email pattern validator', () => {
// Arrange
component.accountControl.setValue('abc');
const error = component.accountControl.errors!;
const expectedPattern = '/^\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b$/gi';
// Assert
expect(error.pattern.requiredPattern).toBe(expectedPattern);
});

});

describe('passwordFormControl', () => {

it('should have the required validator', () => {
// Arrange
const error = component.accountControl.errors!;
// Assert
expect(error.required).toBe(true);
});

it('should have the min-length validator', () => {
// Arrange
component.passwordControl.setValue('abc');
const error = component.passwordControl.errors!;
// Assert
expect(error.minlength.requiredLength).toBe(8);
});

it('should have the max-length validator', () => {
// Arrange
component.passwordControl.setValue('12345678901234567');
const error = component.passwordControl.errors!;
// Assert
expect(error.maxlength.requiredLength).toBe(16);
});
});
});
});

此處比較特別的地方是,我在 after ngInit 的 beforeEach 裡是用 fixture.detectChanges() 來觸發 ngOnInit() ,而不是使用 component.ngOnInit() 的方式來觸發,這是因為我認為我們在寫的是 Angular ,而這個 Lifecycle Hook 又是 Angular 的東西,所以使用 Angular 的機制來觸發會比直接使用該函式觸發來的好。

當然也是可以直接使用 component.ngOnInit() 來觸發,在測試的驗證結果上其實不會有什麼不同,所以用哪個方式其實都可以。

測試結果:

testing result

本日小結

已經寫了兩次的測試,相信大家對於測試的熟悉度已經有顯著地提昇,而今天的重點主要會是在使用 FormControl markAsDirty 來改變欄位的狀態,以及了解 fixture.detectChanges 與 ngOnInit 的關係,未來在寫測試的時候,這兩點也是非常需要多加留意的。

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day07 - 整合測試實作 - 登入系統 by Template Driven Forms

Day7

昨天幫我們用 Template Driven Forms 所撰寫的登入系統寫完單元測試之後,今天則是要來為它寫整合測試。

大家還記得整合測試的目標是要測什麼嗎?我幫大家複習一下:

整合測試的測試目標是要測試兩個或是兩個以上的類別之間的互動是否符合我們的預期。

再更直接一點地說,整合測試就是在測互動關係,其他的事情我們都不在乎,因為其他的事情基本上都會在單元測試的部份裡測。

這時候可能會有人覺得奇怪,我們現在就只有一個 Component ,並沒有符合「兩個或是兩個以上的類別」,這樣是要怎麼測試?

沒錯,雖然我們現在並沒有「兩個或是兩個以上的類別」,但是前端比較不一樣的地方是前端會有畫面,使用者實際上是看著畫面來跟我們的程式互動的。

用我們用做的登入系統來說,雖然很簡單、很陽春,但如果沒有畫面、沒有那些輸入欄位,使用者也沒辦法使用。

所以今天寫整合測試的目的就是要來來驗證我們所做的登入系統的畫面,有沒有如我們所預期地和我們的程式碼互動。

實作開始

首先我們先增加一個 describe 的區塊,有關於整合測試的程式碼接下來都會放在這裡面:

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
import { TestBed } from '@angular/core/testing';

import { AppComponent } from './app.component';

describe('AppComponent', () => {
let component: AppComponent;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [FormsModule]
}).compileComponents();

const fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
});

describe('Unit testing', () => {
// 昨天寫的單元測試...
});

describe('Integration testing', () => {
// 今天要寫的整合測試
});
});

一般我們不會特別將單元測試跟整合測試的程式碼分開檔案來寫,只會用測試集合將其區隔。

由於今天的整合測試跟畫面會比較有相關,所以我們打開 app.component.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
29
30
31
32
33
34
35
36
37
38
<form #form="ngForm" (ngSubmit)="login()">
<p>
<label for="account">帳號:</label>
<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)
"
/>
<span class="error-message">{{ accountErrorMessage }}</span>
</p>
<p>
<label for="password">密碼:</label>
<input
type="password"
name="password"
id="password"
required
#passwordNgModel="ngModel"
[minlength]="8"
[maxlength]="16"
[ngModel]="password"
(ngModelChange)="
passwordValueChange(passwordNgModel.value, passwordNgModel.errors)
"
/>
<span class="error-message">{{ passwordErrorMessage }}</span>
</p>
<p>
<button type="submit" [disabled]="form.invalid">登入</button>
</p>
</form>

大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:

  • 帳號欄位
    • 屬性 type 的值要是 email
    • 屬性 name 的值要是 account
    • 屬性 pattern 的值要是 \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b
    • 要有屬性 required
    • 要將 Component 的屬性 account 的值綁定到此欄位上
    • 此欄位的值如果有變動,要能觸發函式 accountValueChange
  • 密碼欄位
    • 屬性 type 的值要是 password
    • 屬性 name 的值要是 password
    • 屬性 minlength 的值要是 8
    • 屬性 maxlength 的值要是 16
    • 要有屬性 required
    • 要將 Component 的屬性 password 的值綁定到此欄位上
    • 此欄位的值如果有變動,要能觸發函式 passwordValueChange
  • 錯誤訊息
    • 要將 Component 的屬性 accountErrorMessage 的值綁定到畫面上
    • 要將 Component 的屬性 passwordErrorMessage 的值綁定到畫面上
  • 登入按鈕
    • 屬性 type 的值要是 submit
    • 當表單是無效的狀態時,要有屬性 disabled
    • 當表單是有效的狀態時,沒有屬性 disabled
    • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

把要測的項目都列出來之後,有沒有覺得要測的項目很多阿?哈哈!

不過上面這些我個人列的項目有些其實並不屬於整合測試的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼,而我自己也習慣在寫測試的時候分成 Component/Template 兩塊,而不是單元測試/整合測試,這樣的命名會比較符合實際上在做的事情。

那要怎麼測畫面呢?

在 beforeEach 裡有個 fixture ,我們在測單元測試的時候,是從這裡取得 Component 的實體。而現在要測畫面,一樣是從 fixture 裡取得 Angular 渲染出來的畫面:

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
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AppComponent } from './app.component';

describe('AppComponent', () => {
let component: AppComponent;

// 將 fixture 抽出來
let fixture: ComponentFixture<AppComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [FormsModule]
}).compileComponents();

fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
});

describe('Unit testing', () => {
// 昨天寫的單元測試...
});


describe('Integration testing', () => {
let compiledComponent: HTMLElement;

beforeEach(() => {
// 此行的意思是讓 Angular 幫我們將畫面的元素都渲染出來
fixture.detectChanges();

// 取得渲染完之後的元素
compiledComponent = fixture.nativeElement;
});

});
});

拿到渲染完的元素之後,接下來要做的事情應該是每個前端工程師都應該要很熟悉的 DOM 操作。

不知道什麼是 DOM 的朋友可能是走錯棚了噢!

沒錯,在撰寫測試以驗證畫面上的元素時,就是用大家都滾瓜爛熟的 DOM 操作來撰寫,以帳號欄位為例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('Integration testing', () => {
let compiledComponent: HTMLElement;

beforeEach(() => {
fixture.detectChanges();
compiledComponent = fixture.nativeElement;
});

describe('Account input field', () => {
let accountInputElement: HTMLInputElement;

beforeEach(() => {
accountInputElement = compiledComponent.querySelector('#account');
});
});
});

如果你的專案有開啟嚴格模式的話( Angular v12 之後預設開啟),可能會在 accountInputElement 底下看到紅色毛毛蟲:

strict mode error

這是因為 TypeScript 在跟你說,這裡有可能會找不到元素,所以型別有可能會是 null 。

如果我們很有自信它一定找的到、絕對不會是 null 的話,可以在該行結尾加 ! ,像這樣: accountInputElement = compiledComponent.querySelector('#account')! ,就不會有紅色毛毛蟲囉。

帳號欄位的驗證

複習一下帳號欄位的驗證項目:

  • 屬性 type 的值要是 email
  • 屬性 name 的值要是 account
  • 屬性 pattern 的值要是 \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b
  • 要有屬性 required
  • 要將 Component 的屬性 account 的值綁定到此欄位上
  • 此欄位的值如果有變動,要能觸發函式 accountValueChange

接下來就把帳號欄位要驗證的項目寫成測試案例:

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
describe('Account input field', () => {
let accountInputElement: HTMLInputElement;

beforeEach(() => {
accountInputElement = compiledComponent.querySelector('#account')!;
});

it('should have attribute "type" and the value is "email"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'email';
// Assert
expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "account"', () => {
// Arrange
const attributeName = 'name';
const attributeValue = 'account';
// Assert
expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "pattern" and the value is "\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b"', () => {
// Arrange
const attributeName = 'pattern';
const attributeValue = '\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b';
// Assert
expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(accountInputElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of property "account"', () => {
// Arrange
const account = 'whatever';
// Act
component.account = account;
fixture.detectChanges();
// Assert
expect(accountInputElement.getAttribute('ng-reflect-model')).toBe(account);
});

it('should trigger function "accountValueChange" when the value be changed', () => {
// Arrange
spyOn(component, 'accountValueChange');
// Act
accountInputElement.value = 'whatever';
accountInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.accountValueChange).toHaveBeenCalled();
});
});

測試結果:

testing result

在這些測試案例裡,比較特別需要說明的是: should trigger function "accountValueChange" when the value be changed 這個測試案例,怎麼說呢?

大家應該都有發現在這個測試案例裡,有使用一個叫做 spyOn 的函式,這個函式的第一個參數是一個物件,第二個參數是這個物件裡的函式的名字。

這個函式的用意是,它會把該物件裡我們所指定的函式替換成一個叫做 Spy 的物件,讓後續如果有人執行該函式時,實際執行的會是我們替換掉的 Spy 物件,而不是原本我們寫的那個函式,這樣才能在後續去驗證該函式是否已經被呼叫過,甚至還可以知道被呼叫的次數、被呼叫時所傳入的參數等等。

這個方式是大家在寫測試時所慣用的手法。在這個測試案例裡,我們只在意該函式是不是有被觸發,不在意該函式的實際執行結果,因為該函式的實際執行結果已經在寫單元測試的時候驗證過了,而整合測試的部份所在意的是互動行為。

關於測試的替身,可以參考此篇網路文章:Unit Test 中的替身:搞不清楚的Dummy 、Stub、Spy、Mock、Fake

不過這個測試案例其實有個美中不足的地方,因為嚴格來說我們必須要驗證在該函式被呼叫的時候有傳入 accountNgModel.value 與 accountNgModel.errors ,但因為這個物件是透過 Angular 的範本語法去產生出來的,如果要抓到它需要在 Component 裡新增一個屬性,並使用 Angular 的裝飾器 @ViewChild() 來幫我們把這個物件抓出來:

1
2
3
4
export class AppComponent {
@ViewChild('accountNgModel') accountNgModelRef!: NgModel;
// ...
}

如此就能改用 toHaveBeenCalledWith 來驗證:

1
2
3
4
5
6
7
8
9
10
it('should trigger function "accountValueChange" when the value be changed', () => {
// Arrange
spyOn(component, 'accountValueChange');
const accountNgModel = component.accountNgModelRef;
// Act
accountInputElement.value = 'whatever';
accountInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.accountValueChange).toHaveBeenCalledWith(accountNgModel.value, accountNgModel.errors);
});

除了這個測試案例大家可能會不習慣之外,其他的測試看起來滿簡單的對吧?!

密碼欄位的驗證

帳號欄位的測試寫完之後,再來就輪到密碼欄位的部分囉!

複習一下密碼欄位的驗證項目:

  • 屬性 type 的值要是 password
  • 屬性 name 的值要是 password
  • 屬性 minlength 的值要是 8
  • 屬性 maxlength 的值要是 16
  • 要有屬性 required
  • 要將 Component 的屬性 password 的值綁定到此欄位上
  • 此欄位的值如果有變動,要能觸發函式 passwordValueChange

測試程式碼如下:

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
describe('Password input field', () => {
let passwordInputElement: HTMLInputElement;

beforeEach(() => {
passwordInputElement = compiledComponent.querySelector('#password')!;
});

it('should have attribute "type" and the value is "password"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'password';
// Assert
expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "password"', () => {
// Arrange
const attributeName = 'name';
const attributeValue = 'password';
// Assert
expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "minlength" and the value is "8"', () => {
// Arrange
const attributeName = 'minlength';
const attributeValue = '8';
// Assert
expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "maxlength" and the value is "16"', () => {
// Arrange
const attributeName = 'maxlength';
const attributeValue = '16';
// Assert
expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(passwordInputElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of property "password"', () => {
// Arrange
const password = 'whatever';
// Act
component.password = password;
fixture.detectChanges();
// Assert
expect(passwordInputElement.getAttribute('ng-reflect-model')).toBe(password);
});

it('should trigger function "passwordValueChange" when the value be changed', () => {
// Arrange
spyOn(component, 'passwordValueChange');
const passwordNgModel = component.passwordNgModelRef;
// Act
passwordInputElement.value = 'whatever';
passwordInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.passwordValueChange).toHaveBeenCalledWith(passwordNgModel.value, passwordNgModel.errors);
});
});

密碼欄位的部份基本上跟帳號欄位差不多,只有一兩個屬性不一樣而已。

測試結果:

testing result

錯誤訊息的驗證

錯誤訊息的驗證也非常簡單,真要說個比較難的地方,大概就是對於 CSS Selector 的熟悉程度吧!

錯誤訊息要驗證的項目是:

  • 要將 Component 的屬性 accountErrorMessage 的值綁定到畫面上
  • 要將 Component 的屬性 passwordErrorMessage 的值綁定到畫面上

測試程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe('Error Message', () => {
it('should binding the value of property "accountErrorMessage" in the template', () => {
// Arrange
const errorMessage = 'account error';
const targetElement = compiledComponent.querySelector('#account + .error-message');
// Act
component.accountErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should binding the value of property "passwordErrorMessage" in the template', () => {
// Arrange
const errorMessage = 'password error';
const targetElement = compiledComponent.querySelector('#password + .error-message');
// Act
component.passwordErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});

如果你對於 CSS Selector 真的不熟,就在要驗的元素上增加你可以找到的 ID 、類別或者是屬性吧!

測試結果:

testing result

登入按鈕的驗證

最後是登入按鈕的驗證,它的驗證項目是:

  • 屬性 type 的值要是 submit
  • 當表單是無效的狀態時,要有屬性 disabled
  • 當表單是有效的狀態時,沒有屬性 disabled
  • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

程式碼如下:

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
describe('Login button', () => {
let buttonElement: HTMLButtonElement;

beforeEach(() => {
buttonElement = compiledComponent.querySelector('button')!;
});

it('should have attribute "type" and the value is "submit"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'submit';
// Assert
expect(buttonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "disabled" when the form\'s status is invalid', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(true);
});

describe('When the form\'s status is valid', () => {
beforeEach(() => {
component.account = '[email protected]';
component.password = '12345678';
fixture.detectChanges();
});

it('should not have attribute "disabled"', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(false);
});

it('should trigger function "login" when being clicked', () => {
// Arrange
spyOn(component, 'login');
// Act
buttonElement.click();
// Assert
expect(component.login).toHaveBeenCalled();
});
});
});

測試結果:

testing result

咦?怎麼會有 Error 咧?我自己在第一次遇到這個狀況也是有點傻眼,於是我深入調查了之後發現:

testing result

原來是因為 Karma 渲染出來的元素跟 Angular 渲染出來的元素狀態不一樣,Karma 渲染出來的 form 元素跟沒有正確吃到底下的表單欄位:

testing result

關於這個問題,我會再發 issue 詢問官方,如果後續有任何消息,我會再更新此篇文章讓大家知道。

至於目前這個案例,我們可以先在 it 的前面加上一個 x ,代表我們要 ignore 這個案例的意思,像這樣:

1
2
3
4
5
6
xit('should have attribute "disabled" when the form\'s status is invalid', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(true);
});

測試結果:

testing result

至此,我們就完成了整合測試的部份囉!雖然剛好遇到奇怪的問題,但學習如何排除異常也是非常重要的一部分噢!

今天的文章就到這邊,明天我們要為用 Reactive Forms 所撰寫的登入表單來撰寫單元測試,不過我其實昨天其實就教過大家怎麼寫單元測試,在看我的文章之前,建議大家先自己寫寫看再參考我的文章,相信一定會有更多的收穫!

本日小結

再次提醒大家,在寫整合測試時,需要測試的是兩個類別實體之間在各種情況下的互動行為是否符合我們的預期,跟單元測試要測試的重點是很不一樣的。

除此之外,就算我們是在寫整合測試不是單元測試,但依然要盡量做到我在如何寫出優秀的測試?文中所提到的部份噢!

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day06 - 單元測試實作 - 登入系統 by Template Driven Forms

Day6

今天我們要來為我們用 Template Driven Forms 所撰寫的登入系統寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第二天的文章: Template Driven Forms 實作 - 以登入為例。

此外,由於使用 Stackblitz 來寫測試比較麻煩一點,所以我建議大家都使用 ng new 建立新的專案,因為 Angular 都幫開發者處理好了,使用 Angular 的開發者就是這麼幸福。

所以在開始之前,如果當初是用 Stackblitz 練習的話,要先將程式碼複製到專案裡,詳細步驟我就不再贅述囉!

小提醒,將程式碼複製到專案裡之後,記得先使用 ng serve 的指令將其啟動起來看看是不是可以正常運作噢!

此外,如果是用 Angular v12 以上的同學,預設的 typescript 會是 strict mode 的狀態,也就是說型別檢查會比較嚴格一點,所以如果看到很多紅色毛毛蟲不用太擔心。

如果有任何問題,我預言會有 80% 的朋友是忘記在 module 裡 import FormsModule ,哈哈!

實作開始

上述前置作業做完之後,我們就可以先打開 app.component.spec.ts,你應該會看到 Angular CLI 幫我們產生的程式碼:

Testing Sample

我們先把除了 should create the app 之外的測試案例刪掉,刪完應該要長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';

describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
});

it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

至此我稍微說明一下,在 beforeEach 裡我們可以看到有段滿特別的程式碼:

1
2
3
4
5
TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();

這段程式碼是在配置我們測試集合的環境,就像我們在寫 Angular 的時候一樣, Component 會需要一個模組,而 TestBed 是 Angular 幫我們預先寫好給測試用的一個類型,透過 configureTestingModule 來模擬真實使用情境,最後用 compileComponents 將其實際執行。

這段配置在 Angular 基本上會是必備的,並且我們還會需要依據 Component 其實際情況來調整該配置,例如我們現在就因為我們的表單需要的關係,要在這裡引入 FormsModule :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [FormsModule]
}).compileComponents();
});

it('should create the app', () => {
// ...
});
});

接著使用 ng test 的指令將測試程式啟動起來,應該會可以通過我們的第一個測試案例 should create the app:

pass first case

通過這個測試基本上意謂著我們要測試的 Component 的配置沒有什麼太大的問題,因為他要可以被正常建立實體才能通過,至此我們就可以開始來撰寫單元測試了。

欲測試的單元選擇

在第一天時我有提到,單元測試主要是要用來驗證單個類別的函式其實際執行結果是否符合我們預期的執行結果。

所以我們先打開 app.component.ts 來看一下目前的程式碼:

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
79
80
81
export class AppComponent {

// 綁定在帳號欄位上
account = '';

// 綁定在密碼欄位上
password = '';

// 帳號欄位的錯誤訊息
accountErrorMessage = '';

// 密碼欄位的錯誤訊息
passwordErrorMessage = '';

/**
* 綁定在帳號欄位上,當使用者改變帳號時會觸發此函式
*
* @param {string} account
* @param {ValidationErrors} errors
*/
accountValueChange(account: string, errors: ValidationErrors | null): void {
this.account = account;
this.validationCheck(errors, 'account');
}

/**
* 綁定在密碼欄位上,當使用者改變密碼時會觸發此函式
*
* @param {string} password
* @param {ValidationErrors} errors
*/
passwordValueChange(password: string, errors: ValidationErrors | null): void {
this.password = password;
this.validationCheck(errors, 'password');
}

// 綁定在表單上,當使用者按下登入按鈕時會觸發此函式
login(): void {
// do login...
}

/**
* 透過欄位裡的 ValidationErrors 來設定該欄位的錯誤訊息
*
* @param {ValidationErrors | null} errors 欲驗證的欄位的錯誤 (by Angular)
* @param {'account' | 'password'} fieldName 欄位名稱
*/
private validationCheck(
errors: ValidationErrors | null,
fieldName: 'account' | 'password'
): void {
let errorMessage: string;
if (!errors) {
errorMessage = '';
} else if (errors.required) {
errorMessage = '此欄位必填';
} else if (errors.pattern) {
errorMessage = '格式有誤,請重新輸入';
} else if (errors.minlength) {
errorMessage = '密碼長度最短不得低於8碼';
}
this.setErrorMessage(fieldName, errorMessage);
}

/**
* 設定指定欄位的錯誤訊息
*
* @param {'account' | 'password'} fieldName 欲設定錯誤訊息的欄位名稱
* @param {string} errorMessage 欲設定的錯誤訊息
*/
private setErrorMessage(
fieldName: 'account' | 'password',
errorMessage: string
): void {
if (fieldName === 'account') {
this.accountErrorMessage = errorMessage;
} else {
this.passwordErrorMessage = errorMessage;
}
}
}

以目前的程式碼來看,這個 Component 的函式有以下這些:

  1. accountValueChange
  2. passwordValueChange
  3. login
  4. validationCheck
  5. setErrorMessage

這五個函式裡,其中 login 沒寫什麼先不測, validationCheck 與 setErrorMessage 是 private 的也不用測,所以我們主要要測試 accountValueChange 與 passwordValueChange 這兩個函式。

測試單元 - accountValueChange

既然如此,我們先加一個 describe ,表明在這裡面的測試案例都是在測 accountValueChange 這個函式:

1
2
3
4
5
6
7
describe('AppComponent', () => {
// ...

describe('accountValueChange', () => {
// 這裡面的測試案例都是要測這個函式
});
});

然後我們來統整一下這個 accountValueChange 的函式裡會遇到的情況:

  1. 會將傳入的 account 的值賦值給 AppComponent 的屬性 account 。
  2. 如果傳入的 errors 有 required 欄位,則會將錯誤訊息 此欄位必填 賦值給 AppComponent 的屬性 accountErrorMessage 。
  3. 如果傳入的 errors 有 pattern 欄位,則會將錯誤訊息 格式有誤,請重新輸入 賦值給 AppComponent 的屬性 accountErrorMessage 。
  4. 如果傳入的 errors 是 null ,則會將 AppComponent 的屬性 accountErrorMessage 設為空字串。

統整完之後,就可以將上述情況寫成測試案例:

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
describe('accountValueChange', () => {
it('should set value into property "account"', () => {
// Arrange
const account = '[email protected]';
const errors = null;
// Act
component.accountValueChange(account, errors);
// Assert
expect(component.account).toBe(account);
});

it('should set the required error message into property "accountErrorMessage" when the value is empty string', () => {
// Arrange
const account = '';
const errors = { required: true };
const accountErrorMessage = '此欄位必填';
// Act
component.accountValueChange(account, errors);
// Assert
expect(component.accountErrorMessage).toBe(accountErrorMessage);
});

it('should set the pattern error message into property "accountErrorMessage" when the value is not the correct pattern', () => {
// Arrange
const account = 'abc123';
const errors = {
pattern: {
actualValue: 'abc123',
requiredPattern: '^\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b$'
}
};
const accountErrorMessage = '格式有誤,請重新輸入';
// Act
component.accountValueChange(account, errors);
// Assert
expect(component.accountErrorMessage).toBe(accountErrorMessage);
});

it('should set empty string into property "accountErrorMessage" when the value is the correct pattern', () => {
// Arrange
const account = '[email protected]';
const errors = null;
const accountErrorMessage = '';
// Act
component.accountValueChange(account, errors);
// Assert
expect(component.accountErrorMessage).toBe(accountErrorMessage);
});
});

測試結果:

testing result

測試單元 - passwordValueChange

接下來,我們繼續來撰寫測試案例來測試 passwordValueChange 函式,一樣先加一個 describe ,表明在這裡面的測試案例都是在測 passwordValueChange 函式:

1
2
3
4
5
6
7
describe('AppComponent', () => {
// ...

describe('passwordValueChange', () => {
// 這裡面的測試案例都是要測這個函式
});
});

然後我們來統整一下這個 passwordValueChange 的函式裡會遇到的情況:

  1. 會將傳入的 password 的值賦值給 AppComponent 的屬性 password 。
  2. 如果傳入的 errors 有 required 欄位,則會將錯誤訊息 此欄位必填 賦值給 AppComponent 的屬性 passwordErrorMessage 。
  3. 如果傳入的 errors 有 minlength 欄位,則會將錯誤訊息 密碼長度最短不得低於8碼 賦值給 AppComponent 的屬性 passwordErrorMessage 。
  4. 如果傳入的 errors 是 null ,則會將 AppComponent 的屬性 passwordErrorMessage 設為空字串。

統整完之後其實可以發現,這跟剛剛我們測 accountValueChange 的時候很像,所以我們只要複製一下 accountValueChange 的測試案例再稍微改一下就可以用了:

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
describe('passwordValueChange', () => {

it('should set value into property "password"', () => {
// Arrange
const password = 'abc123';
const errors = null;
// Act
component.passwordValueChange(password, errors);
// Assert
expect(component.password).toBe(password);
});

it('should set the required error message into property "passwordErrorMessage" when the value is empty string', () => {
// Arrange
const password = '';
const errors = { required: true };
const passwordErrorMessage = '此欄位必填';
// Act
component.passwordValueChange(password, errors);
// Assert
expect(component.passwordErrorMessage).toBe(passwordErrorMessage);
});

it('should set the pattern error message into property "passwordErrorMessage" when the value is not the correct pattern', () => {
// Arrange
const password = 'abc123';
const errors = {
minlength: {
actualLength: 7,
requiredLength: 8
}
};
const passwordErrorMessage = '密碼長度最短不得低於8碼';
// Act
component.passwordValueChange(password, errors);
// Assert
expect(component.passwordErrorMessage).toBe(passwordErrorMessage);
});

it('should set empty string into property "passwordErrorMessage" when the value is the correct pattern', () => {
// Arrange
const password = 'abcd1234';
const errors = null;
const passwordErrorMessage = '';
// Act
component.passwordValueChange(password, errors);
// Assert
expect(component.passwordErrorMessage).toBe(passwordErrorMessage);
});
});

測試結果:

testing result

至此,我們就完成了單元測試的部份囉!是不是感覺其實很簡單,並沒有想像中的難呢?!俗話說:「萬事起頭難」,只要我們已經跨出第一步,後面就會越來越簡單噢!

今天的文章就到這邊,大家稍微沉澱、吸收一下,明天我們接著撰寫整合測試的部份。

本日小結

再次提醒大家,單元測試要驗證的是某一函式在不同情況下的執行結果是否符合預期,並且記得要盡量做到我在如何寫出優秀的測試?文中所提到的部份。

今天的程式碼比較多,且應該會有很多朋友初次接觸到測試所以可能腦筋會比較轉不過來,這時可以先回頭看看我第四天與第五天的文章,複習一下核心概念與測試語法,相信一定會有所幫助。

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

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day05 - 如何寫出優秀的測試?

Day5

昨天介紹了開始撰寫測試之前必須要知道的二三事之後,想必大家已經對如何開始撰寫測試有了一些概念,但測試不是「有拜有保佑」,有寫就好。所以我們除了要知道如何開始撰寫測試之外,也要知道如何寫出優秀的測試。

什麼是優秀的測試?

我認為要優秀的測試會具備以下三個特質:

  • 值得信賴
  • 易於維護
  • 可讀性高

值得信賴

雖說我們寫測試的目的是為了證明我們的程式碼沒有問題,但不代表我們的測試程式碼值得信賴。

換句話說,如果我們寫出的測試有問題,怎麼證明我們的程式碼沒問題?因此,如何撰寫出令人值得信賴的程式碼就是一個很重要的課題。

易於維護

測試跟我們的程式碼同樣需要維護,而通常這會是很多人之所以「沒辦法」寫測試的原因,每當需求有變動且時間緊迫、資源短缺的情況下,測試就會被拋棄。

但如果我們能夠撰寫出易於維護的測試,就算時間緊迫、資源短缺,也能夠持續讓測試保護我們的程式碼。

可讀性高

優秀的測試程式碼,是可以當成說明書來看的。透過閱讀測試程式碼,我們可以很快地了解被測試的程式具備了哪些功能、要怎麼使用。而且如果測試有問題,我們也能夠可以用最短的時間發現問題的根源。

甚至可以這麼說:一旦測試程式失去了可讀性,也不用想它能夠多易於維護與多值得信賴了。

因此,要如何讓我們的測試具備上述三個特質呢?

撰寫值得信賴的測試

我認為要撰寫出值得信賴的測試要從以下幾個方向著手:

  • 避免在測試中裡寫邏輯
  • 每次只測試一個關注點
  • Code review

避免在測試中裡寫邏輯

我們寫測試是用來驗證我們程式中的邏輯是否正確,一旦我們在寫測試的時候也有邏輯,那是不是還要寫其他的程式來驗證我們的測試?在測試裡,我們不關心過程,只要結果,所以我們不需要在測試裡面寫邏輯,任何的 switch 、 if-else 、 for/while loop 、 try-catch 甚至是四則運算都不應該出現在測試裡,直接把結果寫上去即可。

每次只測試一個關注點

很多時候在我們的程式裡同時做很多事情,這些事情就是我們要測試、驗證的關注點。

以我們前面撰寫過的程式碼來舉例:

1
2
3
4
accountValueChange(accountControl: FormControl): void {
this.account = accountControl.value;
this.validationCheck(accountControl.errors, 'account');
}

這個函式做了兩件事情:

  1. 將 accountControl 的值指定給 account 。
  2. 用 accountControl 的 errors 來判斷要將什麼樣子的錯誤訊息指定給 accountErrorMessage 。

程式碼請參考第二天的文章:Template Driven Forms 實作 - 以登入為例

如果我們將這兩件事情的驗證都寫在同一個測試案例裡,當測試執行時,一旦第一件事情有錯,就不會再驗證第二件事情。

如此一來,我們怎麼知道第二件事情到底是對還是錯?

所以當我們在測試這個函式時,就至少要用兩個測試案例來驗證上述做的兩件事情,以保證我們的測試案例有確實測試到每一件事情。

Code review

有的時候我們自己一個人悶著頭寫,很容易沉浸在自己的世界、無法發現自己的錯誤,這時候我們就需要別人來幫忙我們用更客觀一點的角度來發現我們的不足。

其實幫你 Code review 的人不用一定是比你厲害的人,古語有云:「三人必有我師焉」,每個人都是獨特的,很多時候你沒發現的錯誤、你沒想到的問題、你沒有過的想法,都可以在這時候互相交流,就算幫你 Code review 的人比你差,這也是一個教他的好時機。

撰寫易於維護的測試

要撰寫出易於維護的測試也一樣可以從以下幾個方向著手:

  • 只測試公開的方法
  • 測試也需要重構
  • 測試隔離
  • 比較物件

只測試公開的方法

一般來說,我們會將方法宣告為 private 或是 protected 時,一定是基於很多設計上或安全上的考量,所以我們也只會測試公開的方法。而且宣告為 private 或是 protected 的方法一定不會單獨存在,它們一定會被某個公開方法呼叫(如果沒有就表示這個方法根本就沒人在使用,可以刪掉了),所以當我們測試公開方法時,一定會測到那個被呼叫到的 private 或是 protected 的方法。

這時一定會有人問說:「那我真的很想要測試那個宣告為 private 或是 protected 的方法的話要怎麼辦?」。

如果真的很想要測試那個宣告為 private 或是 protected 的方法,我們可以:

  1. 直接將該方法改為公開方法
  2. 將該方法抽到新的類別裡
  3. 把方法改成靜態方法

我個人比較偏好第二種跟第三種,因為這樣可以讓抽出來的這些方法可以被共用,在後續維護上也比較彈性。

測試也需要重構

正如本文一開始所說的,程式碼需要維護,測試也需要維護;同樣地,程式碼需要重構,測試也需要。

不過測試的重構跟一般程式碼重構的重點稍稍有點不一樣,雖然大體上一樣是要減少重複的程式碼,但前面小節有提到「不要在測試裡寫邏輯」,以及後續會提到「動作與驗證要分開」以提升可讀性,所以在重構時要特別注意。

測試隔離

想想看,你的測試有沒有以下的情況:

  1. 需要以某種順序執行
  2. 會在測試案例裡呼叫其他的測試案例
  3. 沒有重設共用的屬性、資料或者是狀態

如果你的測試有以上任何一種情況,都表示你沒有做好測試隔離。

測試隔離這名字聽起來很專業,其實講白話一點就是讓每個測試案例都是獨立的,不跟其他的測試案例有依賴、或是順序上的關係。每一個測試案例都要能單獨運作,每一個測試案例都要從初始化開始,一直到驗證完、清除或是還原狀態為止,如此才不會影響到其他的測試案例。

撰寫可讀性高的測試

那到底要怎麼樣撰寫可讀性高的測試呢?其實大致上就跟我們開發的時候所要求的差不多,畢竟開發者寫的程式碼並不是給電腦看的,而是給人看的。

所以除了 Clean Code 一書裡提到的部分之外,對測試來說還需要注意以下兩點:

  • 測試案例與測試集合的命名
  • 把驗證和操作分開

測試案例與測試集合的命名

好的測試案例與測試集合的命名,可以讓我們在讀測試程式碼或是測試結果時達到事半功倍的效果。舉例來說,如果我們要測試登入系統的帳號欄位,一個不好的測試案例與測試集合的命名可能會是這樣子的:

1
2
3
4
5
6
7
8
9
10
11
describe('LoginComponent', () => {

it('Test account input - positive', () => {
// ...
});

it('Test account input - negative', () => {
// ...
});

});

雖然可以知道這兩個測試是一個是驗證正向的情境,另一個是驗證負向的情境,但實際上還要去細看測試案例裡面程式碼在寫什麼才會知道當下這個測試案例驗證的是什麼樣的情境,可讀性較差。

而好的測試案例與測試集合的命名可能會是這樣子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('LoginComponent', () => {

describe('accountValueChange', () => {

it('should set value into property "account"', () => {
// ...
});

it('should assign the error message "此欄位必填" to property "accountErrorMessage" when the value is the empty string', () => {
// ...
});

it('should assign the error message "格式有誤,請重新輸入" to property "accountErrorMessage" when the value is not the correct pattern', () => {
// ...
});

it('should assign the empty string to property "accountErrorMessage" when the value is the correct pattern', () => {
// ...
});
});

});

有沒有覺得這樣比較好讀呢?

語言當然不一定要用英文啦,用中文也行,看團隊、主管或者是公司的規範。

把驗證和操作分開

為了可讀性,讓別人可以很好閱讀且很快速地理解我們所寫的內容,所以我們不會為了節省程式碼的空間,而把程式碼都擠在一起,導致看的人還要去動腦思考,降低效率。

例如我們要驗證登入系統的帳號欄位在值改變時,有沒有將 input 欄位的值指派給 Component 的屬性 account ,所以我們有程式碼可能會這樣子寫:

1
2
3
4
5
it('should assign the value to property "account"', () => {
const accountControl = new FormControl('[email protected]');
component.accountValueChange(accountControl);
expect(component.account).toBe(accountControl.value);
});

乍看之下其實沒什麼太大的問題,也不是很難的程式碼,但如果這樣寫會更好一點:

1
2
3
4
5
6
it('should assign the value to property "account"', () => {
const account = '[email protected]';
const accountControl = new FormControl(account);
component.accountValueChange(accountControl);
expect(component.account).toBe(account);
});

又或者是這樣:

1
2
3
4
5
6
it('should assign the value to property "account"', () => {
const accountControl = new FormControl('[email protected]');
component.accountValueChange(accountControl);
const account = accountControl.value;
expect(component.account).toBe(account);
});

簡單來說就是一步一步來,將動作跟驗證分開,減少一些閱讀時的負擔,會讓整個程式碼更好閱讀。

此外,在撰寫測試時,有個 3A 原則的方式非常推薦大家使用。

3A 原則

這是在測試的世界裡,非常著名的方法。可以說是只要照著這個方法寫,滿簡單就能寫出不錯的測試。

而這個 3A 分別指的是:

  • Arrange - 準備物件或者是進行必要的前置作業。
  • Act - 實際執行、操作物件。
  • Assert - 進行結果驗證

以上面的程式碼為例, 3A 是這樣分的:

1
2
3
4
5
6
7
8
9
it('should assign the value to property "account"', () => {
// Arrange
const account = '[email protected]';
const accountControl = new FormControl(account);
// Act
component.accountValueChange(accountControl);
// Assert
expect(component.account).toBe(account);
});

這樣看起來是不是更好讀了呢?

雖然已經說了那麼多,但當程式已經實作好之後再來補測試其實是還滿辛苦的,因此有一種開發方式叫做測試驅動開發。

測試驅動開發

測試驅動開發,也就是所謂的 TDD (Test-driven development) 。

這個方式有一個流程,如下圖所示:

心法

  1. 一開始要先寫測試不實作程式碼,這時測試會是紅燈的狀態
  2. 只實作足以讓測試通過的程式碼,這時測試就會通過變成綠燈
  3. 當反覆這樣子做了幾次之後,實作的程式碼變多了可能會需要重構
  4. 重構完之後,如果測試變成了紅燈,我們就再調整實作使其變成綠燈
  5. 重複循環這個過程

這樣子的作法有滿多好處的,像是:

  • 測試跟開發同步進行,有多少測試就寫多少程式碼
  • 由於測試先行,所以寫出來的程式碼都很好被測試
  • 由於有測試保護,在不斷重構的過程中並不會出現改 A 壞 B 的情況
  • 由於會不斷地重構,所以寫出來的程式碼會很好維護

雖然聽起來很簡單、好處很多,但在這流程中還是要注意以下三點:

  • 絕不跳過重構
  • 儘快變綠
  • 出錯後放慢腳步

此外,我建議大家在寫按照這個方式開發時,注意以下幾件事情:

  • 編寫測試時就僅僅關注測試,不想去如何實現
  • 先以調用方的角度來調用這塊代碼,並且從調用方的角度說出所期望的結果
  • 在編寫某個功能的代碼之前先編寫測試代碼,然後只編寫使測試通過的功能代碼
  • 所有的實現都是測試「逼」出來的,所有的實現代碼都是為了讓測試通過而編寫的

本日小結

今天的重點主要是分享何謂優秀的測試與如何撰寫出優秀的測試這兩點上,後面所分享測試驅動開發是提供一種更好寫測試的開發方法給大家參考。

雖然我已經將如何寫測試、如何寫出好的測試都分享給大家了,但羅馬不是一天造成的,沒有人一開始就能寫得出很好的測試。唯有不斷地練習與學習,才能越寫越輕鬆、越寫越快樂。

總之,坐而言不如起而行,撰寫測試對於專業的軟體工程師來說絕對是一件利大於弊的事情,因此,從今天就開始寫測試吧!

此外,非常推薦大家閱讀書籍:「單元測試的藝術」,裡面對於「什麼是優秀的測試」與「如何撰寫優秀的測試」的部份會講得更加詳細與完整。

對於我今天所分享的部份,如果我有講錯或是大家有任何想要補充的部分,都非常歡迎留言在下面或訊息我讓我知道噢!

Your browser is out-of-date!

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

×