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

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

評論

Your browser is out-of-date!

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

×