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

Day8

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

实作开始

前置作业基本上都跟第六天的文章:单元测试实作 - 登入系统 by Template Driven Forms 相同,今天就不会再赘述,大家如果忘记怎麽做可以先回去复习一下。

目前的程序码:

export class AppComponent {
  formGroup: FormGroup | undefined;

  get accountControl(): FormControl {
    return this.formGroup!.get('account') as FormControl;
  }

  get passwordControl(): FormControl {
    return this.formGroup!.get('password') as FormControl;
  }

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({
      account: [
        '',
        [
          Validators.required,
          Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
        ]
      ],
      password: [
        '',
        [Validators.required, Validators.minLength(8), Validators.maxLength(16)]
      ]
    });
  }

  getErrorMessage(formControl: FormControl): string {
    let errorMessage = '';
    if (!formControl.errors || formControl.pristine) {
      errorMessage = '';
    } else if (formControl.errors.required) {
      errorMessage = '此栏位必填';
    } else if (formControl.errors.pattern) {
      errorMessage = '格式有误,请重新输入';
    } else if (formControl.errors.minlength) {
      errorMessage = '密码长度最短不得低於8码';
    } else if (formControl.errors.maxlength) {
      errorMessage = '密码长度最长不得超过16码';
    }
    return errorMessage;
  }

  login(): void {
    // do login...
  }
}

以目前的程序码来看,基本上我们只要验 getErrorMessage 这个函式,不过我们其实也能验 ngOnInit 这个 Angular Component Lifecycle Hook 的执行结果,毕竟它也是个函式,我们一样可以写测试去验证这个函式的执行结果是否符合我们的预期。

关於 Angular Component Lifecycle Hook ,如果想知道更多可以阅读官方文件: Component Lifecycle hooks

测试单元 - getErrorMessage

我们一样先加一个 describe ,表明在这里面的测试案例都是在测 getErrorMessage 这个函式:

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

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

接着统整一下这个 getErrorMessage 的函式里会遇到的情况:

  1. 如果传入的 formControl 里没有任何 error ,则会取得空字串。
  2. 如果传入的 formControl 的属性 pristine 的值为 true ,则会取得空字串。
  3. 如果传入的 formControl 里有必填的错误: required ,则会取得错误讯息 此栏位必填
  4. 如果传入的 formControl 里有格式的错误: pattern ,则会取得错误讯息 格式有误,请重新输入
  5. 如果传入的 formControl 里有最小长度的错误: minlength ,则会取得错误讯息 密码长度最短不得低於8码
  6. 如果传入的 formControl 里有最大长度的错误: maxlength ,则会取得错误讯息 密码长度最长不得超过16码

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

describe('getErrorMessage', () => {
  it('should get empty string when the value is correct', () => {
    // Arrange
    const formControl = new FormControl('');
    const expectedMessage = '';
    // Act
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get empty string when the value is empty string but the form control is pristine', () => {
    // Arrange
    const formControl = new FormControl('', [Validators.required]);
    const expectedMessage = '';
    // Act
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "此栏位必填" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('', [Validators.required]);
    const expectedMessage = '此栏位必填';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "格式有误,请重新输入" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('whatever', [Validators.pattern('/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi')]);
    const expectedMessage = '格式有误,请重新输入';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "密码长度最短不得低於8码" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('abc', [Validators.minLength(8)]);
    const expectedMessage = '密码长度最短不得低於8码';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });

  it('should get "密码长度最长不得超过16码" when the value is empty string but the form control', () => {
    // Arrange
    const formControl = new FormControl('12345678901234567', [Validators.maxLength(16)]);
    const expectedMessage = '密码长度最长不得超过16码';
    // Act
    formControl.markAsDirty();
    const message = component.getErrorMessage(formControl);
    // Assert
    expect(message).toBe(expectedMessage);
  });
});

从上面的程序码中可以看出,我这次写单元测试的策略是:让每个案例自己配置足以验证该案例的 formControl 与其必须的 Validators 即可。

也就是说,当我需要验证 此栏位必填 的错误讯息时,我只需要配置 Validators.requiredformControl ;当我需要验证 密码长度最短不得低於8码 的错误讯息时,我只需要配置 Validators.minlength(8)formControl ,依此类推。

会这样写是因为我们只需要专注在什麽样子的 errors 会得到什麽样子的错误讯息上面,当然大家也可以每次都帮 formControl 配置最完整的 Validators ,这两个方法我觉得都可以。

