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

Day6

今天我们要来为我们用 Template Driven Forms 所撰写的登入系统写单元测试,如果还没有相关程序码的朋友,赶快前往阅读第二天的文章: Template Driven Forms 实作 - 以登入为例

此外,由於使用 Stackblitz 来写测试比较麻烦一点,所以我建议大家都使用 ng new 建立新的专案,因为 Angular 都帮开发者处理好了,使用 Angular 的开发者就是这麽幸福。

所以在开始之前,如果当初是用 Stackblitz 练习的话,要先将程序码复制到专案里,详细步骤我就不再赘述罗!

小提醒,将程序码复制到专案里之後,记得先使用 ng serve 的指令将其启动起来看看是不是可以正常运作噢!

此外,如果是用 Angular v12 以上的同学,预设的 typescript 会是 strict mode 的状态,也就是说型别检查会比较严格一点,所以如果看到很多红色毛毛虫不用太担心。

如果有任何问题,我预言会有 80% 的朋友是忘记在 module 里 import FormsModule ,哈哈!

实作开始

上述前置作业做完之後,我们就可以先打开 app.component.spec.ts,你应该会看到 Angular CLI 帮我们产生的程序码:

Testing Sample

我们先把除了 should create the app 之外的测试案例删掉,删完应该要长这样:

import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';

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

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });
});

至此我稍微说明一下,在 beforeEach 里我们可以看到有段满特别的程序码:

TestBed.configureTestingModule({
  declarations: [
    AppComponent
  ],
}).compileComponents();

这段程序码是在配置我们测试集合的环境,就像我们在写 Angular 的时候一样, Component 会需要一个模组,而 TestBed 是 Angular 帮我们预先写好给测试用的一个类型,透过 configureTestingModule 来模拟真实使用情境,最後用 compileComponents 将其实际执行。

这段配置在 Angular 基本上会是必备的,并且我们还会需要依据 Component 其实际情况来调整该配置,例如我们现在就因为我们的表单需要的关系,要在这里引入 FormsModule

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

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

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

  it('should create the app', () => {
    // ...
  });
});

接着使用 ng test 的指令将测试程序启动起来,应该会可以通过我们的第一个测试案例 should create the app

pass first case

通过这个测试基本上意谓着我们要测试的 Component 的配置没有什麽太大的问题,因为他要可以被正常建立实体才能通过,至此我们就可以开始来撰写单元测试了。

欲测试的单元选择

在第一天时我有提到,单元测试主要是要用来验证单个类别函式其实际执行结果是否符合我们预期的执行结果。

所以我们先打开 app.component.ts 来看一下目前的程序码:

export class AppComponent {
  
  // 绑定在帐号栏位上
  account = '';

  // 绑定在密码栏位上
  password = '';
  
  // 帐号栏位的错误讯息
  accountErrorMessage = '';
  
  // 密码栏位的错误讯息
  passwordErrorMessage = '';

  /**
   * 绑定在帐号栏位上,当使用者改变帐号时会触发此函式
   * 
   * @param {string} account 
   * @param {ValidationErrors} errors 
   */
  accountValueChange(account: string, errors: ValidationErrors | null): void {
    this.account = account;
    this.validationCheck(errors, 'account');
  }

  /**
   * 绑定在密码栏位上,当使用者改变密码时会触发此函式
   * 
   * @param {string} password 
   * @param {ValidationErrors} errors 
   */
  passwordValueChange(password: string, errors: ValidationErrors | null): void {
    this.password = password;
    this.validationCheck(errors, 'password');
  }

  // 绑定在表单上,当使用者按下登入按钮时会触发此函式
  login(): void {
    // do login...
  }

