Angular 组件单元测试

202 阅读5分钟

本文介绍如何对 Angular component 进行单元测试,其中有一些非常坑的点我都踩过了,希望能够帮助到你!

1. 将代码整理成容易测试的形式

在正式开始测试某个 Angular component 之前,有一些基础准备需要被澄清。在开发功能的时候,我们通常会遵循一些代码分割的准则来使代码更具可读性。但是很多时候,我们并不会去考虑之后单元测试相关的问题。

有的时候,尽管代码分割看起来做的已经很好了,但是在测试的角度来看依然是不够的。为此,在本节中我们举两个例子来说明如何将代码整理成易于测试的样子。

1.1 管道类向外提供 Module

先看代码:

import { NgModule, Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'safeValue'
})
export class SafeValuePipe implements PipeTransform {

  transform<T>(value: T, placeholder: string = '-'): T | string {
    if (typeof value !== 'string') return value;
    if (value === '') { // 检查是否为假值
      return placeholder;
    }
    return `${value}`; // 非假值时转换为字符串
  }
}

上面的代码定义了一个名为 safeValue 的管道,当输入为空字符串的时候,使用占位符代替之。

这段代码在开发者看来可能要被集成到 sharedModule 中去,然后注入到 App.moudle.ts 中供全局使用。这是不够的,对于单元测试来说,考虑到我们不可能将 sharedModule 配置到测试环境中,所以管道自己需要提供一个 module. 我们对上面的代码进行如下改造:

import { NgModule, Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'safeValue'
})
export class SafeValuePipe implements PipeTransform {

  transform<T>(value: T, placeholder: string = '-'): T | string {
    if (typeof value !== 'string') return value;
    if (value === '') { // 检查是否为假值
      return placeholder;
    }
    return `${value}`; // 非假值时转换为字符串
  }
}

@NgModule({
  declarations: [SafeValuePipe],
  exports: [SafeValuePipe],
})
export class SafeValueModule { }

SafeValueModule 的意义在于在测试中为环境配置提供 safeValue 的编译环境。

当然 sharedModule 也可以间接使用 SafeValueModule 而不是在 sharedModule 中声明在导出 SafeValuePipe.

1.2 精简 component 代码

如下图所示,一个完整的 Angular component 的文件结构为:

image.png

这确实很完整,但是不够简洁,对于测试不友好。实际上:我们可以先将 html 和 less 集成到 component.ts 中,然后将 component 和 module 集成到 index.ts 中,这样一来,我们代码的结构就变成了:

image.png

其中,index.ts 的代码如下所示:

import { NgModule } from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { SafeValueModule } from '../pipes/safe-value.pipe';

@Component({
    selector: 'app-truncated-text',
    template: `
        <span [matTooltip]="showTooltip ? text : ''" matTooltipClass="tooltip-class">
        {{ truncatedText | safeValue : suffix }}
        </span>
    `,
    styles: [`
        .tooltip-class {
            /* 你的自定义样式 */
        }
    `]
})
export class TruncatedTextComponent { // 这里的 export 是必须的
    @Input() text: string = '';
    @Input() maxDisplayLength: number = 50; // 默认最大显示长度
    @Input() suffix: string = '-'; // 默认最大显示长度

    constructor() { }

    get truncatedText(): string {
        if (!this.text) return '';
        return this.text.length > this.maxDisplayLength
            ? this.text.substring(0, this.maxDisplayLength) + '...'
            : this.text;
    }

    get showTooltip(): boolean {
        return this.text.length > this.maxDisplayLength;
    }
}


@NgModule({
    declarations: [
        TruncatedTextComponent,
    ],
    imports: [
        // BrowserModule,
        CommonModule,
        SafeValueModule,
        MatTooltipModule
    ],
    exports: [
        TruncatedTextComponent,
    ]
})
export class TruncatedTextModule { }

目前,唯一需要注意的是:export class TruncatedTextComponent {} 最前面的 export 是必须的,否则会报错!

2. 测试 Angular component 的坑点

测试如 1.2 节后面所示的组件,有两大坑点,在本节中将会详细说明。

2.1 坑点一:外部组件库 Material 中的指令

<span [matTooltip]="showTooltip ? text : ''" matTooltipClass="tooltip-class"> 所示,用到了两个和 material 相关的指令:matTooltipmatTooltipClass。现在有一个问题就是,测试环境中,编译的时候在哪里找到这两个指令。

如:

@NgModule({
    declarations: [
        TruncatedTextComponent,
    ],
    imports: [
        // BrowserModule,
        CommonModule,
        SafeValueModule,
        MatTooltipModule
    ],
    exports: [
        TruncatedTextComponent,
    ]
})
export class TruncatedTextModule { }

所示!我们 import 一个 MatTooltipModule 来注入相关指令,而 MatTooltipModule 本身来自于:import { MatTooltipModule } from '@angular/material/tooltip';. 我们可以通过安装 "@angular/material": "^18.1.2" 和 "@angular/cdk": "^18.1.2"完成,只需保证版本号相同即可!(其实这才是坑)

参考 TruncatedTextModule 我们很容易将测试环境配置成如下形式:

import { MatTooltipModule } from "@angular/material/tooltip";
...
   ...
    TestBed.configureTestingModule({
      declarations: [
        ...
      ],
      imports: [
        ...
        MatTooltipModule,
      ],
    });

2.2 坑点二:管道 safeValue 的配置

src/app/truncated-text/index.ts 中的 TruncatedTextComponenttemplate 所示,在其中用到了管道 safeValue:

