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

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 供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

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


<<:  Day 18动画原理

>>:  [Day 15] Dialog 弹跳视窗

Day09-为了让表单资料不要太过自大,给予其正确的绝望-Validation(II)

标题参考来源 大家好~ 如果有个表单验证需要大量重复使用的话, 我们可以为此表单验证建立一个 For...

Flutter体验 Day 25-SharedPreferences

SharedPreferences 有时候我们在应用程序会需要保存登入Session资料、个人偏好设...

Angular 深入浅出三十天:表单与测试 Day02 - Template Driven Forms 实作 - 以登入为例

今天要来用 Template Driven Forms 的方式实作一个简单的登入系统,撇开 UI ...

Day 18 : 笔记篇 05— 如何整理学习笔记?分享我的学习笔记整理流程

前言 在 上一篇文章 中,我提到使用 Obsidian 处理笔记的过程,但在「纪录资讯」这一段没有多...

day31 虽然没有写完,但是还是要有summary

第一次写文章,其实我没有什麽自信能够写得好,毕竟我一直都不够厉害,所以也觉得自己没能写出什麽;後来真...