  /**
   * 透过栏位里的 ValidationErrors 来设定该栏位的错误讯息
   * 
   * @param {ValidationErrors | null} errors 欲验证的栏位的错误 (by Angular)
   * @param {'account' | 'password'} fieldName 栏位名称
   */
  private validationCheck(
    errors: ValidationErrors | null,
    fieldName: 'account' | 'password'
  ): void {
    let errorMessage: string;
    if (!errors) {
      errorMessage = '';
    } else if (errors.required) {
      errorMessage = '此栏位必填';
    } else if (errors.pattern) {
      errorMessage = '格式有误,请重新输入';
    } else if (errors.minlength) {
      errorMessage = '密码长度最短不得低於8码';
    }
    this.setErrorMessage(fieldName, errorMessage);
  }

  /**
   * 设定指定栏位的错误讯息
   * 
   * @param {'account' | 'password'} fieldName 欲设定错误讯息的栏位名称
   * @param {string} errorMessage 欲设定的错误讯息
   */
  private setErrorMessage(
    fieldName: 'account' | 'password',
    errorMessage: string
  ): void {
    if (fieldName === 'account') {
      this.accountErrorMessage = errorMessage;
    } else {
      this.passwordErrorMessage = errorMessage;
    }
  }
}

以目前的程序码来看,这个 Component 的函式有以下这些:

  1. accountValueChange
  2. passwordValueChange
  3. login
  4. validationCheck
  5. setErrorMessage

这五个函式里,其中 login 没写什麽先不测, validationChecksetErrorMessageprivate 的也不用测,所以我们主要要测试 accountValueChangepasswordValueChange 这两个函式。

测试单元 - accountValueChange

既然如此,我们先加一个 describe ,表明在这里面的测试案例都是在测 accountValueChange 这个函式:

describe('AppComponent', () => {
  // ...

  describe('accountValueChange', () => {
    // 这里面的测试案例都是要测这个函式
  });
});

然後我们来统整一下这个 accountValueChange 的函式里会遇到的情况:

  1. 会将传入的 account 的值赋值给 AppComponent 的属性 account
  2. 如果传入的 errorsrequired 栏位,则会将错误讯息 此栏位必填 赋值给 AppComponent 的属性 accountErrorMessage
  3. 如果传入的 errorspattern 栏位,则会将错误讯息 格式有误,请重新输入 赋值给 AppComponent 的属性 accountErrorMessage
  4. 如果传入的 errorsnull ,则会将 AppComponent 的属性 accountErrorMessage 设为空字串。

统整完之後,就可以将上述情况写成测试案例:

describe('accountValueChange', () => {
  it('should set value into property "account"', () => {
    // Arrange
    const account = '[email protected]';
    const errors = null;
    // Act
    component.accountValueChange(account, errors);
    // Assert
    expect(component.account).toBe(account);
  });

  it('should set the required error message into property "accountErrorMessage" when the value is empty string', () => {
    // Arrange
    const account = '';
    const errors = { required: true };
    const accountErrorMessage = '此栏位必填';
    // Act
    component.accountValueChange(account, errors);
    // Assert
    expect(component.accountErrorMessage).toBe(accountErrorMessage);
  });

  it('should set the pattern error message into property "accountErrorMessage" when the value is not the correct pattern', () => {
    // Arrange
    const account = 'abc123';
    const errors = {
      pattern: {
        actualValue: 'abc123',
        requiredPattern: '^\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b$'
      }
    };
    const accountErrorMessage = '格式有误,请重新输入';
    // Act
    component.accountValueChange(account, errors);
    // Assert
    expect(component.accountErrorMessage).toBe(accountErrorMessage);
  });

  it('should set empty string into property "accountErrorMessage" when the value is the correct pattern', () => {
    // Arrange
    const account = '[email protected]';
    const errors = null;
    const accountErrorMessage = '';
    // Act
    component.accountValueChange(account, errors);
    // Assert
    expect(component.accountErrorMessage).toBe(accountErrorMessage);
  });
});

测试结果:

testing result

测试单元 - passwordValueChange

接下来,我们继续来撰写测试案例来测试 passwordValueChange 函式,一样先加一个 describe ,表明在这里面的测试案例都是在测 passwordValueChange 函式:

describe('AppComponent', () => {
  // ...

  describe('passwordValueChange', () => {
    // 这里面的测试案例都是要测这个函式
  });
});