此外,由於我们这次有判断 formControl 的状态: pristine ,因此在写测试的时候要特别留意,记得要先 markAsDirty 之後才能测试噢!

上一次写单元测试的文章: 单元测试实作 - 登入系统 by Template Driven Forms

测试结果:

testing result

测试单元 - ngOnInit

再来是 ngOnInit 的部份, ngOnInit 要验证的项目跟 formGroup 满相关,所以我打算用 formGroup 当测试集合的名称,具体要验证的项目有:

  1. ngOnInit 执行之前, formGroupundefined 的状况。
  2. ngOnInit 执行之後,
    1. formGroup 是类型为 FormGroup 的实体。
    2. formGroup 里要有两个 FormControl
      1. accountFormControl
        • 要有必填的验证
        • 要有 Email 格式的验证
      2. passwordFormControl
        • 要有必填的验证
        • 要有字串最小长度为 8 的验证
        • 要有字串最大长度为 16 的验证

程序码如下:

describe('formGroup', () => {
  it('should be undefined before init', () => {
    // Assert
    expect(component.formGroup).toBeFalsy();
  });

  describe('after ngInit', () => {

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

    it('should be instance of FormGroup', () => {
      // Assert
      expect(component.formGroup).toBeInstanceOf(FormGroup);
    });

    it('should have 2 form controls', () => {
      // Arrange
      const formControls = component.formGroup!.controls;
      const controlLength = Object.keys(formControls).length;
      // Assert
      expect(controlLength).toBe(2);
    });

    describe('accountFormControl', () => {

      it('should have the required validator', () => {
        // Arrange
        const error = component.accountControl.errors!;
        // Assert
        expect(error.required).toBe(true);
      });

      it('should have the email pattern validator', () => {
        // Arrange
        component.accountControl.setValue('abc');
        const error = component.accountControl.errors!;
        const expectedPattern = '/^\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b$/gi';
        // Assert
        expect(error.pattern.requiredPattern).toBe(expectedPattern);
      });

    });

    describe('passwordFormControl', () => {

      it('should have the required validator', () => {
        // Arrange
        const error = component.accountControl.errors!;
        // Assert
        expect(error.required).toBe(true);
      });

      it('should have the min-length validator', () => {
        // Arrange
        component.passwordControl.setValue('abc');
        const error = component.passwordControl.errors!;
        // Assert
        expect(error.minlength.requiredLength).toBe(8);
      });

      it('should have the max-length validator', () => {
        // Arrange
        component.passwordControl.setValue('12345678901234567');
        const error = component.passwordControl.errors!;
        // Assert
        expect(error.maxlength.requiredLength).toBe(16);
      });
    });
  });
});

此处比较特别的地方是,我在 after ngInitbeforeEach 里是用 fixture.detectChanges() 来触发 ngOnInit() ,而不是使用 component.ngOnInit() 的方式来触发,这是因为我认为我们在写的是 Angular ,而这个 Lifecycle Hook 又是 Angular 的东西,所以使用 Angular 的机制来触发会比直接使用该函式触发来的好。

当然也是可以直接使用 component.ngOnInit() 来触发,在测试的验证结果上其实不会有什麽不同,所以用哪个方式其实都可以。

测试结果:

testing result

本日小结

已经写了两次的测试,相信大家对於测试的熟悉度已经有显着地提昇,而今天的重点主要会是在使用 FormControl markAsDirty改变栏位的状态,以及了解 fixture.detectChangesngOnInit 的关系,未来在写测试的时候,这两点也是非常需要多加留意的。

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

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


<<:  [Day 15] Drone - Runner in k8s 安装设定

>>:  Day11 do-while

[Day15]-类别2

类别继承 定义:可以继承父类别所有的公有方法与属性,在子类别就不用重新设计 基於保护原则,外部是不...

记忆卡随身碟硬碟档案丢失的解决办法

这是一篇有用的工具文。 日常生活中,我们不可避免会碰上误删,误格式化硬碟/外接硬碟/记忆卡亦或是随身...

30天打造品牌特色电商网站 Day.4 网站设计及Figma介绍

开始打程序前,网页设计是不可或缺的环节! 网站介面设计,称为UIUX设计 UI指使用者介面(User...

Day22 [实作] 一对一视讯通话(2): Signaling server

今天我们要实作 Signaling server 的部分: 建立文件 # 进入要放专案的路径 ❯ c...

Day18-13. Roman to Integer

今日题目:13. Roman to Integer(Easy) Roman numerals are...