Angular的测试

背景

软件工程上的一个很重要的考量就是,如何进行单元测试.
无论是不是TDD,执行自动化的测试都是非常重要的.因此看看Angular的测试.
前提知识: 建议先了解 JasmineKarma.

环境相关

Angular使用 Jasmine 测试框架,并使用 karma 测试运行器,
默认脚手架已经搭建好.运行命令 ng test 测试即可.

有关的配置文件有

  • karma.conf.js
  • src/test.ts

脚手架会生成许多 *.spec.ts 文件,是测试用的文件
一般放置测试文件有两条约定

  • 单元测试放在要测试的文件旁边
  • 集成测试等跨文件夹和模块的测试由于不属于任何一个部分,可以放在 tests 目录下

代码覆盖率

测试时使用

1
ng test --no-watch --code-coverage

则会给出代码覆盖率的结果,
然后会在项目下生成 /coverage/project-name 文件夹,再往里则是分门别类的测试结果.

或者也可以在配置文件中要求 ng test 时默认做覆盖率测试

1
2
3
4
5
6
// angular.json
"test": {
"options": {
"codeCoverage": true
}
}

对于最低覆盖率的配置可以在 karma.conf.js 文件中配置

Angular提供了什么

  • 使用jasmine测试框架
  • 涉及依赖注入的地方使用 TestBed 来注入
    但这不意味着注入一个真正的服务,因为服务里发生的错误,一般是妨碍当前组件的测试的.

服务的测试

通常服务是最容易测试的部分.

没有依赖的服务

一些没有依赖的服务,测试起来尤为简单

1
2
3
4
5
6
7
8
9
10
11
12
describe('ValueService', () => {
// 声明服务
let service: ValueService;

// 初始化服务
beforeEach(() => { service = new ValueService(); });

// 对初始化好的服务进行测试
it('#getValue should return real value', () => {
expect(service.getValue()).toBe('real value');
});
});

有依赖的服务

常见地,服务之间会有依赖,比如一个 MasterService 依赖 ValueService

1
2
3
4
5
@Injectable()
export class MasterService {
constructor(private valueService: ValueService) { }
getValue() { return this.valueService.getValue(); }
}

测试时一个比较笨的方法是手动初始化两个服务,拼接起来

1
2
3
4
5
6
7
8
9
describe('MasterService without Angular testing support', () => {
let masterService: MasterService;

it('#getValue should return real value from the real service', () => {
// 手动new两次,分别制造两个service
masterService = new MasterService(new ValueService());
expect(masterService.getValue()).toBe('real value');
});
});

这样做两个缺点

  1. 有时依赖非常复杂
  2. ValueService中的错误导致MasterService不能用于测试

对应的解决方法是

  1. 一方面做一个假的 ValueService
  2. 另一方面使用更合理的方式进行依赖的管理
    此处要介绍Angular官方的 TestBed 服务

TestBed发挥类似NgModule的作用,
使用providers等关键字来定义提供服务的类,
然后使用依赖注入的方式注入服务.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 声明变量
let masterService: MasterService;
let valueServiceSpy: jasmine.SpyObj<ValueService>;

beforeEach(() => {
// 先为ValueService建立一个替身
const spy = jasmine.createSpyObj('ValueService', ['getValue']);

// provider中配置要用的各种服务
TestBed.configureTestingModule({
providers: [
MasterService,
{ provide: ValueService, useValue: spy } // 其中Valueservice使用的是替身
]
});

// 然后使用inject函数获取实例
masterService = TestBed.inject(MasterService);
valueServiceSpy = TestBed.inject(ValueService) as jasmine.SpyObj<ValueService>;
});

it('#getValue should return stubbed value from a spy', () => {
// 配置假服务的返回值
const stubValue = 'stub value';
valueServiceSpy.getValue.and.returnValue(stubValue);

// 正常地使用各种要测试的service即可
expect(masterService.getValue())
.toBe(stubValue, 'service returned stub value');
});
  1. 如果不喜欢使用beforeEach

    有些人不喜欢beforeEach(),(可能是因为这样不能给刚入门的人表达这段代码的意图)
    认为使用另一种基于函数的方式更好

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function setup() {
    // 原先beforeEach中的内容
    return {masterService, valueServiceSpy, stubValue};
    }

    it('新的测试中使用setup', () => {
    const {masterService, valueServiceSpy, stubValue} = setup();
    // expect ...
    })

