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

Day15

昨天幫我們用 Reactive 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
<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-message">{{ 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-message">{{ 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>

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

  • 姓名欄位
    • 屬性 type 的值要是 text
    • 屬性 formControlName 的值要是 name
    • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值只有一個字時,則顯示 姓名至少需兩個字以上 的錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值超過十個字時,則顯示 姓名至多只能輸入十個字 的錯誤訊息
  • 性別欄位
      • 屬性 type 的值要是 radio
      • 屬性 value 的值要是 male
      • 屬性 formControlName 的值要是 gender
      • 屬性 type 的值要是 radio
      • 屬性 value 的值要是 male
      • 屬性 formControlName 的值要是 gender
  • 年齡欄位
    • 屬性 formControlName 的值要是 age
    • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
  • 新增被保人按鈕
    • 按下按鈕要能觸發函式 addInsured
  • 刪除被保人按鈕
    • 按下按鈕要能觸發函式 deleteInsured
  • 送出按鈕
    • 屬性 type 的值要是 submit
    • 沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
    • 任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
    • 當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit

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

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

大家應該還記得怎麼測吧?忘記的趕快回去看一下之前的文章!

此外,開始之前也別忘記先做以下程式碼所展示的前置作業,後面將不再贅述:

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

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

// 案例寫在這邊
});

