今天我們要來為我們用 Reactive Forms 所撰寫的被保人表單寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第十一天的文章: Reactive Forms 實作 - 動態表單初體驗。
複習一下目前的程式碼:
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
裡,這樣就不用在每個測試案例加了。
像這樣:
beforeEach(() => {
// 其他省略
fixture.detectChanges();
});
這個單元很單純,基本只要驗在 ngOnInit
被觸發後,可以取得 formArray
即可。
程式碼如下:
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);
});
});
測試結果:
這個單元基本上要測三個狀況:
formArray
裡的 controls
的長度為 0
時,回傳 true
formGroup
裡有任何 errors
時,回傳 true
formArray
裡的 controls
的長度不為 0
且 formGroup
裡也沒有任何 errors
時,回傳 false
程式碼如下:
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);
});
});
測試結果:
ngOnInit
要驗證的情況也很簡單,就是看執行完有沒有順利地把 formGroup
建立出來。
不過要驗證到什麼地步就看個人了,例如我們可以很簡單地這樣子驗:
describe('ngOnInit', () => {
it('should initialize property "formGroup"', () => {
// Act
fixture.detectChanges();
// Assert
expect(component.formGroup).toBeTruthy();
});
});
也可以驗稍微仔細一點:
describe('ngOnInit', () => {
it('should initialize property "formGroup"', () => {
// Act
fixture.detectChanges();
// Assert
expect(component.formGroup).toBeInstanceOf(FormGroup);
});
});
驗得越粗糙,測試對你的單元保護力越低;反之則越高。所以就看你想要提供給你要測的單元怎麼樣的保護。
測試結果:
這兩個單元就更沒難度了,一個只是驗證執行後, formArray
的長度有沒有增加;另一個則是減少 formArray
的長度。
程式碼如下:
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);
});
});
測試結果:
我知道一定有人會有一個疑問:「為什麼測 deleteInsured
的時候, Arrange 的部分不直接用 component.addInsured()
就好,還要自己敲?」。
這是因為我們要做到測試隔離,大家還記得嗎?不記得的趕快回去翻第五天的文章:如何寫出優秀的測試?
大家可以想想,如果今天我們真的使用了 component.addInsured()
,之後哪一天 addInsured
這個函式被改壞了不就也連帶導致了 deleteInsured
這個不相干的測試也會跑失敗嗎?
雖然廣義一點來講,一個跑失敗跟兩個跑失敗貌似沒什麼區別,都是失敗。但在實質意義上來說就差很多,這點務必請大家銘記在心。
最後是大家都非常熟悉的 getErrorMessage
,有沒有一種整天都在測這個案例的感覺?
雖然前面都測得比較隨便粗糙,我們這個單元測仔細一點好了。
要驗證的項目如下:
key
值導致找不到對應的 FormControl
,則回傳空字串。pristine
為 true
,則回傳空字串。required
的錯誤,則回傳 此欄位必填
minlength
的錯誤,則回傳 姓名至少需兩個字以上
maxlength
的錯誤,則回傳 姓名至多只能輸入十個字
程式碼如下:
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('姓名至多只能輸入十個字');
});
});
測試結果:
今天所有測試的結果:
跟昨天一樣的是,其實測試手法大致上差不多就這些,當然更複雜的情境會用到其他的手法,但目前主要還是以讓大家多熟悉、多練習為主,後面才會提到更複雜的情況。
我個人覺得,提高撰寫測試的功力不外乎就是練習以及多跟他人交流,所以如果在公司沒人可以幫你 code review 或是你也不會幫其他人 code review 的話,是很可惜的一件事。
今天實作程式碼一樣會放在 Github - Branch: day14 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!
Hi Leo,
在上面測試單元 - formArray
的部分中,Act部分是否能想成Arrange?因為感覺const formArray = component.formGroup?.get('insuredList') as FormArray;
這行只是單純取出資料而已,並非有操作任何funtion
在測試單元 - ngOnInit
的部分中,應該可以省略fixture.detectChanges();
,因為在beforeEach的地方已經呼叫過了XD
在測試單元 - addInsured & deleteInsured
的部分中,addInsured是否需要自製一個FormArray出來跟component中的formArray作比較?因為想說先前的Template Driven Forms
在單元測試時也是用這樣的方法,那時我也是想說用長度去判斷就好,但覺得筆者有確認新增內容的寫法應該更好
示意圖:
(Template Driven Forms)
在上面測試單元 - formArray的部分中,Act部分是否能想成Arrange?因為感覺const formArray = component.formGroup?.get('insuredList') as FormArray;這行只是單純取出資料而已,並非有操作任何funtion
我個人是覺得那件事情已經是執行了 formGroup
的 get
function ,而 Arrange 比較像是在執行 Act 之前的事前準備。
另外在測試單元 - ngOnInit的部分中,應該可以省略fixture.detectChanges();,因為在beforeEach的地方已經呼叫過了XD
對耶!這部份我沒留意到,感謝指正!!
我個人是覺得那件事情已經是執行了 formGroup 的 get function ,而 Arrange 比較像是在執行 Act 之前的事前準備。
原來如此~如果是這樣考量,放在Act是比較合理的
我上面還有第三個問題,但好像我們同時在打,所以沒有讓你看到XD
第三個問題的話是的,連內容都比對會比較好一點
了解~感謝筆者的回覆