组件的测试

组件类本身的一些逻辑可以看作和service的测试,没有太大区别,
但不要忘了组件的DOM也是需要测试的,比如

  • 消息是否显示
  • 数据是否刷新

等等则需要使用专门的API来获取组件的元素,继而获取元素的内容来进行测试

组件本身(ts文件相关)

测试时与使用时不同的是,

  • 组件本身的一些原本需要用户触发的函数可以直接调用了
    • 比如click事件
    • 再比如使用@Input传入的参数
  • 需要考虑生命周期

假如要测试的组件长这样

1
2
3
4
5
6
export class DashboardHeroComponent {
@Input() hero: Hero;
isOn = false;
clicked() { this.isOn = !this.isOn; }
get message() { return `The light is ${this.isOn ? 'On' : 'Off'}`; }
}

测试时可以使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('LightswitchComp', () => {
it('#clicked() should toggle #isOn', () => {
// 简单new一个组件
const comp = new LightswitchComponent();

expect(comp.isOn).toBe(false, 'off at first');
// 直接调用组件的方法
comp.clicked();
expect(comp.isOn).toBe(true, 'on after click');
});
it('其他的测试', () => {
const comp = new DashboardHeroComponent();
const hero: Hero = {id: 42, name: 'Test'};
comp.hero = hero; // 直接设置了本该传入的属性
})
});

组件的创建方式也可以

  • 直接使用new
  • 如果有service的依赖,可以使用TestBed注入

比如一个依赖UserService的组件

1
2
3
4
5
6
7
8
9
export class WelcomeComponent implements OnInit {
welcome: string;
constructor(private userService: UserService) { }

ngOnInit(): void {
this.welcome = this.userService.isLoggedIn ?
'Welcome, ' + this.userService.user.name : 'Please log in.';
}
}

测试时,一面可以制作一个假的替身

1
2
3
4
class MockUserService {
isLoggedIn = true;
user = { name: 'Test User'};
}

另一面需要使用TestBed来注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
WelcomeComponent, // 一方面提供组件
{ provide: UserService, useClass: MockUserService } // 另一方面提供服务
]
});
// 注入组件即可
comp = TestBed.inject(WelcomeComponent);
userService = TestBed.inject(UserService);
});

it('should ask user to log in if not logged in after ngOnInit', () => {
comp.ngOnInit(); // 需要手动调用ngOnInit
expect(comp.welcome).toContain(userService.user.name); // 然后属性才能有值
expect(comp.welcome).toContain('log in');
});

组件的DOM

为了测试html中的某个元素,
首先需要让测试代码渲染一个假想中存在的html页面,
然后为了获取页面上的某个元素,angular建立了一个fixture概念,
比如使用 fixture.nativeElement.querySelector('p').textContent 的方法来获取页面上的一个 <p> 的文字内容.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('BannerComponent (with beforeEach)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;

beforeEach(() => {
// 像ngModule一样声明component
TestBed.configureTestingModule({declarations: [BannerComponent]});
// 渲染假想的页面,获取到的是fixture
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // fixture有许多属性,这只是其中之一
});

it('should create', () => {
expect(component).toBeDefined();
});

it('should have <p> with "banner works!"', () => {
const bannerElement: HTMLElement = fixture.nativeElement;
const p = bannerElement.querySelector('p');
expect(p.textContent).toEqual('banner works!');
});
});

另外补充,事实上 fixture.nativeElementfixture.debugElement.nativeElement 的快捷方式.

1
2
const bannerDe: DebugElement = fixture.debugElement;
const bannerEl: HTMLElement = bannerDe.nativeElement;

因为有些平台不是浏览器,或许不支持 HTMLElement 类,
只能先获取 DebugElement 类,然后让该类中继一下.
这里预计使用平台就是浏览器,直接将 DebugElement 类整个直接转成 HTMLElement 了.

非浏览器的平台上,可能不能直接在 HTMLElement 类上使用 querySelector 方法.
而是需要先在 DebugElement 类上使用 query(By.css('p')) 之类的方法来获取对象,
然后再转成 HTMLElement.

