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

Day13

昨天帮我们用 Template Driven Forms 所撰写的被保人表单写完单元测试之後,今天则是要来为它写整合测试。

大家还记得整合测试的目标是要测什麽吗?我帮大家复习一下:

整合测试的测试目标是要测是两个或是两个以上的类别之间的互动是否符合我们的预期。

实作开始

首先我们先增加一个 Integration testing 的区块,有关於整合测试的程序码接下来都会放在这里面,至於昨天的就放在 Unit testing 的区块:

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

  describe('Unit testing', () => {
    // 昨天写的单元测试...
  });
  
  describe('Integration testing', () => {
    // 今天要写的整合测试
  });
});

跟之前样先打开 .html 来看一下目前的程序码:

<form>
  <fieldset *ngFor="let insured of insuredList; let index = index; trackBy: trackByIndex">
    <legend>被保人</legend>
    <p>
      <label [for]="'name-' + index">姓名:</label>
      <input
        type="text"
        [name]="'name-' + index"
        [id]="'name-' + index"
        required
        maxlength="10"
        minlength="2"
        #nameNgModel="ngModel"
        [ngModel]="insured.name"
        (ngModelChange)="insuredNameChange(nameNgModel.control, insured)"
      />
      <span class="error-message">{{ insured.nameErrorMessage }}</span>
    </p>
    <p>
      性别:
      <input
        type="radio"
        [name]="'gender-' + index"
        [id]="'male-' + index"
        value="male"
        required
        [(ngModel)]="insured.gender"
      >
      <label [for]="'male-' + index">男</label>
      <input
        type="radio"
        [name]="'gender-' + index"
        [id]="'female-' + index"
        value="female"
        required
        [(ngModel)]="insured.gender"
      >
      <label [for]="'female-' + index">女</label>
    </p>
    <p>
      <label [for]="'age-' + index">年龄:</label>
      <select
        [name]="'age-' + index"
        [id]="'age-' + index"
        required
        #ageNgModel="ngModel"
        [ngModel]="insured.age"
        (ngModelChange)="insuredAgeChange(ageNgModel.control, insured)"
      >
        <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">{{ insured.ageErrorMessage }}</span>
    </p>
    <p><button type="button" (click)="deleteInsured(index)">删除</button></p>
  </fieldset>
  <p>
    <button type="button" (click)="addInsured()">新增被保险人</button>
    <button type="submit" [disabled]="isFormInvalid">送出</button>
  </p>
</form>

大家有看出来要测什麽了吗?我来帮大家整理一下要测的项目:

  • 姓名栏位
    • 属性 type 的值要是 text
    • 属性 name 的值要是 name-N
    • 属性 minlength 的值要是 2
    • 属性 maxlength 的值要是 10
    • 要有属性 required
    • 要将被保人的属性 name 的值绑定到此栏位上
    • 此栏位的值如果有变动,要能触发函式 insuredNameChange
  • 性别栏位
    • 属性 type 的值要是 radio
    • 属性 name 的值要是 gender-N
    • 要有属性 required
    • 要将被保人的属性 gender 的值绑定到此栏位上
  • 年龄栏位
    • 属性 name 的值要是 age-N
    • 要有属性 required
    • 要将被保人的属性 age 的值绑定到此栏位上
    • 此栏位的值如果有变动,要能触发函式 insuredAgeChange
  • 错误讯息
    • 要将被保人的的属性 nameErrorMessage 的值绑定到画面上
    • 要将被保人的的属性 ageErrorMessage 的值绑定到画面上
  • 新增被保人按钮
    • 按下按钮要能触发函式 addInsured
  • 删除被保人按钮
    • 按下按钮要能触发函式 deleteInsured
  • 送出按钮
    • 属性 type 的值要是 submit
    • 没有任何被保人时,送出按钮皆呈现不可被点选之状态
    • 任一个被保人的验证有误时,送出按钮皆呈现不可被点选之状态
    • 当所有的被保人资料皆正确时,按下送出按钮要能触发函式 submit

把要测的项目都列出来之後,有没有觉得要测的项目很多阿?哈哈!

再次跟大家说明,虽然上面这些项目有些其实并不真的属於整合测试的范围,但我个人会在这时候一起测,因为这样可以省下一些重复的程序码。

此外,开始之前也别忘记先做以下程序码所展示的前置作业:

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

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

  // 案例写在这边
});

姓名栏位的验证

复习一下姓名栏位的验证项目:

  • 属性 type 的值要是 text
  • 属性 name 的值要是 name-N
  • 属性 minlength 的值要是 2
  • 属性 maxlength 的值要是 10
  • 要有属性 required
  • 要将被保人的属性 name 的值绑定到此栏位上
  • 此栏位的值如果有变动,要能触发函式 insuredNameChange

接下来就把姓名栏位要验证的项目写成测试案例:

