iT邦幫忙

2021 iThome 鐵人賽

DAY 8
0
Modern Web

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

Angular 深入淺出三十天:表單與測試 Day08 - 單元測試實作 - 登入系統 by Reactive Forms

Day8

今天我們要來為我們用 Reactive Forms 所撰寫的登入系統寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第三天的文章: Reactive Forms 實作 - 以登入為例

實作開始

前置作業基本上都跟第六天的文章:單元測試實作 - 登入系統 by Template Driven Forms 相同,今天就不會再贅述,大家如果忘記怎麼做可以先回去複習一下。

目前的程式碼:

export class AppComponent {
  formGroup: FormGroup | undefined;

  get accountControl(): FormControl {
    return this.formGroup!.get('account') as FormControl;
  }

  get passwordControl(): FormControl {
    return this.formGroup!.get('password') as FormControl;
  }

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({
      account: [
        '',
        [
          Validators.required,
          Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
        ]
      ],
      password: [
        '',
        [Validators.required, Validators.minLength(8), Validators.maxLength(16)]
      ]
    });
  }

  getErrorMessage(formControl: FormControl): string {
    let errorMessage = '';
    if (!formControl.errors || formControl.pristine) {
      errorMessage = '';
    } else if (formControl.errors.required) {
      errorMessage = '此欄位必填';
    } else if (formControl.errors.pattern) {
      errorMessage = '格式有誤,請重新輸入';
    } else if (formControl.errors.minlength) {
      errorMessage = '密碼長度最短不得低於8碼';
    } else if (formControl.errors.maxlength) {
      errorMessage = '密碼長度最長不得超過16碼';
    }
    return errorMessage;
  }

  login(): void {
    // do login...
  }
}

以目前的程式碼來看,基本上我們只要驗 getErrorMessage 這個函式,不過我們其實也能驗 ngOnInit 這個 Angular Component Lifecycle Hook 的執行結果,畢竟它也是個函式,我們一樣可以寫測試去驗證這個函式的執行結果是否符合我們的預期。

關於 Angular Component Lifecycle Hook ,如果想知道更多可以閱讀官方文件: Component Lifecycle hooks

測試單元 - getErrorMessage

我們一樣先加一個 describe ,表明在這裡面的測試案例都是在測 getErrorMessage 這個函式:

describe('AppComponent', () => {
  // ...

  describe('getErrorMessage', () => {
    // 這裡面的測試案例都是要測這個函式
  });
});

接著統整一下這個 getErrorMessage 的函式裡會遇到的情況:

  1. 如果傳入的 formControl 裡沒有任何 error ,則會取得空字串。
  2. 如果傳入的 formControl 的屬性 pristine 的值為 true ,則會取得空字串。
  3. 如果傳入的 formControl 裡有必填的錯誤: required ,則會取得錯誤訊息 此欄位必填
  4. 如果傳入的 formControl 裡有格式的錯誤: pattern ,則會取得錯誤訊息 格式有誤,請重新輸入
  5. 如果傳入的 formControl 裡有最小長度的錯誤: minlength ,則會取得錯誤訊息 密碼長度最短不得低於8碼
  6. 如果傳入的 formControl 裡有最大長度的錯誤: maxlength ,則會取得錯誤訊息 密碼長度最長不得超過16碼

統整完之後,就可以將上述情況寫成測試案例:

describe('getErrorMessage', () => {
  it('should get empty string when the value is correct', () => {
    // Arrange
    const formControl = new FormControl('');
    const expectedMessage = '';
    // Act
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get empty string when the value is empty string but the form control is pristine', () => {
    // Arrange
    const formControl = new FormControl('', [Validators.required]);
    const expectedMessage = '';
    // Act
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "此欄位必填" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('', [Validators.required]);
    const expectedMessage = '此欄位必填';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "格式有誤,請重新輸入" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('whatever', [Validators.pattern('/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi')]);
    const expectedMessage = '格式有誤,請重新輸入';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "密碼長度最短不得低於8碼" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('abc', [Validators.minLength(8)]);
    const expectedMessage = '密碼長度最短不得低於8碼';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "密碼長度最長不得超過16碼" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('12345678901234567', [Validators.maxLength(16)]);
    const expectedMessage = '密碼長度最長不得超過16碼';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });
});

從上面的程式碼中可以看出,我這次寫單元測試的策略是:讓每個案例自己配置足以驗證該案例的 formControl 與其必須的 Validators 即可。

也就是說,當我需要驗證 此欄位必填 的錯誤訊息時,我只需要配置 Validators.requiredformControl ;當我需要驗證 密碼長度最短不得低於8碼 的錯誤訊息時,我只需要配置 Validators.minlength(8)formControl ,依此類推。

會這樣寫是因為我們只需要專注在什麼樣子的 errors 會得到什麼樣子的錯誤訊息上面,當然大家也可以每次都幫 formControl 配置最完整的 Validators ,這兩個方法我覺得都可以。

此外,由於我們這次有判斷 formControl 的狀態: pristine ,因此在寫測試的時候要特別留意,記得要先 markAsDirty 之後才能測試噢!

上一次寫單元測試的文章: 單元測試實作 - 登入系統 by Template Driven Forms

