iT邦幫忙

2021 iThome 鐵人賽

DAY 15
0
Modern Web

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

Angular 深入淺出三十天:表單與測試 Day15 - 整合測試實作 - 被保人 by Reactive Forms

  • 分享至 

  • xImage
  •  

Day15

昨天幫我們用 Reactive Forms 所撰寫的被保人表單寫完單元測試之後,今天則是要來為它寫整合測試。

大家還記得整合測試的目標是要測什麼嗎?我幫大家複習一下:

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

實作開始

首先我們先增加一個 Integration testing 的區塊,有關於整合測試的程式碼接下來都會放在這裡面,至於昨天的就放在 Unit testing 的區塊:

describe('TemplateDrivenFormsAsyncInsuredComponent', () => {
  // 其他省略...

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

跟之前樣先打開 .html 來看一下目前的程式碼:

<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
  <ng-container
    formArrayName="insuredList"
    *ngFor="let control of formArray.controls; let index = index"
  >
    <fieldset [formGroupName]="index">
      <legend>被保人</legend>
      <p>
        <label [for]="'name-' + index">姓名:</label>
        <input type="text" [id]="'name-' + index" formControlName="name" />
        <span class="error-message">{{ getErrorMessage("name", index) }}</span>
      </p>
      <p>
        性別:
        <input
          type="radio"
          [id]="'male-' + index"
          value="male"
          formControlName="gender"
        />
        <label [for]="'male-' + index">男</label>
        <input
          type="radio"
          [id]="'female-' + index"
          value="female"
          formControlName="gender"
        />
        <label [for]="'female-' + index">女</label>
      </p>
      <p>
        <label [for]="'age-' + index">年齡:</label>
        <select name="age" [id]="'age-' + index" formControlName="age">
          <option value="">請選擇</option>
          <option value="18">18歲</option>
          <option value="20">20歲</option>
          <option value="70">70歲</option>
          <option value="75">75歲</option>
        </select>
        <span class="error-message">{{ getErrorMessage("age", index) }}</span>
      </p>
      <p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
    </fieldset>
  </ng-container>
  <p>
    <button type="button" (click)="addInsured()">新增被保險人</button>
    <button type="submit" [disabled]="isFormInvalid">送出</button>
  </p>
</form>

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

  • 姓名欄位
    • 屬性 type 的值要是 text
    • 屬性 formControlName 的值要是 name
    • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值只有一個字時,則顯示 姓名至少需兩個字以上 的錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值超過十個字時,則顯示 姓名至多只能輸入十個字 的錯誤訊息
  • 性別欄位
      • 屬性 type 的值要是 radio
      • 屬性 value 的值要是 male
      • 屬性 formControlName 的值要是 gender
      • 屬性 type 的值要是 radio
      • 屬性 value 的值要是 male
      • 屬性 formControlName 的值要是 gender
  • 年齡欄位
    • 屬性 formControlName 的值要是 age
    • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
    • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
  • 新增被保人按鈕
    • 按下按鈕要能觸發函式 addInsured
  • 刪除被保人按鈕
    • 按下按鈕要能觸發函式 deleteInsured
  • 送出按鈕
    • 屬性 type 的值要是 submit
    • 沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
    • 任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
    • 當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit

把要測的項目都列出來之後,有沒有覺得要測的項目很多阿?哈哈!

再次跟大家說明,雖然上面這些項目有些其實並不真的屬於整合測試的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼。

大家應該還記得怎麼測吧?忘記的趕快回去看一下之前的文章!

此外,開始之前也別忘記先做以下程式碼所展示的前置作業,後面將不再贅述:

describe('Integration testing', () => {
  let compiledComponent: HTMLElement;

  beforeEach(() => {
    compiledComponent = fixture.nativeElement;
  });

  // 案例寫在這邊
});

姓名欄位的驗證

複習一下姓名欄位的驗證項目:

  • 屬性 type 的值要是 text
  • 屬性 formControlName 的值要是 name
  • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值只有一個字時,則顯示 `姓名至少

程式碼如下:

describe('the insured fields', () => {
  let formGroup: FormGroup;

  beforeEach(() => {
    const nameControl = new FormControl('', [
      Validators.required,
      Validators.minLength(2),
      Validators.maxLength(10)
    ]);
    const genderControl = new FormControl('', Validators.required);
    const ageControl = new FormControl('', Validators.required);
    formGroup = new FormGroup({
      name: nameControl,
      gender: genderControl,
      age: ageControl
    });
    component.formArray.push(formGroup);
    fixture.detectChanges();
  });

  describe('the name input field', () => {
    let nameInputElement: HTMLInputElement;

    beforeEach(() => {
      nameInputElement = compiledComponent.querySelector('#name-0')!;
    });

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

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

    describe('Error Messages', () => {
      let nameFormControl: FormControl;

      beforeEach(() => {
        nameFormControl = formGroup.get('name') as FormControl;
      });

      it('should be empty string when property "pristine" of the "formControl" is `true`', () => {
        // Arrange
        const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
        // Assert
        expect(targetElement?.textContent).toBe('');
      });

      describe('when the field is dirty', () => {

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

        it('should be "此欄位必填" when the value is empty string', () => {
          // Arrange
          const errorMessage = '此欄位必填';
          const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
          // Assert
          expect(targetElement?.textContent).toBe(errorMessage);
        });

        it('should be "姓名至少需兩個字以上" when the value\'s length less than 2', () => {
          // Arrange
          nameFormControl.setValue('A')
          const errorMessage = '姓名至少需兩個字以上';
          const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
          // Act
          fixture.detectChanges();
          // Assert
          expect(targetElement?.textContent).toBe(errorMessage);
        });

        it('should be "姓名至多只能輸入十個字" when the value\'s length greater than 10', () => {
          // Arrange
          nameFormControl.setValue('ABCDE123456')
          const errorMessage = '姓名至多只能輸入十個字';
          const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
          // Act
          fixture.detectChanges();
          // Assert
          expect(targetElement?.textContent).toBe(errorMessage);
        });

        it('should be empty string when there are not any errors', () => {
          // Arrange
          nameFormControl.setValue('ABCDE123456')
          const errorMessage = '姓名至多只能輸入十個字';
          const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
          // Act
          fixture.detectChanges();
          // Assert
          expect(targetElement?.textContent).toBe(errorMessage);
        });
      });
    });
  });
});

測試結果:

testing result

這段程式碼中有兩個重點:

  1. 為了之後測其他欄位,我多新增了一個 test insured fieldsdescribe 。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 beforeEach 來做。

  2. 切記不要使用 component.addInsured() 來新增被保人。

性別欄位的驗證

性別欄位要驗證的部份非常簡單,項目如下:

    • 屬性 type 的值要是 radio
    • 屬性 value 的值要是 male
    • 屬性 formControlName 的值要是 gender
    • 屬性 type 的值要是 radio
    • 屬性 value 的值要是 male
    • 屬性 formControlName 的值要是 gender

測試程式碼如下:

describe('the gender radio buttons', () => {
  let radioButtonElement: HTMLInputElement;

  describe('male', () => {
    beforeEach(() => {
      radioButtonElement = compiledComponent.querySelector(`#male-0`)!;
    });

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

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

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

  describe('female', () => {
    beforeEach(() => {
      radioButtonElement = compiledComponent.querySelector(`#female-0`)!;
    });

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

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

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

測試結果:

testing result

年齡欄位的驗證

年齡欄位要驗證的項目如下:

  • 屬性 formControlName 的值要是 age
  • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息

程式碼如下:

describe('the age field', () => {
  const key = 'age-0'
  let ageSelectElement: HTMLSelectElement;

  beforeEach(() => {
    ageSelectElement = compiledComponent.querySelector(`#${key}`)!;
  });

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

  describe('Error Messages', () => {
    let ageFormControl: FormControl;

    beforeEach(() => {
      ageFormControl = formGroup.get('age') as FormControl;
    });

    it('should be empty string when property "pristine" of the "formControl" is `true`', () => {
      // Arrange
      const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
      // Assert
      expect(targetElement?.textContent).toBe('');
    });

    describe('when the field is dirty', () => {
      beforeEach(() => {
        ageFormControl.markAsDirty();
        fixture.detectChanges();
      });

      it('should be "此欄位必填" when the value is empty string', () => {
        // Arrange
        const errorMessage = '此欄位必填';
        const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
        // Assert
        expect(targetElement?.textContent).toBe(errorMessage);
      });
    });
  });
});

年齡欄位的驗證跟姓名的驗證有 87% 像,複製過來再稍微調整一下即可。

測試結果:

testing result

刪除按鈕的驗證

刪除被保人按鈕要驗證的是:按下按鈕要能觸發函式 deleteInsured 。這部份大家只要使用 Spy 的技巧來驗證即可,也是頗為簡單。

程式碼如下:

describe('Delete insured button', () => {
  it('should trigger function `deleteInsured` after being clicked', () => {
    // Arrange
    const index = 0;
    const deleteButtonElement = compiledComponent.querySelector('fieldset button[type="button"]') as HTMLElement;
    spyOn(component, 'deleteInsured');
    // Act
    deleteButtonElement.click();
    // Assert
    expect(component.deleteInsured).toHaveBeenCalledWith(index);
  });
});

測試結果:

testing result

新增被保人按鈕的驗證

新增被保人按鈕要驗證的是:按下按鈕要能觸發函式 addInsured ,跟刪除被保人的按鈕要驗證的項目幾乎是一模一樣,複製過來稍微修改一下即可。

程式碼如下:

describe('add insured button', () => {
  it('should trigger function `addInsured` after being clicked', () => {
    // Arrange
    const addButtonElement = compiledComponent.querySelector('p:last-child button[type="button"]') as HTMLElement;
    spyOn(component, 'addInsured');
    // Act
    addButtonElement.click();
    // Assert
    expect(component.addInsured).toHaveBeenCalled();
  });
});

測試結果:

testing result

送出按鈕的驗證

最後,送出按鈕要驗證的項目是:

  • 屬性 type 的值要是 submit
  • 沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
  • 任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit

程式碼如下:

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

  beforeEach(() => {
    buttonElement = compiledComponent.querySelector('button[type="submit"]') as HTMLButtonElement;
  });

  it('should be existing', () => {
    // Assert
    expect(buttonElement).toBeTruthy();
  });

  it('should be disabled when there are not any insureds', () => {
    // Assert
    expect(buttonElement.hasAttribute('disabled')).toBe(true);
  });

  describe('When there is a insured', () => {
    let formGroup: FormGroup;

    beforeEach(() => {
      const nameControl = new FormControl('', [
        Validators.required,
        Validators.minLength(2),
        Validators.maxLength(10)
      ]);
      const genderControl = new FormControl('', Validators.required);
      const ageControl = new FormControl('', Validators.required);
      formGroup = new FormGroup({
        name: nameControl,
        gender: genderControl,
        age: ageControl
      });
      component.formArray.push(formGroup);
      fixture.detectChanges();
    });

    it('should be disabled when there ara any verifying errors that insured\'s data', () => {
      // Arrange
      compiledComponent.querySelector('button[type="submit"]')
      // Act
      fixture.detectChanges();
      // Assert
      expect(buttonElement.hasAttribute('disabled')).toBe(true);
    })

    it('should be enabled when there ara any verifying errors that insured\'s data', () => {
      // Arrange
      formGroup.patchValue({
        name: 'Leo',
        gender: 'male',
        age: '18',
      });
      // Act
      fixture.detectChanges();
      // Assert
      expect(buttonElement.hasAttribute('disabled')).toBe(false);
    })
  });
});

