iT邦幫忙

2021 iThome 鐵人賽

DAY 9
0
Modern Web

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

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

Day9

昨天幫我們用 Reactive Forms 所撰寫的登入系統寫完單元測試之後,今天則是要來為它寫整合測試。

再次幫大家複習一下整合測試的測試目標:

整合測試的測試目標是要測試兩個或是兩個以上的類別之間的互動是否符合我們的預期。

如果對於整合測試在測什麼還沒有概念的話,建議大家先回到第七天的文章複習一下:整合測試實作 - 登入系統 by Template Driven Forms

實作開始

跟上次一樣先增加一個 describe 的區塊,有關於整合測試的程式碼接下來都會放在這裡面:

import { TestBed } from '@angular/core/testing';

import { AppComponent } from './app.component';

describe('AppComponent', () => {
  let component: AppComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [AppComponent],
      imports: [
        FormsModule,
        ReactiveFormsModule
      ]
    }).compileComponents();

    const fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
  });

  describe('Unit testing', () => {
    // 昨天寫的單元測試...
  });
  
  describe('Integration testing', () => {
    // 今天要寫的整合測試
  });
});

一般我們不會特別將單元測試跟整合測試的程式碼分開檔案來寫,只會用測試集合將其區隔。

上次有提到整合測試跟畫面會比較有相關,但這次因為我們有使用到第二個類別 FormBuilder ,所以我們先來看 xxxx.component.ts 的程式碼:

export class AppComponent {
  
  // 以上省略...

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

  // 以下省略...
}

以整合測試要驗證的項目來說,這邊其實可以驗在 ngOnInit 被呼叫時, formBuildergroup 函式有沒有被呼叫,像是這樣:

it('should call function "group" of the "FormBuilder" when function "ngOnInit" be trigger', () => {
  // Arrange
  const formBuilder = TestBed.inject(FormBuilder);
  spyOn(formBuilder, 'group');
  // Act
  fixture.detectChanges();
  // Assert
  expect(formBuilder.group).toHaveBeenCalled();
});

不過我個人覺得這個測試案例在這裡沒啥必要,一方面是因為我們在單元測試已經有驗過 FormGroup 了, 另一方面則是因為在這裡我們其實並不在意 FormBuilder 的互動,只要 FormGroup 那邊的測試有符合預期即可。

因為 FormGroup 除了可以用 FormBuilder 來產生實體之外,也可以直接用 new FormGroup() 的方式來產生實體。

接著我們回來看畫面的部分,目前的程式碼大致上應該會長這樣:

<form
  *ngIf="formGroup"
  [formGroup]="formGroup"
  (ngSubmit)="login()"
>
  <p>
    <label for="account">帳號:</label>
    <input
      type="email"
      id="account"
      [formControl]="accountControl"
    />
    <span class="error-message">{{ getErrorMessage(accountControl) }}</span>
  </p>
  <p>
    <label for="password">密碼:</label>
    <input
      type="password"
      id="password"
      [formControl]="passwordControl"
    />
    <span class="error-message">{{ getErrorMessage(passwordControl) }}</span>
  </p>
  <p>
    <button type="submit" [disabled]="formGroup.invalid">登入</button>
  </p>
</form>

大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:

  • 帳號欄位
    • 屬性 type 的值要是 email
    • 要將 accountControl 綁定到此欄位上
  • 密碼欄位
    • 屬性 type 的值要是 password
    • 要將 passwordControl 綁定到此欄位上
  • 錯誤訊息
    • 要將帳號欄位的錯誤訊息綁定到畫面上
    • 要將密碼欄位的錯誤訊息綁定到畫面上
  • 登入按鈕
    • 屬性 type 的值要是 submit
    • 當表單是無效的狀態時,要有屬性 disabled
    • 當表單是有效的狀態時,沒有屬性 disabled
    • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

列完之後大家有沒有發現,跟上次測 Template Driven Forms 的時候相比,要驗證的項目少了很多對吧?!

