Angular 深入浅出三十天:表单与测试 Day07 - 整合测试实作 - 登入系统 by Template Driven Forms

Day7

昨天帮我们用 Template Driven Forms 所撰写的登入系统写完单元测试之後,今天则是要来为它写整合测试。

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

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

再更直接一点地说,整合测试就是在测互动关系,其他的事情我们都不在乎,因为其他的事情基本上都会在单元测试的部份里测。

这时候可能会有人觉得奇怪,我们现在就只有一个 Component ,并没有符合「两个或是两个以上的类别」,这样是要怎麽测试?

没错,虽然我们现在并没有「两个或是两个以上的类别」,但是前端比较不一样的地方是前端会有画面,使用者实际上是看着画面来跟我们的程序互动的。

用我们用做的登入系统来说,虽然很简单、很阳春,但如果没有画面、没有那些输入栏位,使用者也没办法使用。

所以今天写整合测试的目的就是要来来验证我们所做的登入系统的画面,有没有如我们所预期地和我们的程序码互动

实作开始

首先我们先增加一个 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]
    }).compileComponents();

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

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

一般我们不会特别将单元测试跟整合测试的程序码分开档案来写,只会用测试集合将其区隔。

由於今天的整合测试跟画面会比较有相关,所以我们打开 app.component.html 来看一下目前的程序码:

<form #form="ngForm" (ngSubmit)="login()">
  <p>
    <label for="account">帐号:</label>
    <input
      type="email"
      name="account"
      id="account"
      required
      pattern="\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b"
      #accountNgModel="ngModel"
      [ngModel]="account"
      (ngModelChange)="
        accountValueChange(accountNgModel.value, accountNgModel.errors)
      "
    />
    <span class="error-message">{{ accountErrorMessage }}</span>
  </p>
  <p>
    <label for="password">密码:</label>
    <input
      type="password"
      name="password"
      id="password"
      required
      #passwordNgModel="ngModel"
      [minlength]="8"
      [maxlength]="16"
      [ngModel]="password"
      (ngModelChange)="
        passwordValueChange(passwordNgModel.value, passwordNgModel.errors)
      "
    />
    <span class="error-message">{{ passwordErrorMessage }}</span>
  </p>
  <p>
    <button type="submit" [disabled]="form.invalid">登入</button>
  </p>
</form>

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

  • 帐号栏位
    • 属性 type 的值要是 email
    • 属性 name 的值要是 account
    • 属性 pattern 的值要是 \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b
    • 要有属性 required
    • 要将 Component 的属性 account 的值绑定到此栏位上
    • 此栏位的值如果有变动,要能触发函式 accountValueChange
  • 密码栏位
    • 属性 type 的值要是 password
    • 属性 name 的值要是 password
    • 属性 minlength 的值要是 8
    • 属性 maxlength 的值要是 16
    • 要有属性 required
    • 要将 Component 的属性 password 的值绑定到此栏位上
    • 此栏位的值如果有变动,要能触发函式 passwordValueChange
  • 错误讯息
    • 要将 Component 的属性 accountErrorMessage 的值绑定到画面上
    • 要将 Component 的属性 passwordErrorMessage 的值绑定到画面上
  • 登入按钮
    • 属性 type 的值要是 submit
    • 当表单是无效的状态时,要有属性 disabled
    • 当表单是有效的状态时,没有属性 disabled
    • 当表单是有效状态时,按下登入按钮要能触发函式 login

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

不过上面这些我个人列的项目有些其实并不属於整合测试的范围,但我个人会在这时候一起测,因为这样可以省下一些重复的程序码,而我自己也习惯在写测试的时候分成 Component/Template 两块,而不是单元测试/整合测试,这样的命名会比较符合实际上在做的事情。

那要怎麽测画面呢?

beforeEach 里有个 fixture ,我们在测单元测试的时候,是从这里取得 Component 的实体。而现在要测画面,一样是从 fixture 里取得 Angular 渲染出来的画面:

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

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

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

  // 将 fixture 抽出来
  let fixture: ComponentFixture<AppComponent>;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [AppComponent],
      imports: [FormsModule]
    }).compileComponents();

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

  describe('Unit testing', () => {
    // 昨天写的单元测试...
  });
  
  
  describe('Integration testing', () => {
    let compiledComponent: HTMLElement;

    beforeEach(() => {
      // 此行的意思是让 Angular 帮我们将画面的元素都渲染出来
      fixture.detectChanges();

      // 取得渲染完之後的元素
      compiledComponent = fixture.nativeElement;
    });

  });
});

拿到渲染完的元素之後,接下来要做的事情应该是每个前端工程师都应该要很熟悉的 DOM 操作。