1
2
3
4
5
6
it('should find the <p> with fixture.debugElement.query(By.css)', () => {
const bannerDe: DebugElement = fixture.debugElement;
const paragraphDe = bannerDe.query(By.css('p')); // 先获取目标
const p: HTMLElement = paragraphDe.nativeElement; // 然后再转成HTMLElement
expect(p.textContent).toEqual('banner works!');
});

TODO 组件测试高级

需要手动检测变化

Angular是双向绑定框架,组件中的变量变化后,
不能立即反映在html中,需要手动检测变化

1
2
3
4
5
it('should display a different test title', () => {
component.title = 'Test Title'; // 组件类中的变量改变
fixture.detectChanges(); // 需要手动检测
expect(h1.textContent).toContain('Test Title'); // 然后html中绑定{{title}}的地方才会发生变化.
});

一定程度上可以自动检测变化

1
2
3
4
5
6
7
8
import { ComponentFixtureAutoDetect } from '@angular/core/testing';

TestBed.configureTestingModule({
// ...
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
});

限制在于该自动检测变化只能检测异步活动,比如Observable,Promise,DOM事件,
而不能对同步活动做响应(比如手动使用测试代码更新了属性的值),
此时需要手动调用 fixture.detectChanges()
事实上许多人为了保证一定调用了 detectChanges, 喜欢频繁手动调用,因为并没有损害.

输入框需要先发射事件

页面上的一个input框输入了内容,往往后台会收到,
但在测试时,仅仅使用 detectChanges, 仅仅能读取到input里面的初始值,
若要得到输入到input中的值,则需要先使用 dispatchEvent 以通知input内容有变.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it('should convert hero name to Title Case', () => {
const hostElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input');
const nameDisplay: HTMLElement = hostElement.querySelector('span');

// 改变input的值,模拟的是手动输入了新的值
nameInput.value = 'quick BROWN fOx';

// 需要先手动通知input内容改变
nameInput.dispatchEvent(new Event('input'));
// 然后告诉angular去检测变更
fixture.detectChanges();

expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});

依赖服务的注入和服务的测试中一样

组件 WelcomeComponent 依赖 UserService 的话

  1. 一方面可以制作一个假的服务,比如 userServiceStub
  2. 然后需要在 providers 里面带上这个服务
  3. 最后就能正常使用组件了,如果想获取服务,有两种办法
    1. 直接从TestBed获取
    2. 从fixture获取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let userServiceStub: Partial<UserService>;