describe('the insured fields', () => {
  beforeEach(() => {
    component.insuredList = [{
      name: '',
      gender: '',
      age: '',
      nameErrorMessage: '',
      ageErrorMessage: ''
    }];
    fixture.detectChanges();
  });

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

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

    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 "name" and the value is "name-0"', () => {
      // Arrange
      const attributeName = 'ng-reflect-name';
      const attributeValue = key;
      // Assert
      expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
    });

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

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

    it('should have attribute "required"', () => {
      // Arrange
      const attributeName = 'required';
      // Assert
      expect(nameInputElement.hasAttribute(attributeName)).toBe(true);
    });

    it('should binding the value of the insured\'s property "name"', () => {
      // Arrange
      const name = 'whatever';
      // Act
      component.insuredList[0].name = name;
      fixture.detectChanges();
      // Assert
      expect(nameInputElement.getAttribute('ng-reflect-model')).toBe(name);
    });

    it('should trigger function "insuredNameChange" when the value be changed', () => {
      // Arrange
      spyOn(component, 'insuredNameChange');
      const nameNgModel = component.nameNgModelRefList.get(0)!;
      // Act
      nameInputElement.value = 'whatever';
      nameInputElement.dispatchEvent(new Event('ngModelChange'));
      // Assert
      expect(component.insuredNameChange).toHaveBeenCalledWith(nameNgModel.value, nameNgModel.errors, component.insuredList[0]);
    });
  });
});

测试结果:

testing result

这段程序码中有几个重点:

  1. 为了之後测其他栏位,我多新增了一个 test insured fieldsdescribe 。这是因为要验证这些栏位之前,一定要先让被保人的表单长出来,所我才会多包一层,并把大家都会做的事情拉到这层的 beforeEach 来做。

  2. should have attribute "name" and the value is "name-0" 这个测试案例要记得我们在 Template 绑定时是用 [name] 的方式绑定,所以在验证的时候是抓 ng-reflect-name ,如果单纯抓 name 来验是会报错的噢!

  3. should trigger function "insuredNameChange" when the value be changed 最後这个测试案例比较特别,不知道大家还记不记得上次写这里的时候,我有介绍过关於 Spy 的事情与怎麽用 @ViewChild 抓 Template 中的 nameFormControl

如果不记得的话,赶快回去第七天的文章复习一下!

上次用的 @ViewChild 是抓取单一的元素,但这次是复数的怎办?

答案是 ─ @ViewChildren

有没有一种写 Angular 还可以学英文的感觉?

只要我们像这样在程序码中加上这个 Angular 的装饰器:

export class TemplateDrivenFormsAsyncInsuredComponent {
  @ViewChildren('nameNgModel') nameNgModelRefList!: QueryList<NgModel>;
  // ...
}

Angular 就会在每次渲染完画面之後,帮我们抓取有在 HTML 的属性中加上 #nameNgModel 的所有元素,而抓出来的元素会用 Angular 所包装的类别 ─ QueryList 包起来,以利我们使用。

性别栏位的验证

性别栏位的验证项目如下:

  • 男生
    • 属性 type 的值要是 radio
    • 属性 name 的值要是 male-N
    • 属性 value 的值要是 male
    • 要有属性 required
    • 要将被保人的属性 gender 的值绑定到此栏位上
  • 女生
    • 属性 type 的值要是 radio
    • 属性 name 的值要是 female-N
    • 属性 value 的值要是 female
    • 要有属性 required
    • 要将被保人的属性 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 "name" and the value is "gender-0"', () => {
      // Arrange
      const attributeName = 'ng-reflect-name';
      const attributeValue = 'gender-0';
      // 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);
    });

    it('should have attribute "required"', () => {
      // Arrange
      const attributeName = 'required';
      // Assert
      expect(radioButtonElement.hasAttribute(attributeName)).toBe(true);
    });

    it('should binding the value of the insured\'s property "gender"', () => {
      // Arrange
      const gender = 'male';
      // Act
      component.insuredList[0].gender = gender;
      fixture.detectChanges();
      // Assert
      expect(radioButtonElement.getAttribute('ng-reflect-model')).toBe(gender);
    });
  });
  
  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 "name" and the value is "gender-0"', () => {
      // Arrange
      const attributeName = 'ng-reflect-name';
      const attributeValue = 'gender-0';
      // 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);
    });

    it('should have attribute "required"', () => {
      // Arrange
      const attributeName = 'required';
      // Assert
      expect(radioButtonElement.hasAttribute(attributeName)).toBe(true);
    });

    it('should binding the value of the insured\'s property "gender"', () => {
      // Arrange
      const gender = 'female';
      // Act
      component.insuredList[0].gender = gender;
      fixture.detectChanges();
      // Assert
      expect(radioButtonElement.getAttribute('ng-reflect-model')).toBe(gender);
    });
  });
});

这边的测试虽然简单,但我还是遇到了一个问题:「怎麽验双向绑定里,关於 ngModelChange 的部份」。

我的预期是我点击了某个性别的单选钮之後,它会把值指定给被保人的 gender 栏位。

但我试了好几种验法,也查了老半天资料,就是没办法成功(摊手),如果有朋友成功验出来,请麻烦在下方留言分享一下,感谢!