不知道什麽是 DOM 的朋友可能是走错棚了噢!

没错,在撰写测试以验证画面上的元素时,就是用大家都滚瓜烂熟的 DOM 操作来撰写,以帐号栏位为例:

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

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

  describe('Account input field', () => {
    let accountInputElement: HTMLInputElement;
  
    beforeEach(() => {
      accountInputElement = compiledComponent.querySelector('#account');
    });
  });
});

如果你的专案有开启严格模式的话( Angular v12 之後预设开启),可能会在 accountInputElement 底下看到红色毛毛虫:

strict mode error

这是因为 TypeScript 在跟你说,这里有可能会找不到元素,所以型别有可能会是 null

如果我们很有自信它一定找的到、绝对不会是 null 的话,可以在该行结尾加 ! ,像这样: accountInputElement = compiledComponent.querySelector('#account')! ,就不会有红色毛毛虫罗。

帐号栏位的验证

复习一下帐号栏位的验证项目:

  • 属性 type 的值要是 email
  • 属性 name 的值要是 account
  • 属性 pattern 的值要是 \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b
  • 要有属性 required
  • 要将 Component 的属性 account 的值绑定到此栏位上
  • 此栏位的值如果有变动,要能触发函式 accountValueChange

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

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 have attribute "name" and the value is "account"', () => {
    // Arrange
    const attributeName = 'name';
    const attributeValue = 'account';
    // Assert
    expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
  });

  it('should have attribute "pattern" and the value is "\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b"', () => {
    // Arrange
    const attributeName = 'pattern';
    const attributeValue = '\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b';
    // Assert
    expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
  });

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

  it('should binding the value of property "account"', () => {
    // Arrange
    const account = 'whatever';
    // Act
    component.account = account;
    fixture.detectChanges();
    // Assert
    expect(accountInputElement.getAttribute('ng-reflect-model')).toBe(account);
  });

  it('should trigger function "accountValueChange" when the value be changed', () => {
    // Arrange
    spyOn(component, 'accountValueChange');
    // Act
    accountInputElement.value = 'whatever';
    accountInputElement.dispatchEvent(new Event('ngModelChange'));
    // Assert
    expect(component.accountValueChange).toHaveBeenCalled();
  });
});

测试结果:

testing result

在这些测试案例里,比较特别需要说明的是: should trigger function "accountValueChange" when the value be changed 这个测试案例,怎麽说呢?

大家应该都有发现在这个测试案例里,有使用一个叫做 spyOn 的函式,这个函式的第一个参数是一个物件,第二个参数是这个物件里的函式的名字。

这个函式的用意是,它会把该物件里我们所指定的函式替换成一个叫做 Spy 的物件,让後续如果有人执行该函式时,实际执行的会是我们替换掉的 Spy 物件,而不是原本我们写的那个函式,这样才能在後续去验证该函式是否已经被呼叫过,甚至还可以知道被呼叫的次数、被呼叫时所传入的参数等等。

这个方式是大家在写测试时所惯用的手法。在这个测试案例里,我们只在意该函式是不是有被触发,不在意该函式的实际执行结果,因为该函式的实际执行结果已经在写单元测试的时候验证过了,而整合测试的部份所在意的是互动行为

关於测试的替身,可以参考此篇网路文章:Unit Test 中的替身:搞不清楚的Dummy 、Stub、Spy、Mock、Fake

不过这个测试案例其实有个美中不足的地方,因为严格来说我们必须要验证在该函式被呼叫的时候有传入 accountNgModel.valueaccountNgModel.errors ,但因为这个物件是透过 Angular 的范本语法去产生出来的,如果要抓到它需要在 Component 里新增一个属性,并使用 Angular 的装饰器 @ViewChild() 来帮我们把这个物件抓出来:

export class AppComponent {
  @ViewChild('accountNgModel') accountNgModelRef!: NgModel;
  // ...
}

如此就能改用 toHaveBeenCalledWith 来验证:

it('should trigger function "accountValueChange" when the value be changed', () => {
  // Arrange
  spyOn(component, 'accountValueChange');
  const accountNgModel = component.accountNgModelRef;
  // Act
  accountInputElement.value = 'whatever';
  accountInputElement.dispatchEvent(new Event('ngModelChange'));
  // Assert
  expect(component.accountValueChange).toHaveBeenCalledWith(accountNgModel.value, accountNgModel.errors);
});

除了这个测试案例大家可能会不习惯之外,其他的测试看起来满简单的对吧?!

密码栏位的验证

帐号栏位的测试写完之後,再来就轮到密码栏位的部分罗!