測試結果:

testing result

至此,我們就完成了整合測試的部份囉!

今天所有的測試結果:

testing result

本日小結

今天一樣主要是讓大家練習,提昇撰寫測試的熟悉度,該講的重點應該在之前的文章都有提到。

不過我相信大家應該寫差不多類型的測試寫到有點索然無味了,所以我明天不會讓大家寫測試,而是會總結一下 Template Driven FormsReactive Forms 這兩種開發方式的優缺點,敬請期待。

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

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


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

1 則留言

0
ryan851109
iT邦新手 5 級 ‧ 2022-03-02 17:53:00

Hi Leo,
我發現name的驗證應該可以調整成像age那樣的結構會整潔許多XD

看更多先前的回應...收起先前的回應...
Leo iT邦新手 3 級 ‧ 2022-03-04 09:00:43 檢舉

Hi ryan851109, 請問是什麼意思呢?

仔細看一下應該也不是像age那樣,因為我有稍微調整一下the name input field > Error Messages的驗證程式碼,想說每個it裡面都有targetElement,就把他提出來放在宣告nameFormControl的地方,並在第一個beforeEach時去抓取內容,再把nameFormControl取值的部分移到第二個beforeEach,因為'should be empty string when property "pristine" of the "formControl" is true'這個it用不掉nameFormControl,如此一來程式碼就少了蠻多行的,應該不會出甚麼問題吧XD
示意圖 :
https://ithelp.ithome.com.tw/upload/images/20220304/20108518SwhEDUQseT.png

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

不拉出來的考量就像我們在其他篇討論的那樣,不過如果跑測試可以過的話可以先這樣XD

好的~如果後面有出現問題,我應該就會印象深刻了XD

Leo iT邦新手 3 級 ‧ 2022-03-07 09:19:51 檢舉

沒錯,這也是我自己的經驗分享,有痛過就會懂了XDD

我要留言

立即登入留言