{{ truncatedText | safeValue : suffix }}

那么,在测试环境中,又是怎么样编译它呢?我们可以仿照上面处理 tooltip 的方式,参考 TruncatedTextModule 对其的处理方式:

import { SafeValueModule } from '../pipes/safe-value.pipe';
....
@NgModule({
    declarations: [
        TruncatedTextComponent,
    ],
    imports: [
        CommonModule,
        SafeValueModule,
        MatTooltipModule
    ],
    exports: [
        TruncatedTextComponent,
    ]
})
export class TruncatedTextModule { }

那么我们照猫画虎将测试环境配置成如下:

    TestBed.configureTestingModule({
      declarations: [
        ...
      ],
      imports: [
        CommonModule,
        MatTooltipModule,
        SafeValueModule,
      ],
    });

好的,坑点出现了! 如果是这样配置,那么测试环境是无法正常搭建的,我们必须在 declaration 中重新声明 TruncatedTextComponent, 如下所示:

TestBed.configureTestingModule({
  declarations: [
    TruncatedTextComponent,
  ],
  imports: [
    CommonModule,
    MatTooltipModule,
    SafeValueModule,
  ],
});

或者你可以认为,将整理之后的 TruncatedTextModule 中的 exports 元数据去掉之后就立刻得到了我们的测试环境!

3. 测试前的预备工作

  1. 在最开始的时候我们准备两个全局变量 fixturecomponent 分别保存固定点和组件实例;
  2. 在 beforeAll 中我们搭建测试环境下的编译环境;
  3. 在 beforeEach 中我们对 fixturecomponent 重新赋值,以防止各个测试之间相互影响。
  let fixture: ComponentFixture<TruncatedTextComponent>;
  let component: TruncatedTextComponent;

  beforeAll(() => {
    TestBed.configureTestingModule({
      declarations: [
        TruncatedTextComponent,
      ],
      imports: [
        CommonModule,
        MatTooltipModule,
        SafeValueModule,
      ],
    });
  })

  beforeEach(() => {
    fixture = TestBed.createComponent(TruncatedTextComponent);
    component = fixture.componentInstance;
  })

4. 尽可能的覆盖测试

为了全面的测试此组件的功能,我们设计了 6 个用例,分别从 class 层面和 view 层面对其测试。下面是这六个测试用例的测试目标:

  1. component 能否被成功创建出来,这一步实际上是在测试我们在 beforeAll 搭建的环境是否正确。
  2. 基础的截断功能的测试。
  3. 截断的时候 showTooltip 是否为真。
  4. 没有截断的时候 showTooltip 是否为假。
  5. 当规定的最大阶段长度变化的时候,最终显示值是否发生变化。
  6. 兜底的管道是否生效。

5. 测试技巧

  1. 设置组件的 props 并更新组件
component.text = 'This is a long text that should be truncated.';
component.maxDisplayLength = 10;
fixture.detectChanges();
  1. class 层对 component 测试
expect(component?.truncatedText).toBe('Some t...');
  1. view 层对 component 测试
fixture.whenStable().then(() => {
  expect(fixture.nativeElement.querySelector('span').innerText).toContain('Some text');
})

6. 测试代码

本节是测试一个 component 的全部代码,测试文件名为:truncated-text.spec.ts

image.png

import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { TruncatedTextComponent } from "./index";
import { MatTooltipModule } from "@angular/material/tooltip";
import { SafeValueModule } from "../pipes/safe-value.pipe";
import { CommonModule } from "@angular/common";


describe('Test trucated text component', () => {
  let fixture: ComponentFixture<TruncatedTextComponent>;
  let component: TruncatedTextComponent;

  beforeAll(() => {
    TestBed.configureTestingModule({
      declarations: [
        TruncatedTextComponent,
      ],
      imports: [
        CommonModule,
        MatTooltipModule,
        SafeValueModule,
      ],
    });
  })

  beforeEach(() => {
    fixture = TestBed.createComponent(TruncatedTextComponent);
    component = fixture.componentInstance;
  })

  it('Should create', () => {
    expect(component).toBeTruthy();
  })

  it('Should truncate text if it is longer than maxDisplayLength', () => {
    component.text = 'This is a long text that should be truncated.';
    component.maxDisplayLength = 10;
    fixture.detectChanges();
    expect(component?.truncatedText).toBe('This is a ...');
  })

  it('should show tooltip if text is truncated', () => {
    component.text = 'This is a long text that should trigger the tooltip.';
    component.maxDisplayLength = 5;
    expect(component?.showTooltip).toBe(true);
  })

  it('Should not show tooltip if text is not truncated', () => {
    component.text = 'Short';
    fixture.detectChanges();
    expect(component?.showTooltip).toBe(false);
  })

  it('Should update the truncated text when maxDisplayLength changes', () => {
    component.text = 'Some text';
    component.maxDisplayLength = 6;
    fixture.detectChanges();
    expect(component?.truncatedText).toBe('Some t...');

    component.maxDisplayLength = 10;
    fixture.detectChanges();
    expect(component?.truncatedText).toBe('Some text');
    fixture.whenStable().then(() => {
      expect(fixture.nativeElement.querySelector('span').innerText).toContain('Some text');
    })
  })

  it('Should be safe value', fakeAsync(() => {
    component.text = '';
    component.maxDisplayLength = 5;
    fixture.detectChanges();
    tick(200)
    fixture.whenStable().then(() => {
      expect(fixture.nativeElement.querySelector('span').innerText).toContain('-');
    })
  }))

  afterAll(() => { })
})