然後我们来统整一下这个 passwordValueChange 的函式里会遇到的情况:

  1. 会将传入的 password 的值赋值给 AppComponent 的属性 password
  2. 如果传入的 errorsrequired 栏位,则会将错误讯息 此栏位必填 赋值给 AppComponent 的属性 passwordErrorMessage
  3. 如果传入的 errorsminlength 栏位,则会将错误讯息 密码长度最短不得低於8码 赋值给 AppComponent 的属性 passwordErrorMessage
  4. 如果传入的 errorsnull ,则会将 AppComponent 的属性 passwordErrorMessage 设为空字串。

统整完之後其实可以发现,这跟刚刚我们测 accountValueChange 的时候很像,所以我们只要复制一下 accountValueChange 的测试案例再稍微改一下就可以用了:

describe('passwordValueChange', () => {

  it('should set value into property "password"', () => {
    // Arrange
    const password = 'abc123';
    const errors = null;
    // Act
    component.passwordValueChange(password, errors);
    // Assert
    expect(component.password).toBe(password);
  });

  it('should set the required error message into property "passwordErrorMessage" when the value is empty string', () => {
    // Arrange
    const password = '';
    const errors = { required: true };
    const passwordErrorMessage = '此栏位必填';
    // Act
    component.passwordValueChange(password, errors);
    // Assert
    expect(component.passwordErrorMessage).toBe(passwordErrorMessage);
  });

  it('should set the pattern error message into property "passwordErrorMessage" when the value is not the correct pattern', () => {
    // Arrange
    const password = 'abc123';
    const errors = {
      minlength: {
        actualLength: 7,
        requiredLength: 8
      }
    };
    const passwordErrorMessage = '密码长度最短不得低於8码';
    // Act
    component.passwordValueChange(password, errors);
    // Assert
    expect(component.passwordErrorMessage).toBe(passwordErrorMessage);
  });

  it('should set empty string into property "passwordErrorMessage" when the value is the correct pattern', () => {
    // Arrange
    const password = 'abcd1234';
    const errors = null;
    const passwordErrorMessage = '';
    // Act
    component.passwordValueChange(password, errors);
    // Assert
    expect(component.passwordErrorMessage).toBe(passwordErrorMessage);
  });
});

测试结果:

testing result

至此,我们就完成了单元测试的部份罗!是不是感觉其实很简单,并没有想像中的难呢?!俗话说:「万事起头难」,只要我们已经跨出第一步,後面就会越来越简单噢!

今天的文章就到这边,大家稍微沉淀、吸收一下,明天我们接着撰写整合测试的部份。

本日小结

再次提醒大家,单元测试要验证的是某一函式不同情况下的执行结果是否符合预期,并且记得要尽量做到我在如何写出优秀的测试?文中所提到的部份。

今天的程序码比较多,且应该会有很多朋友初次接触到测试所以可能脑筋会比较转不过来,这时可以先回头看看我第四天与第五天的文章,复习一下核心概念与测试语法,相信一定会有所帮助。

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

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


<<:  OpenStack Cinder 介绍

>>:  [前端暴龙机,Vue2.x 进化 Vue3 ] Day12.事件处理

Leetcode: 1971. Find if Path Exists in Graph

思路 用dps从start点走一遍,然後检查end点有没有finish。 程序码 class Sol...

铁人赛 Day1 -- HTML基本架构

哎呀,自学了两个月後刚好碰到2021的铁人赛开打,顺便来分享一下我的学习过程好了,有错的在劳烦各位大...

Day24 DB-NodeJS中的mongoDB

昨天讲了关联式资料库的MySQL,今天要接着介绍NoSQL中受欢迎的mongoDB,以及在NPM里m...

【前端效能优化】WebP - 较小容量的图片格式选择

常见的图片格式有 GIF:常用来做动态图片 JPEG:适合 Banner、风景等大图片 PNG:透明...

Day 3 | 游戏故事与世界观

游戏简介 我们制作的「山海异闻录」是一款AR手机游戏,一共有五个关卡,完成每个关卡即可开启新的故事剧...