某方面來說,這是因為我們把一些原本是在這時候驗的項目轉移到單元測試上的緣故;另一方面是,有些項目可以多驗一些不同的狀況,容我後續遇到時再加以說明。

帳號欄位的驗證

跟上次一樣先來驗證帳號欄位,複習一下帳號欄位的驗證項目:

  • 屬性 type 的值要是 email
  • 要將 accountControl 綁定到此欄位上

然後把帳號欄位要驗證的項目寫成測試案例:

describe('Account input field', () => {
  let accountInputElement: HTMLInputElement;

  beforeEach(() => {
    accountInputElement = compiledComponent.querySelector('#account')!;
  });

  it('should have attribute "type" and the value is "email"', () => {
    // Arrange
    const attributeName = 'type';
    const attributeValue = 'email';
    // Assert
    expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
  });

  it('should binding with formControl "accountControl"', () => {
    // Arrange
    const account = 'whatever';
    // Act
    component.accountControl.patchValue(account);
    fixture.detectChanges();
    // Assert
    expect(accountInputElement.value).toBe(account);
  });
});

測試結果:

testing result

在這些測試案例裡,比較特別需要說明的是: should binding with formControl "accountControl" 這個測試案例,怎麼說呢?

大家有沒有發現,這個測試案例跟上一個測試案例的驗證方式不太一樣?上一個是用 getAttribute 的方式,而這測試案例卻不是?

在講原因之前,要先跟大家報告的是,其實將 FormControl 綁定到某個表單欄位上的方法有以下兩種:

  1. 直接用某個 FormControl 的實體綁定,使用方式是在該欄位用屬性綁定的方式綁定時體,如: [formControl]="accountControl"(也就是我目前使用的方式)。
  2. 使用該欄位在 FormGroup 內所對應的 Key Name 來綁定,如: [formControlName]="'account'" 或者是 formControlName="account"

[formControlName]="'account'"formControlName="account" 之間的差別在,前者在 Angular 裡叫做屬性綁定,意思是可以將其跟某個 Component 的屬性綁定;後者就只是在該元素上多加了一個自定的 HTML 的屬性,其值是寫死的。

如果是使用第二種的方式去將 FormControl 綁定到某個表單欄位上的話,在寫測試時可以很簡單的只用 getAttribute 的方式驗證。但是如果是使用第一種方式的話,就必須用我上面程式碼所示範的方式拐著彎驗,如果用 getAttribute 的方式來驗的話,只會取得 '[Object Object]' 這種沒有辦法進一步驗證的字串。

密碼欄位的驗證

至於密碼欄位的部分,也跟帳號欄位差不多,其驗證項目如下:

  • 屬性 type 的值要是 password
  • 要將 passwordControl 綁定到此欄位上

測試程式碼如下:

describe('Password input field', () => {
  let passwordInputElement: HTMLInputElement;

  beforeEach(() => {
    passwordInputElement = compiledComponent.querySelector('#password')!;
  });

  it('should have attribute "type" and the value is "password"', () => {
    // Arrange
    const attributeName = 'type';
    const attributeValue = 'password';
    // Assert
    expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
  });

  it('should binding with formControl "passwordControl"', () => {
    // Arrange
    const password = 'whatever';
    // Act
    component.passwordControl.patchValue(password);
    fixture.detectChanges();
    // Assert
    expect(passwordInputElement.value).toBe(password);
  });
});

測試結果:

testing result

錯誤訊息的驗證

錯誤訊息要驗證的項目是:

  • 要將帳號欄位的錯誤訊息綁定到畫面上
  • 要將密碼欄位的錯誤訊息綁定到畫面上

為什麼這兩個項目的敘述感覺起來很籠統呢?

這是因為在我們原本的程式碼中,我們沒有特別用變數來儲存該欄位的錯誤訊息,而是直接讓 Template 在渲染畫面的時候,直接用該欄位的 formControlerrors 來取得對應的錯誤訊息,所以我們在驗證的時候就不能用上次的方式驗,具體請看我的測試程式碼:

