iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0
Modern Web

Angular 深入淺出三十天:表單與測試系列 第 14

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

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


上一篇
Angular 深入淺出三十天:表單與測試 Day13 - 整合測試實作 - 被保人 by Template Driven Forms
下一篇
Angular 深入淺出三十天:表單與測試 Day15 - 整合測試實作 - 被保人 by Reactive Forms
系列文
Angular 深入淺出三十天:表單與測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
ryan851109
iT邦新手 5 級 ‧ 2022-03-02 15:25:40

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)
https://ithelp.ithome.com.tw/upload/images/20220302/20108518vqJ80xkyAH.png

看更多先前的回應...收起先前的回應...
Leo iT邦新手 3 級 ‧ 2022-03-02 15:39:01 檢舉

在上面測試單元 - formArray的部分中,Act部分是否能想成Arrange?因為感覺const formArray = component.formGroup?.get('insuredList') as FormArray;這行只是單純取出資料而已,並非有操作任何funtion

我個人是覺得那件事情已經是執行了 formGroupget function ,而 Arrange 比較像是在執行 Act 之前的事前準備。

另外在測試單元 - ngOnInit的部分中,應該可以省略fixture.detectChanges();,因為在beforeEach的地方已經呼叫過了XD

對耶!這部份我沒留意到,感謝指正!!

我個人是覺得那件事情已經是執行了 formGroup 的 get function ,而 Arrange 比較像是在執行 Act 之前的事前準備。

原來如此~如果是這樣考量,放在Act是比較合理的
我上面還有第三個問題,但好像我們同時在打,所以沒有讓你看到XD

Leo iT邦新手 3 級 ‧ 2022-03-02 17:53:04 檢舉

第三個問題的話是的,連內容都比對會比較好一點

了解~感謝筆者的回覆

我要留言

立即登入留言