姓名欄位的驗證

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

  • 屬性 type 的值要是 text
  • 屬性 formControlName 的值要是 name
  • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值只有一個字時,則顯示 `姓名至少

程式碼如下:

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
describe('the insured fields', () => {
let formGroup: FormGroup;

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

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

beforeEach(() => {
nameInputElement = compiledComponent.querySelector('#name-0')!;
});

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 "formControlName" and the value is "name"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'name';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

describe('Error Messages', () => {
let nameFormControl: FormControl;

beforeEach(() => {
nameFormControl = formGroup.get('name') as FormControl;
});

it('should be empty string when property "pristine" of the "formControl" is `true`', () => {
// Arrange
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe('');
});

describe('when the field is dirty', () => {

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

it('should be "此欄位必填" when the value is empty string', () => {
// Arrange
const errorMessage = '此欄位必填';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should be "姓名至少需兩個字以上" when the value\'s length less than 2', () => {
// Arrange
nameFormControl.setValue('A')
const errorMessage = '姓名至少需兩個字以上';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should be "姓名至多只能輸入十個字" when the value\'s length greater than 10', () => {
// Arrange
nameFormControl.setValue('ABCDE123456')
const errorMessage = '姓名至多只能輸入十個字';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should be empty string when there are not any errors', () => {
// Arrange
nameFormControl.setValue('ABCDE123456')
const errorMessage = '姓名至多只能輸入十個字';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});
});
});
});

測試結果:

testing result

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

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

  2. 切記不要使用 component.addInsured() 來新增被保人。

性別欄位的驗證

性別欄位要驗證的部份非常簡單,項目如下:

    • 屬性 type 的值要是 radio
    • 屬性 value 的值要是 male
    • 屬性 formControlName 的值要是 gender
    • 屬性 type 的值要是 radio
    • 屬性 value 的值要是 male
    • 屬性 formControlName 的值要是 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
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 "formControlName" and the value is "gender"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'gender';
// 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);
});
});

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 "formControlName" and the value is "gender"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'gender';
// 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);
});
});
});

測試結果:

testing result

年齡欄位的驗證

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

  • 屬性 formControlName 的值要是 age
  • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息

程式碼如下:

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

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

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

describe('Error Messages', () => {
let ageFormControl: FormControl;

beforeEach(() => {
ageFormControl = formGroup.get('age') as FormControl;
});

it('should be empty string when property "pristine" of the "formControl" is `true`', () => {
// Arrange
const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe('');
});

describe('when the field is dirty', () => {
beforeEach(() => {
ageFormControl.markAsDirty();
fixture.detectChanges();
});

it('should be "此欄位必填" when the value is empty string', () => {
// Arrange
const errorMessage = '此欄位必填';
const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});
});
});

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

測試結果:

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
49
50
51
52
53
54
55
56
57
58
59
60
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 there are not any insureds', () => {
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(true);
});

describe('When there is a insured', () => {
let formGroup: FormGroup;

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

it('should be disabled when there ara any verifying errors that insured\'s data', () => {
// Arrange
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
formGroup.patchValue({
name: 'Leo',
gender: 'male',
age: '18',
});
// Act
fixture.detectChanges();
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(false);
})
});
});

測試結果:

testing result

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

今天所有的測試結果:

testing result

本日小結

今天一樣主要是讓大家練習,提昇撰寫測試的熟悉度,該講的重點應該在之前的文章都有提到。

不過我相信大家應該寫差不多類型的測試寫到有點索然無味了,所以我明天不會讓大家寫測試,而是會總結一下 Template Driven FormsReactive Forms 這兩種開發方式的優缺點,敬請期待。

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

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

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 的長度不為 0formGroup 裡也沒有任何 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 ,則回傳空字串。
  • 如果該欄位沒有任何錯誤,則回傳空字串。
  • 如果該欄位的 pristinetrue,則回傳空字串。
  • 如果該欄位的有 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 fieldsdescribe 。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 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;
}
}

以目前的程式碼來看,我們要測的單元有 trackByIndexinsuredNameChangeinsuredAgeChangeaddInsureddeleteInsured 這五個,接下來我們照順序先從 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. 如果傳入的 errorsrequired 欄位,則會將錯誤訊息 此欄位必填 賦值給傳入的 insured 裡的 nameErrorMessage
  3. 如果傳入的 errorsminlength 欄位,則會將錯誤訊息 姓名至少需兩個字以上 賦值傳入的 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. 如果傳入的 errorsrequired 欄位,則會將錯誤訊息 此欄位必填 賦值給傳入的 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. 清楚 toBetoEqual 的差別。
  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" ,大家可以自行選用喜歡的方式。

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

目前的結果:

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 ,它其實是一個抽象類別,而 FormGroupFormArrayFormControl 這三種類型其實都繼承於這個類別,所以大家不知道有沒有注意到,一般我們在 .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 互相比較一下,除了 forid 這兩個屬性因為天生侷限的關係真的沒辦法之外,但 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-0NgModel 們就不見了,只留下 xxx-1NgModel 們。在這之後如果再按新增被保人時,由於新增的那一筆的索引是 1 ,就又會把原本留下的 Key 值為 xxx-1NgModel 們蓋掉,導致大家現在所看到的情況。

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 被呼叫時, formBuildergroup 函式有沒有被呼叫,像是這樣:

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 在渲染畫面的時候,直接用該欄位的 formControlerrors 來取得對應的錯誤訊息,所以我們在驗證的時候就不能用上次的方式驗,具體請看我的測試程式碼:

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. 學習如何正確驗證「直接用該欄位的 formControlerrors 來取得對應的錯誤訊息」的情況。

程式碼的部份一樣會放在 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.requiredformControl ;當我需要驗證 密碼長度最短不得低於8碼 的錯誤訊息時,我只需要配置 Validators.minlength(8)formControl ,依此類推。

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

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

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

測試結果:

testing result

測試單元 - ngOnInit

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

  1. ngOnInit 執行之前, formGroupundefined 的狀況。
  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 ngInitbeforeEach 裡是用 fixture.detectChanges() 來觸發 ngOnInit() ,而不是使用 component.ngOnInit() 的方式來觸發,這是因為我認為我們在寫的是 Angular ,而這個 Lifecycle Hook 又是 Angular 的東西,所以使用 Angular 的機制來觸發會比直接使用該函式觸發來的好。

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

測試結果:

testing result

本日小結

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

今日的實作程式碼一樣會放在 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.valueaccountNgModel.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 沒寫什麼先不測, validationChecksetErrorMessageprivate 的也不用測,所以我們主要要測試 accountValueChangepasswordValueChange 這兩個函式。

測試單元 - accountValueChange

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

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

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

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

  1. 會將傳入的 account 的值賦值給 AppComponent 的屬性 account
  2. 如果傳入的 errorsrequired 欄位,則會將錯誤訊息 此欄位必填 賦值給 AppComponent 的屬性 accountErrorMessage
  3. 如果傳入的 errorspattern 欄位,則會將錯誤訊息 格式有誤,請重新輸入 賦值給 AppComponent 的屬性 accountErrorMessage
  4. 如果傳入的 errorsnull ,則會將 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. 如果傳入的 errorsrequired 欄位,則會將錯誤訊息 此欄位必填 賦值給 AppComponent 的屬性 passwordErrorMessage
  3. 如果傳入的 errorsminlength 欄位,則會將錯誤訊息 密碼長度最短不得低於8碼 賦值給 AppComponent 的屬性 passwordErrorMessage
  4. 如果傳入的 errorsnull ,則會將 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 供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

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

Your browser is out-of-date!

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

×