今天我们要来为我们用 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 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!
如果有任何的问题或是回馈,也都非常欢迎留言给我让我知道噢!
<<: android studio 30天学习笔记-day 14-databinding 单向绑定
>>: Day29 ( 高级 ) 绘制正多角星形 ( 多线版 )
Integration with pivot table and chart 承续昨天所列的第一点...
前言 let shoppingList = ['Oreo', 'Tilamisu', 'snicke...
别再用 setTimeOut、setInterval 写动画啦! 如果你有用 js 写过动画,那通...
如果有错误,欢迎留言指教~ Q_Q 没写完啦 useCallback 回传一个 memoized ...
这次找了个漫画网站来爬关於一部漫画的资讯。本来想要将资讯一个个罗列出来比较整齐,结果遇到了点困难无法...