describe('Error Message', () => {
  it('should binding error message "格式有誤,請重新輸入" with the error of "accountControl"', () => {
    // Arrange
    const errorMessage = '格式有誤,請重新輸入';
    const targetElement = compiledComponent.querySelector('#account + .error-message');
    // Act
    component.accountControl.setValue('abc');
    component.accountControl.markAsDirty();
    fixture.detectChanges();
    // Assert
    expect(targetElement?.textContent).toBe(errorMessage);
  });

  it('should binding error message "密碼長度最短不得低於8碼" with the error of "passwordControl"', () => {
    // Arrange
    const errorMessage = '密碼長度最短不得低於8碼';
    const targetElement = compiledComponent.querySelector('#password + .error-message');
    // Act
    component.passwordControl.setValue('abc');
    component.passwordControl.markAsDirty();
    fixture.detectChanges();
    // Assert
    expect(targetElement?.textContent).toBe(errorMessage);
  });
});

從程式碼中可以看到,這邊要先將值設給對應的 formControl 並且 markAsDirty() 之後,才能抓取到正確的錯誤訊息。

這其實是因為在我們的程式碼裡, formControl 的狀態如果是 pristine 的話,會回傳空字串。

雖然我這邊目前是用各自欄位才會有的錯誤訊息來表示驗了兩種不同欄位,但其實是可以分成兩個欄位,然後將所有的情況都驗一遍。

不過這樣就會跟單元測試有點重疊,這部份大家可以自行斟酌。

測試結果:

testing result

登入按鈕的驗證

最後是登入按鈕的驗證,它的驗證項目是:

  • 屬性 type 的值要是 submit
  • 當表單是無效的狀態時,要有屬性 disabled
  • 當表單是有效的狀態時,沒有屬性 disabled
  • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

程式碼如下:

describe('Login button', () => {
  let buttonElement: HTMLButtonElement;

  beforeEach(() => {
    buttonElement = compiledComponent.querySelector('button')!;
  });

  it('should have attribute "type" and the value is "submit"', () => {
    // Arrange
    const attributeName = 'type';
    const attributeValue = 'submit';
    // Assert
    expect(buttonElement.getAttribute(attributeName)).toBe(attributeValue);
  });

  it('should have attribute "disabled" when the form\'s status is invalid', () => {
    // Arrange
    const attributeName = 'disabled';
    // Assert
    expect(buttonElement.hasAttribute(attributeName)).toBe(true);
  });

  describe('When the form\'s status is valid', () => {
    beforeEach(() => {
      component.formGroup?.setValue({
        account: 'abc@email.tw',
        password: '12345678'
      });
      fixture.detectChanges();
    });

    it('should not have attribute "disabled"', () => {
      // Arrange
      const attributeName = 'disabled';
      // Assert
      expect(buttonElement.hasAttribute(attributeName)).toBe(false);
    });

    it('should trigger function "login" when being clicked', () => {
      // Arrange
      spyOn(component, 'login');
      // Act
      buttonElement.click();
      // Assert
      expect(component.login).toHaveBeenCalled();
    });
  });
});

測試結果:

testing result

這次沒有任何預期外的狀況,不像上次剛好遇到奇怪的問題,搞不好這又是 Reactive Forms 的另一個優點呢!(笑)。

至此,我們已經完成了第一個里程碑:用 Template Driven Forms 的方式與用 Reactive Forms 的方式各自實作一個登入系統,並且也都為它們寫了單元測試以及整合測試,相信大家對於如何使用 Angular 製作表單與撰寫測試都有了長足的進步。

明天開始就要邁入下一個里程碑:用 Template Driven Forms 的方式與用 Reactive Forms 的方式各自實作一個動態的表單,並且也要都為它們寫單元測試以及整合測試,敬請期待(壞笑)。

