Angular 深入浅出三十天:表单与测试 Day14 - 单元测试实作 - 被保人 by Reactive Forms

Day14

今天我们要来为我们用 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();
});

测试单元 - formArray

这个单元很单纯,基本只要验在 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);
  });
});

测试结果:

testing result

测试单元 - isFormInvalid

这个单元基本上要测三个状况:

  1. formArray 里的 controls 的长度为 0 时,回传 true
  2. formGroup 里有任何 errors 时,回传 true
  3. formArray 里的 controls 的长度不为 0formGroup 里也没有任何 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);
  });
});

测试结果:

testing result

测试单元 - ngOnInit

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);
  });
});

验得越粗糙,测试对你的单元保护力越低;反之则越高。所以就看你想要提供给你要测的单元怎麽样的保护。

测试结果:

testing result

测试单元 - addInsured & deleteInsured

这两个单元就更没难度了,一个只是验证执行後, 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);
  });
});

测试结果:

testing result

我知道一定有人会有一个疑问:「为什麽测 deleteInsured 的时候, Arrange 的部分不直接用 component.addInsured() 就好,还要自己敲?」。

这是因为我们要做到测试隔离,大家还记得吗?不记得的赶快回去翻第五天的文章:如何写出优秀的测试?

大家可以想想,如果今天我们真的使用了 component.addInsured() ,之後哪一天 addInsured 这个函式被改坏了不就也连带导致了 deleteInsured 这个不相干的测试也会跑失败吗?

虽然广义一点来讲,一个跑失败跟两个跑失败貌似没什麽区别,都是失败。但在实质意义上来说就差很多,这点务必请大家铭记在心。

测试单元 - getErrorMessage

最後是大家都非常熟悉的 getErrorMessage ,有没有一种整天都在测这个案例的感觉?

虽然前面都测得比较随便粗糙,我们这个单元测仔细一点好了。

要验证的项目如下:

  • 如果用错误的 key 值导致找不到对应的 FormControl ,则回传空字串。
  • 如果该栏位没有任何错误,则回传空字串。
  • 如果该栏位的 pristinetrue,则回传空字串。
  • 如果该栏位的有 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('姓名至多只能输入十个字');
  });
});

测试结果:

testing result

今天所有测试的结果:

testing result

本日小结

跟昨天一样的是,其实测试手法大致上差不多就这些,当然更复杂的情境会用到其他的手法,但目前主要还是以让大家多熟悉、多练习为主,後面才会提到更复杂的情况。

我个人觉得,提高撰写测试的功力不外乎就是练习以及多跟他人交流,所以如果在公司没人可以帮你 code review 或是你也不会帮其他人 code review 的话,是很可惜的一件事。

今天实作程序码一样会放在 Github - Branch: day14 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

如果有任何的问题或是回馈,也都非常欢迎留言给我让我知道噢!


<<:  android studio 30天学习笔记-day 14-databinding 单向绑定

>>:  Day29 ( 高级 ) 绘制正多角星形 ( 多线版 )

Day28 我还是视觉动物

Integration with pivot table and chart 承续昨天所列的第一点...

D11 - 分子料理 解构赋值 Destructing Assignment

前言 let shoppingList = ['Oreo', 'Tilamisu', 'snicke...

那些被忽略但很好用的 Web API / RequestAnimationFrame

别再用 setTimeOut、setInterval 写动画啦! 如果你有用 js 写过动画,那通...

Day 21 - Memorized Hook: useCallback

如果有错误,欢迎留言指教~ Q_Q 没写完啦 useCallback 回传一个 memoized ...

今日份的爬虫

这次找了个漫画网站来爬关於一部漫画的资讯。本来想要将资讯一个个罗列出来比较整齐,结果遇到了点困难无法...