本文介绍如何对 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 的文件结构为:
这确实很完整,但是不够简洁,对于测试不友好。实际上:我们可以先将 html 和 less 集成到 component.ts 中,然后将 component 和 module 集成到 index.ts 中,这样一来,我们代码的结构就变成了:
其中,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 相关的指令:matTooltip 和 matTooltipClass。现在有一个问题就是,测试环境中,编译的时候在哪里找到这两个指令。
如:
@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 中的 TruncatedTextComponent 的 template 所示,在其中用到了管道 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. 测试前的预备工作
- 在最开始的时候我们准备两个全局变量
fixture和component分别保存固定点和组件实例; - 在 beforeAll 中我们搭建测试环境下的编译环境;
- 在 beforeEach 中我们对
fixture和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;
})
4. 尽可能的覆盖测试
为了全面的测试此组件的功能,我们设计了 6 个用例,分别从 class 层面和 view 层面对其测试。下面是这六个测试用例的测试目标:
- component 能否被成功创建出来,这一步实际上是在测试我们在 beforeAll 搭建的环境是否正确。
- 基础的截断功能的测试。
- 截断的时候 showTooltip 是否为真。
- 没有截断的时候 showTooltip 是否为假。
- 当规定的最大阶段长度变化的时候,最终显示值是否发生变化。
- 兜底的管道是否生效。
5. 测试技巧
- 设置组件的 props 并更新组件
component.text = 'This is a long text that should be truncated.';
component.maxDisplayLength = 10;
fixture.detectChanges();
- class 层对 component 测试
expect(component?.truncatedText).toBe('Some t...');
- view 层对 component 测试
fixture.whenStable().then(() => {
expect(fixture.nativeElement.querySelector('span').innerText).toContain('Some text');
})
6. 测试代码
本节是测试一个 component 的全部代码,测试文件名为:truncated-text.spec.ts
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(() => { })
})