beforeEach(() => {
// 造一个假的服务
userServiceStub = {
isLoggedIn: true,
user: { name: 'Test User' },
};

TestBed.configureTestingModule({
declarations: [ WelcomeComponent ],
providers: [ { provide: UserService, useValue: userServiceStub } ], // 配置上服务
});

fixture = TestBed.createComponent(WelcomeComponent);
comp = fixture.componentInstance;

// 直接从TestBed获取service比较方便
userService = TestBed.inject(UserService);
// 也可以从fixture获取service,虽然很直观,但就是有点罗嗦
userService = fixture.debugElement.injector.get(UserService);

// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});

元素的点击

浏览器平台和非浏览器平台的点击事件实现起来很不一样
浏览器平台

1
heroEl.click();                 // HTMLElement类型

非浏览器平台

1
heroDe.triggerEventHandler('click', null); // DebugElement类型

为此,可以写一个工具函数来帮忙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 用于传递信息的预定义内容
export const ButtonClickEvents = {
left: { button: 0 },
right: { button: 2 }
};

// 工具函数
export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
if (el instanceof HTMLElement) {
// 浏览器平台则用click
el.click();
} else {
// 其他平台则用triggerEventHandler,同时还带上用的是左键还是右键
el.triggerEventHandler('click', eventObj);
}
}

使用时就能变成

1
2
click(heroEl);
click(heroDe);

TODO 路由

调用compileComponents

许多组件是引用外部定义的模板和css文件的,
如果使用 ng test 来运行测试,那么大可不必担心,
但如果使用的是Web IDE,则需要使用 compileComponents 提醒它,查找并编译有关联的文件.

1
2
3
4
5
6
7
beforeEach(waitForAsync(() => {
TestBed
.configureTestingModule({
declarations: [BannerComponent],
})
.compileComponents(); // compile template and css
}));

当然这个代码在使用 ng test 测试时也是无害的.

异步过程的测试

许多被测试的代码是异步的,比如某组件的一个向远程请求的异步过程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
getQuote() {
this.errorMessage = '';
this.quote = this.twainService.getQuote().pipe(
startWith('...'),
catchError( (err: any) => {
// Wait a turn because errorMessage already set once this turn
setTimeout(() => this.errorMessage = err.message || err.toString());
return of('...'); // reset message to placeholder
})
);
}

// 页面上有 {{quote | async}}

通常服务才关心返回的是什么值,而组件比较关心页面上显示什么.
对于制作数据,有一些方法

  • 特别简单的return(of())方法,只能测试正常结果,对setTimeout等代码无能为力
  • return(defer())方法,可以延迟一个js引擎周期,数据的返回显得更真实一些.
  • return(throwError())返回一个错误,用于测试一些异常

测试方面,通常有4种方法,

  • 简单的同步测试方法,不能模拟时间流逝,最多测一些正常的结果
  • fakeAsync中使用tick与detectChanges搭配,
    既能测试setTimeout,又能模拟经过一段时间后正常的数据才返回来.
  • 如果有XHR调用,则不得不使用waitForAsync,
    不再能够使用同步风格的tick,功能上可以用whenStable来代替
  • jasmine自己的done方法

其中fakeAsync既能测试正常,又能测试异常,个人认为最适合使用.

现在假设已经有一个SpyObj,暂定返回数据的方式是return(of())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
beforeEach(() => {
testQuote = 'Test Quote';

const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// 要求服务直接返回of()
getQuoteSpy = twainService.getQuote.and.returnValue(of(testQuote));

TestBed.configureTestingModule({
declarations: [TwainComponent],
providers: [{provide: TwainService, useValue: twainService}]
});

fixture = TestBed.createComponent(TwainComponent);
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});

最普通的同步方法

1
2
3
4
5
6
7
it('should show quote after component initialized', () => { // 括号外什么也没有,最普通的测试方法
// 在ngOnInit中,由于收到service的返回值,数据发生了改变,必须要使用detectChanges来让View层更新
fixture.detectChanges();

expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).toBe(true, 'getQuote called');
});

fakeAsync方法

  1. 测试异常结果

    准备数据方面,使用的是return(throwError())

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    it('should display error when TwainService fails', fakeAsync(() => {
    getQuoteSpy.and.returnValue(throwError('TwainService test failure'));

    fixture.detectChanges(); // 对应ngOnInit

    tick(); // 模拟时间流逝,对应setTimeout
    fixture.detectChanges(); // 使setTimeout内部代码生效

    expect(errorMessage()).toMatch(/test failure/, 'should display error');
    expect(quoteEl.textContent).toBe('...', 'should show placeholder');
    }));
  2. 测试正常结果

    准备数据方面,可以使用return(defer()),具体使用方法如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    export function asyncData<T>(data: T) {
    // defer可以让js引擎在下一个周期再发出这个数据
    // defer的参数稍微有点特殊,要求用Promise来写,暂时不管
    return defer(() => Promise.resolve(data));
    }

    export function asyncError<T>(errorObject: any) {
    return defer(() => Promise.reject(errorObject));
    }

    // 使用
    getQuoteSpy.and.returnValue(asyncData(testQuote));

    测试中会使用tick和detectChanges模拟过了一段时间数据才返回,
    因此可以测试一些placeholder

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
    fixture.detectChanges(); // 对应ngOnInit(),可以移动到beforeEach中
    expect(quoteEl.textContent).toBe('...', 'should show placeholder');

    tick(); // 模拟过了一段时间
    fixture.detectChanges(); // 数据返回后更新view

    expect(quoteEl.textContent).toBe(testQuote, 'should show quote');
    expect(errorMessage()).toBeNull('should not show error');
    }));
  3. tick

    tick可以接收参数,默认似乎是一个js引擎周期?

    1
    2
    3
    4
    5
    6
    7
    8
    it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
    let called = false;
    setTimeout(() => {
    called = true;
    }, 100);
    tick(100);
    expect(called).toBe(true);
    }));

waitForAsync方法

道理上和fakeAsync方法也是相同的,不过没有了tick.