本日小結

今天的重點主要有以下兩點:

  1. 學習如何正確驗證「將 formControl 綁定到表單欄位上」,並了解用不同的綁定方式在驗證上會有哪些差異。
  2. 學習如何正確驗證「直接用該欄位的 formControlerrors 來取得對應的錯誤訊息」的情況。

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

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


上一篇
Angular 深入淺出三十天:表單與測試 Day08 - 單元測試實作 - 登入系統 by Reactive Forms
下一篇
Angular 深入淺出三十天:表單與測試 Day10 - Template Driven Forms 實作 - 動態表單初體驗
系列文
Angular 深入淺出三十天:表單與測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
TD
iT邦新手 4 級 ‧ 2021-09-25 09:09:18

/images/emoticon/emoticon12.gif

Leo iT邦新手 3 級 ‧ 2021-09-25 09:29:30 檢舉

/images/emoticon/emoticon13.gif

0
ryan851109
iT邦新手 5 級 ‧ 2022-02-25 15:32:17

不好意思~在實作驗證在 ngOnInit 被呼叫時, formBuilder 的 group 函式有沒有被呼叫 的程式碼時發生了點問題,其測試結果會失敗
https://ithelp.ithome.com.tw/upload/images/20220225/20108518eiboxoYV2c.png
因此我在程式碼中加入了log來顯示程式執行的過程,在componemt中的constructor、ngOninit也有標示
https://ithelp.ithome.com.tw/upload/images/20220225/20108518smtwe54VmX.png
其結果顯示在beforeEach的地方detectChanges執行的地方有跑進ngOninit,但在測試範例中的detectChanges卻沒有,導致測試失敗,想問一下是否有東西遺漏才導致這樣的情形發生?
https://ithelp.ithome.com.tw/upload/images/20220225/20108518T1BfNGa9LY.png

看更多先前的回應...收起先前的回應...
Leo iT邦新手 3 級 ‧ 2022-02-26 17:18:30 檢舉

Hi ryan851109,

ngOnInit 只會執行一次喔!由於你在 beforeEach 的時候已經觸發執行了,當然在你實際要測的時候就不會再執行了唷!

原來如此~原本以為detectChanges()會從生命週期的ngOnChanges()重頭來一次,移除後,測試範例呼叫detectChanges()時有進到ngOninit(),但變成了另一個問題>︿<
https://ithelp.ithome.com.tw/upload/images/20220301/20108518RUqfyrwosB.png
錯誤訊息的理解為FormGroup沒有實體,呼叫detectChanges()後,程式進到ngOninit會出錯,導致沒有實體產出,但一樣的呼叫在beforeEach裡卻沒有錯誤,目前檢查程式碼應該是沒什麼問,在spec中也有引入FormsModule,不知道是否有其他該注意而未注意到的呢?
示意圖:
(component.spec.ts)
https://ithelp.ithome.com.tw/upload/images/20220301/20108518uQNxxHJddp.png
https://ithelp.ithome.com.tw/upload/images/20220301/201085180ISUheZiTK.png
(component.ts)
https://ithelp.ithome.com.tw/upload/images/20220301/201085188FZ5y5ztEF.png

Leo iT邦新手 3 級 ‧ 2022-03-01 16:55:29 檢舉

因為你用 spyOn 把它 Spy 掉,但沒有回傳一個 FormGroup 的實體給它。

你可以改成 spyOn(formBuilder, 'group').and.returnValue(new FormGroup({...}))

成功了(≧∇≦)ノ感謝作者的幫忙,原來spyOn還有這樣的用法,程式碼改成如下
示意圖:
(component.spec.ts)
https://ithelp.ithome.com.tw/upload/images/20220301/20108518d7zH1Nk48h.png

Leo iT邦新手 3 級 ‧ 2022-03-02 15:40:02 檢舉

/images/emoticon/emoticon12.gif

我要留言

立即登入留言