复习一下密码栏位的验证项目:

  • 属性 type 的值要是 password
  • 属性 name 的值要是 password
  • 属性 minlength 的值要是 8
  • 属性 maxlength 的值要是 16
  • 要有属性 required
  • 要将 Component 的属性 password 的值绑定到此栏位上
  • 此栏位的值如果有变动,要能触发函式 passwordValueChange

测试程序码如下:

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 have attribute "name" and the value is "password"', () => {
    // Arrange
    const attributeName = 'name';
    const attributeValue = 'password';
    // Assert
    expect(passwordInputElement.getAttribute(attributeName)).toBe(attributeValue);
  });

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

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

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

  it('should binding the value of property "password"', () => {
    // Arrange
    const password = 'whatever';
    // Act
    component.password = password;
    fixture.detectChanges();
    // Assert
    expect(passwordInputElement.getAttribute('ng-reflect-model')).toBe(password);
  });

  it('should trigger function "passwordValueChange" when the value be changed', () => {
    // Arrange
    spyOn(component, 'passwordValueChange');
    const passwordNgModel = component.passwordNgModelRef;
    // Act
    passwordInputElement.value = 'whatever';
    passwordInputElement.dispatchEvent(new Event('ngModelChange'));
    // Assert
    expect(component.passwordValueChange).toHaveBeenCalledWith(passwordNgModel.value, passwordNgModel.errors);
  });
});

密码栏位的部份基本上跟帐号栏位差不多,只有一两个属性不一样而已。

测试结果:

testing result

错误讯息的验证

错误讯息的验证也非常简单,真要说个比较难的地方,大概就是对於 CSS Selector 的熟悉程度吧!

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

  • 要将 Component 的属性 accountErrorMessage 的值绑定到画面上
  • 要将 Component 的属性 passwordErrorMessage 的值绑定到画面上

测试程序码如下:

describe('Error Message', () => {
  it('should binding the value of property "accountErrorMessage" in the template', () => {
    // Arrange
    const errorMessage = 'account error';
    const targetElement = compiledComponent.querySelector('#account + .error-message');
    // Act
    component.accountErrorMessage = errorMessage;
    fixture.detectChanges();
    // Assert
    expect(targetElement?.textContent).toBe(errorMessage);
  });

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

如果你对於 CSS Selector 真的不熟,就在要验的元素上增加你可以找到的 ID 、类别或者是属性吧!

测试结果:

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.account = '[email protected]';
      component.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

咦?怎麽会有 Error 咧?我自己在第一次遇到这个状况也是有点傻眼,於是我深入调查了之後发现:

testing result

原来是因为 Karma 渲染出来的元素跟 Angular 渲染出来的元素状态不一样,Karma 渲染出来的 form 元素跟没有正确吃到底下的表单栏位:

testing result

关於这个问题,我已经发 issue 询问官方了,如果後续有任何消息,我会再更新此篇文章让大家知道。

issue 连结:https://github.com/karma-runner/karma/issues/3700

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

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

测试结果:

testing result

至此,我们就完成了整合测试的部份罗!虽然刚好遇到奇怪的问题,但学习如何排除异常也是非常重要的一部分噢!

今天的文章就到这边,明天我们要为用 Reactive Forms 所撰写的登入表单来撰写单元测试,不过我其实昨天其实就教过大家怎麽写单元测试,在看我的文章之前,建议大家先自己写写看再参考我的文章,相信一定会有更多的收获!

本日小结

再次提醒大家,在写整合测试时,需要测试的是两个类别实体之间在各种情况下的互动行为是否符合我们的预期,跟单元测试要测试的重点是很不一样的。

除此之外,就算我们是在写整合测试不是单元测试,但依然要尽量做到我在如何写出优秀的测试?文中所提到的部份噢!

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

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


<<:  Day14 Example of Backfill

>>:  D14 - 转移资料到TiDB工具介绍(一)

[DAY8] 与 ActiveRecord 分手

先来看看目前我们专案的资料夹结构: 前面有提到,ActiveRecord 所建立的 model 与 ...

[Day5]-串列的相关用法

字串 函数应用方法 len() – 字串长度 min() – 最小值 max() – 最大值 使用...

Day. 27 Binary Tree Level Order Traversal

Leetcode #102. Binary Tree Level Order Traversal 简...

Top 5 Reasons why you ought to learn Artificial Intelligence

AI is characterized as : AI or Artificial Intellig...

Vue [笔记] Dom元素无生成完毕、API来不及抓取之处理、传值方式

1. Dom元素无生成完毕,使用this.$nextTick 情境:Dom元素无生成 导致 refs...