1
2
3
4
5
6
7
8
9
it('should show quote after getQuote (async)', waitForAsync(() => {
fixture.detectChanges();

fixture.whenStable().then(() => { // 用whenStable模拟时间流逝,这里指的是过了一段时间数据才返回来
fixture.detectChanges(); // 刷新view
expect(quoteEl.textContent).toBe(testQuote); // 在异步的区域内做断言
expect(errorMessage()).toBeNull('should not show error');
});
}));

done方法

fakeAsync和waitForAsync都是jasmine原本的done方法的改进版.
如果用done来写可能会有些麻烦.
并且从官方例子中看不出来用done的意义是什么.

1
2
3
4
5
6
7
8
9
10
11
12
13
it('should show last quote (quote done)', (done: DoneFn) => {
fixture.detectChanges(); // 对应ngOnInit

// quote在页面上用的是管道
// 但如果写成等效的代码,或许是 quote.subscribe(() => {更新页面})
// 因此直接仿照subscribe来写测试代码,这就显得done很多余
component.quote.pipe(last()).subscribe(() => {
fixture.detectChanges();
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).toBeNull('should not show error');
done();
});
});

指令的测试

比如一个指令,用于设置一个html元素高亮,并接收一个参数作为色彩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Directive({ selector: '[highlight]' })
export class HighlightDirective implements OnChanges {
defaultColor = 'rgb(211, 211, 211)'; // lightgray
@Input('highlight') bgColor: string;

constructor(private el: ElementRef) {
el.nativeElement.style.customProperty = true;
}

ngOnChanges() {
// 最终的功能就是设置背景色
this.el.nativeElement.style.backgroundColor = this.bgColor || this.defaultColor;
}
}

在模板中使用的方式如下

1
2
3
4
5
6
7
8
9
import { Component } from '@angular/core';
@Component({
template: `
<h2 highlight="skyblue">About</h2>
<h3>Quote of the day:</h3>
<twain-quote></twain-quote>
`
})
export class AboutComponent { }

与其他测试有些不同的地方就是TestBed要多设置一下,
其他地方没有本质不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
beforeEach(() => {
fixture = TestBed.configureTestingModule({
declarations: [ AboutComponent, HighlightDirective ], // 多声明一个指令
schemas: [ NO_ERRORS_SCHEMA ]
})
.createComponent(AboutComponent);
fixture.detectChanges(); // initial binding
});

it('should have skyblue <h2>', () => {
const h2: HTMLElement = fixture.nativeElement.querySelector('h2');
const bgColor = h2.style.backgroundColor;
// 依然是获取色彩后测试一下一样不一样
expect(bgColor).toBe('skyblue');
});

管道的测试

假如有一个管道,能够将一句话中的所有单词变成:
首位大写,其余小写.

1
2
3
4
5
6
7
@Pipe({name: 'titlecase', pure: true})
export class TitleCasePipe implements PipeTransform {
transform(input: string): string {
return input.length === 0 ? '' :
input.replace(/\w\S*/g, (txt => txt[0].toUpperCase() + txt.substr(1).toLowerCase() ));
}
}

一方面可以直接使用简单的输入/输出测试

1
2
3
4
5
6
7
8
9
10
11
12
13
describe('TitleCasePipe', () => {
// 直接new了管道
const pipe = new TitleCasePipe();

// 然后直接测试一个输入会对应什么输出
it('transforms "abc" to "Abc"', () => {
expect(pipe.transform('abc')).toBe('Abc');
});
// 多测试几个
it('transforms "abc def" to "Abc Def"', () => {
expect(pipe.transform('abc def')).toBe('Abc Def');
});
});

不过也可以使用DOM元素测试最终的效果
提前假设有这么一个页面

  1. 一个input可以让用户输入词语
  2. 一个span显示用户输入的,经过管道格式化的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
it('should convert hero name to Title Case', () => {
const hostElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input');
const nameDisplay: HTMLElement = hostElement.querySelector('span');

// 设置输入框的值
nameInput.value = 'quick BROWN fOx';

nameInput.dispatchEvent(new Event('input')); // 手动发出事件信号
fixture.detectChanges(); // 告诉Angular要检测变化,更新页面

expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});

参考

  1. 官方文档