今天我們要來為我們用 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 { formGroup: FormGroup | undefined ; get formArray(): FormArray { return this .formGroup?.get('insuredList' )! as FormArray; } get isFormInvalid(): boolean { return this .formArray.controls.length === 0 || this .formGroup!.invalid; } constructor (private formBuilder: FormBuilder ) {} ngOnInit(): void { this .formGroup = this .formBuilder.group({ insuredList: this .formBuilder.array([]) }); } addInsured(): void { const formGroup = this .createInsuredFormGroup(); this .formArray.push(formGroup); } deleteInsured(index: number ): void { this .formArray.removeAt(index); } submit(): void { } 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 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(); });
這個單元很單純,基本只要驗在 ngOnInit
被觸發後,可以取得 formArray
即可。
程式碼如下:
1 2 3 4 5 6 7 8 describe('formArray' , () => { it('should get the FormArray from the FormGroup after "ngOnInit" being trigger' , () => { const formArray = component.formGroup?.get('insuredList' ) as FormArray; expect(component.formArray).toBe(formArray); }); });
測試結果:
這個單元基本上要測三個狀況:
formArray
裡的 controls
的長度為 0
時,回傳 true
formGroup
裡有任何 errors
時,回傳 true
formArray
裡的 controls
的長度不為 0
且 formGroup
裡也沒有任何 errors
時,回傳 false
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 describe('isFormInvalid' , () => { it('should be true when there are not any insureds' , () => { const expectedResult = component.isFormInvalid; expect(expectedResult).toBe(true ); }); it('should be true when there are any errors' , () => { const formControl = new FormControl('' , Validators.required); component.formArray.push(formControl); const expectedResult = component.isFormInvalid; expect(expectedResult).toBe(true ); }); it('should be false when there are not any errors' , () => { const formControl = new FormControl('' ); component.formArray.push(formControl); const expectedResult = component.isFormInvalid; expect(expectedResult).toBe(false ); }); });
測試結果:
測試單元 - ngOnInit ngOnInit
要驗證的情況也很簡單,就是看執行完有沒有順利地把 formGroup
建立出來。
不過要驗證到什麼地步就看個人了,例如我們可以很簡單地這樣子驗:
1 2 3 4 5 6 7 8 describe('ngOnInit' , () => { it('should initialize property "formGroup"' , () => { fixture.detectChanges(); expect(component.formGroup).toBeTruthy(); }); });
也可以驗稍微仔細一點:
1 2 3 4 5 6 7 8 describe('ngOnInit' , () => { it('should initialize property "formGroup"' , () => { fixture.detectChanges(); expect(component.formGroup).toBeInstanceOf(FormGroup); }); });
驗得越粗糙,測試對你的單元保護力越低;反之則越高。所以就看你想要提供給你要測的單元怎麼樣的保護。
測試結果:
測試單元 - 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"' , () => { component.addInsured(); expect(component.formArray.length).toBe(1 ); }); }); describe('deleteInsured' , () => { it('should remove the "formGroup" from the "formArray" by the index' , () => { const index = 0 ; const formGroup = new FormGroup({}); component.formArray.push(formGroup); component.deleteInsured(index); expect(component.formArray.length).toBe(0 ); }); });
測試結果:
我知道一定有人會有一個疑問:「為什麼測 deleteInsured
的時候, Arrange 的部分不直接用 component.addInsured()
就好,還要自己敲?」。
這是因為我們要做到測試隔離 ,大家還記得嗎?不記得的趕快回去翻第五天的文章:如何寫出優秀的測試?
大家可以想想,如果今天我們真的使用了 component.addInsured()
,之後哪一天 addInsured
這個函式被改壞了不就也連帶導致了 deleteInsured
這個不相干的測試也會跑失敗嗎?
雖然廣義一點來講,一個跑失敗跟兩個跑失敗貌似沒什麼區別,都是失敗。但在實質意義上來說就差很多,這點務必請大家銘記在心。
測試單元 - getErrorMessage 最後是大家都非常熟悉的 getErrorMessage
,有沒有一種整天都在測這個案例的感覺?
雖然前面都測得比較隨便粗糙,我們這個單元測仔細一點好了。
要驗證的項目如下:
如果用錯誤的 key
值導致找不到對應的 FormControl
,則回傳空字串。
如果該欄位沒有任何錯誤,則回傳空字串。
如果該欄位的 pristine
為 true
,則回傳空字串。
如果該欄位的有 required
的錯誤,則回傳 此欄位必填
如果該欄位的有 minlength
的錯誤,則回傳 姓名至少需兩個字以上
如果該欄位的有 maxlength
的錯誤,則回傳 姓名至多只能輸入十個字
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 describe('getErrorMessage' , () => { let formGroup: FormGroup; beforeEach(() => { const nameControl = new FormControl('' , [ Validators.required, Validators.minLength(2 ), Validators.maxLength(10 ) ]); formGroup = new FormGroup({ name: nameControl, }); component.formArray.push(formGroup); }); it('should return empty string with the wrong key' , () => { const key = 'leo' const index = 0 ; const errorMessage = component.getErrorMessage(key, index); expect(errorMessage).toBe('' ); }); it('should return empty string when the "formControl" without errors' , () => { const key = 'name' const index = 0 ; formGroup.get(key)?.setValue('Leo' ); const errorMessage = component.getErrorMessage(key, index); expect(errorMessage).toBe('' ); }); it('should return empty string when property "pristine" of the "formControl" is `true`' , () => { const key = 'name' const index = 0 ; const errorMessage = component.getErrorMessage(key, index); expect(errorMessage).toBe('' ); }); it('should return "此欄位必填" when the "formControl" has the required error' , () => { const key = 'name' const index = 0 ; formGroup.get(key)?.markAsDirty(); const errorMessage = component.getErrorMessage(key, index); expect(errorMessage).toBe('此欄位必填' ); }); it('should return "姓名至少需兩個字以上" when the "formControl" has the min-length error' , () => { const key = 'name' const index = 0 ; const formControl = formGroup.get(key)!; formControl.setValue('A' ) formControl.markAsDirty(); const errorMessage = component.getErrorMessage(key, index); expect(errorMessage).toBe('姓名至少需兩個字以上' ); }); it('should return "姓名至多只能輸入十個字" when the "formControl" has the max-length error' , () => { const key = 'name' const index = 0 ; const formControl = formGroup.get(key)!; formControl.setValue('ABCDEF123456' ) formControl.markAsDirty(); const errorMessage = component.getErrorMessage(key, index); expect(errorMessage).toBe('姓名至多只能輸入十個字' ); }); });
測試結果:
今天所有測試的結果:
本日小結 跟昨天一樣的是,其實測試手法大致上差不多就這些,當然更複雜的情境會用到其他的手法,但目前主要還是以讓大家多熟悉、多練習為主,後面才會提到更複雜的情況。
我個人覺得,提高撰寫測試的功力不外乎就是練習 以及多跟他人交流 ,所以如果在公司沒人可以幫你 code review 或是你也不會幫其他人 code review 的話,是很可惜的一件事。
今天實作程式碼一樣會放在 Github - Branch: day14 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!