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

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

評論

Your browser is out-of-date!

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

×