测试结果:

testing result

年龄栏位的验证

年龄栏位的验证项目如下:

  • 属性 name 的值要是 age-N
  • 要有属性 required
  • 要将被保人的属性 age 的值绑定到此栏位上
  • 此栏位的值如果有变动,要能触发函式 insuredAgeChange

程序码如下:

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

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

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

  it('should have attribute "required"', () => {
    // Arrange
    const attributeName = 'required';
    // Assert
    expect(ageSelectElement.hasAttribute(attributeName)).toBe(true);
  });

  it('should binding the value of the insured\'s property "age"', () => {
    // Arrange
    const age = '18';
    // Act
    component.insuredList[0].age = age;
    fixture.detectChanges();
    // Assert
    expect(ageSelectElement.getAttribute('ng-reflect-model')).toBe(age);
  });

  it('should trigger function "insuredAgeChange" when the value be changed', () => {
    // Arrange
    spyOn(component, 'insuredAgeChange');
    const ageNgModel = component.ageNgModelRefList.get(0)!;
    // Act
    ageSelectElement.value = '18';
    ageSelectElement.dispatchEvent(new Event('ngModelChange'));
    // Assert
    expect(component.insuredAgeChange).toHaveBeenCalledWith(ageNgModel.value, ageNgModel.errors, component.insuredList[0]);
  });
});

年龄栏位的验证跟姓名的验证有 87% 像,复制过来再稍微调整一下即可。

测试结果:

testing result

错误讯息的验证

错误讯息要验证的项目是:

  • 要将被保人的属性 nameErrorMessage 的值绑定到画面上
  • 要将被保人的属性 ageErrorMessage 的值绑定到画面上

测试程序码如下:

describe('Error Messages', () => {
  it('should binding the value of the insured\'s property "nameErrorMessage" in the template', () => {
    // Arrange
    const insured = component.insuredList[0];
    const errorMessage = 'account error';
    const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
    // Act
    insured.nameErrorMessage = errorMessage;
    fixture.detectChanges();
    // Assert
    expect(targetElement?.textContent).toBe(errorMessage);
  });

  it('should binding the value of the insured\'s property "ageErrorMessage" in the template', () => {
    // Arrange
    const insured = component.insuredList[0];
    const errorMessage = 'password error';
    const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
    // Act
    insured.ageErrorMessage = errorMessage;
    fixture.detectChanges();
    // Assert
    expect(targetElement?.textContent).toBe(errorMessage);
  });
});

错误讯息的验证也非常简单,大家应该都能轻松验证!

测试结果:

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 "insuredList" is empty array', () => {
    // Assert
    expect(buttonElement.hasAttribute('disabled')).toBe(true);
  });

  it('should be disabled when there are any verifying errors that insured\'s data', () => {
    // Arrange
    component.insuredList = [{
      name: 'A',
      gender: '',
      age: '',
      nameErrorMessage: '',
      ageErrorMessage: ''
    }];
    compiledComponent.querySelector('button[type="submit"]')
    // Act
    fixture.detectChanges();
    // Assert
    expect(buttonElement.hasAttribute('disabled')).toBe(true);
  })

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

测试结果:

testing result

咦?怎麽会有 Error 咧?原来这个问题跟上次我们写登入表单的整合测试所遇到的情况一样。

所以我们目前先在这个案例的 it 的前面加上一个 x ,代表我们要 ignore 这个案例的意思,像这样:

xit('should be disabled when there are any verifying errors that insured\'s data', () => {
  // 省略...
})

测试结果:

testing result

至此,我们就完成了整合测试的部份罗!

今天所有的测试结果:

testing result

本日小结

其实今天用所有用到的测试手法与概念都在之前的的文章就已经分享过了,今天主要是让大家练习,提昇撰写测试的熟悉度。

明天我们要为用 Reactive Forms 所撰写的被保人表单来撰写单元测试,我觉得大家可以在看我的文章之前先自己写写看,之後再参考我的文章,一定会有更多的收获!

今天的实作程序码会放在 Github - Branch: day13 供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

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


<<:  JS语法学习Day3

>>:  Day 23:专案05 - KKBOX风云榜02 | AJAX

Day 17:专案03 - PTT 八卦版爬虫02 | session、post

昨天教到使用cookie让服务器记得我们曾经做过哪些事,但缺点就是每次Request都要加上cook...

[ Day 8 ] - 回圈

回圈 想要重复做一件事,会依据条件而有不同的执行次数 for 回圈 写法如下 for (let i ...

JS 14 - 控制物件

大家好! 文章到今天也快要写一半了,谢谢各位的阅读。 我们进入今天的主题吧! 控制物件 先建立一个物...

[Aras笔记] 从Excel快速贴上受影响物件并建立变更单

###本文章内容皆由我本人开发撰写与分享 在变更单建立时,经常会花时间编辑受影响物件 经常是为了从E...

修改word 作者属性

删除作者 作法1 : 直接修改 删除作者,删掉,从这边删掉,按确定就可以了 作法2 : 移除档案属性...