測試結果:

testing result

測試單元 - ngOnInit

再來是 ngOnInit 的部份, ngOnInit 要驗證的項目跟 formGroup 滿相關,所以我打算用 formGroup 當測試集合的名稱,具體要驗證的項目有:

  1. ngOnInit 執行之前, formGroupundefined 的狀況。
  2. ngOnInit 執行之後,
    1. formGroup 是類型為 FormGroup 的實體。
    2. formGroup 裡要有兩個 FormControl
      1. accountFormControl
        • 要有必填的驗證
        • 要有 Email 格式的驗證
      2. passwordFormControl
        • 要有必填的驗證
        • 要有字串最小長度為 8 的驗證
        • 要有字串最大長度為 16 的驗證

程式碼如下:

describe('formGroup', () => {
  it('should be undefined before init', () => {
    // Assert
    expect(component.formGroup).toBeFalsy();
  });

  describe('after ngInit', () => {

    beforeEach(() => {
      fixture.detectChanges();
    });

    it('should be instance of FormGroup', () => {
      // Assert
      expect(component.formGroup).toBeInstanceOf(FormGroup);
    });

    it('should have 2 form controls', () => {
      // Arrange
      const formControls = component.formGroup!.controls;
      const controlLength = Object.keys(formControls).length;
      // Assert
      expect(controlLength).toBe(2);
    });

    describe('accountFormControl', () => {

      it('should have the required validator', () => {
        // Arrange
        const error = component.accountControl.errors!;
        // Assert
        expect(error.required).toBe(true);
      });

      it('should have the email pattern validator', () => {
        // Arrange
        component.accountControl.setValue('abc');
        const error = component.accountControl.errors!;
        const expectedPattern = '/^\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b$/gi';
        // Assert
        expect(error.pattern.requiredPattern).toBe(expectedPattern);
      });

    });

    describe('passwordFormControl', () => {

      it('should have the required validator', () => {
        // Arrange
        const error = component.accountControl.errors!;
        // Assert
        expect(error.required).toBe(true);
      });

      it('should have the min-length validator', () => {
        // Arrange
        component.passwordControl.setValue('abc');
        const error = component.passwordControl.errors!;
        // Assert
        expect(error.minlength.requiredLength).toBe(8);
      });

      it('should have the max-length validator', () => {
        // Arrange
        component.passwordControl.setValue('12345678901234567');
        const error = component.passwordControl.errors!;
        // Assert
        expect(error.maxlength.requiredLength).toBe(16);
      });
    });
  });
});

此處比較特別的地方是,我在 after ngInitbeforeEach 裡是用 fixture.detectChanges() 來觸發 ngOnInit() ,而不是使用 component.ngOnInit() 的方式來觸發,這是因為我認為我們在寫的是 Angular ,而這個 Lifecycle Hook 又是 Angular 的東西,所以使用 Angular 的機制來觸發會比直接使用該函式觸發來的好。

當然也是可以直接使用 component.ngOnInit() 來觸發,在測試的驗證結果上其實不會有什麼不同,所以用哪個方式其實都可以。

測試結果:

testing result

本日小結

已經寫了兩次的測試,相信大家對於測試的熟悉度已經有顯著地提昇,而今天的重點主要會是在使用 FormControl markAsDirty改變欄位的狀態,以及了解 fixture.detectChangesngOnInit 的關係,未來在寫測試的時候,這兩點也是非常需要多加留意的。

今日的實作程式碼一樣會放在 Github - Branch: day8 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

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


上一篇
Angular 深入淺出三十天:表單與測試 Day07 - 整合測試實作 - 登入系統 by Template Driven Forms
下一篇
Angular 深入淺出三十天:表單與測試 Day09 - 整合測試實作 - 登入系統 by Reactive Forms
系列文
Angular 深入淺出三十天:表單與測試30
0
TD
iT邦新手 4 級 ‧ 2021-09-23 17:15:28

那時候同一個欄位是用樣的 formControl 個別去驗

用同樣的?

Leo iT邦新手 3 級 ‧ 2021-09-23 17:30:19 檢舉

哈,語句不太順,我剛剛順了一下,應該比較好一些了

0
TD
iT邦新手 4 級 ‧ 2021-09-23 17:16:12

第一次這麼認真看別人寫測試,學到不少新東西 :D

Leo iT邦新手 3 級 ‧ 2021-09-23 17:31:47 檢舉

大家互相交流學習囉!

0
peter4405
iT邦新手 5 級 ‧ 2021-12-13 16:33:19

可以問一下 假設我那個密碼長度我想變成動態撈取maxlength的值 然後放到 expectedMessage中
如 '密碼長度最長不得超過$0碼'; 那個$0部分該怎麼改寫才能讓她變成動態直接撈取maxlength的值

Leo iT邦新手 3 級 ‧ 2021-12-14 09:21:49 檢舉

Hi peter4405,

方法基本上有兩個:

  1. 撈取完再初始化表單
  2. 撈取完再重新設定該欄位的 Validators
peter4405 iT邦新手 5 級 ‧ 2021-12-15 17:19:10 檢舉

感謝! 我再來試看看

我要留言

立即登入留言