本文带您了解 Angular 中内置的测试框架的使用,相信通过本文的阅读您能够对于 Angular 的测试有一个全面的认识,为您之后的深入学习打下坚实的基础。
1. 测试的结构
- Unit Test
- Integration and Functional Testing
- Complete Application Test
2. 端到端的测试
3. Angular 的集成测试示意图
3.1 Angular 集成测试数据 mock
数据 mock 的四个层次:
- Dummies
- Stubs
- Spies
- True mocks
4. Angular 中的单元测试
- 独立测试 Isolated
- 集成测试 Integration
- 浅集成测试
- 深度集成测试
Angular 单元测试使用的工具有:
- Karma (宿命
/ˈkɑːmə/) - Jasmine (茉莉花
/ˈdʒæzmɪn/)
其它的单元测试工具(除了我们需要使用的 Karma 和 Jasmine):
- Jest
- Mocha/Chai/etc
/tʃaɪ/ chai 印度茶 - Sinon 拉丁语,表示
except if - TestDouble
- Wallaby
/ˈwɒləbi/小型袋鼠 - Cypress
/ˈsaɪprəs/一种常青树 - End to end tools
5. 拉去我们的学习代码
使用 git clone https://github.com/joeeames/PSAngularUnitTestingCourse.git ngUnitTestingDemo 拉去我们这篇文章的配合代码。拉取之后:code ngUnitTestingDemo && npm i
完成代码的拉取之后,在项目的 src/app 目录下创建名为 first-test.spec.ts 的文件。然后输入下面的代码:
describe('my first test', () => {
let sut; // System Under Test (测试对象)
beforeEach(() => {
sut = {};
});
it('should be true if true', () => {
// Arrange
sut.a = false;
// Act
sut.a = true;
// Assert
expect(sut.a).toBe(true);
});
});
虽然这段代码的作用就是为了让我们的测试跑起来,但是这段还是有一些需要说道说道的。首先,一个完整的测试文件的组成部分采用的是:describe -> beforeEach -> it 的搭配方案。其中 describe 统领全局,beforeEach 中完成对 公共资源 的初始化过程;最后在 it 中完成每一个细小的测试任务。
我们在 package.json 中增加一个 script 任务标签:"test": "ng test", 最后执行 yarn test 将测试跑起来。
这个仓库的代码将会帮助我们学习 单元测试 孤立测试 集成测试(深或者浅)
6. 如何写一个好的单元测试
一个好的单元测试是具有结构化的测试。即 structuring tests:
- 将所有的预条件和输入放在一个数组中。
- 测试的对象应该是 js 中的 object 或者 class.
- 对期待的结果进行断言。
6.1 两种测试思想 DRY v.s. DAMP
- DRY 的含义为
don't repeat yourself即不要写重复的代码! - DAMP 的含义为
repeat yourself if neccessary即,必要的话可以写一些重复性代码。
6.2 好的测试本质上是一个完整的故事
- 你需要在一个
it块中将此次测试以一个完整故事说明清楚。并且其他人不用在超出此 it 块的范围之外去理解这个测试。 - 一些小的技巧:将和测试本身关系不太大的 setup 逻辑提升到 beforeEach 中;将测试本身关系密切的 setup 逻辑放在 it 测试块中;只在 it 块中使用 Arrange Act Assert.
7. 对管道进行测试
我们在 src/app/strength 中创建名为 strength.pipe.spec.ts 的文件,用来测试名为 strength 的管道:touch src/app/strength/strength.pipe.spec.ts
该测试文件中的代码为:
import { StrengthPipe } from './strength.pipe';
describe('StrengthPipe', () => {
it('should display waek if strength is 5', () => {
let pipe = new StrengthPipe();
expect(pipe.transform(5)).toEqual('5 (week)');
})
})
注意上面的代码中,我们对管道的测试是将其当成一个普通类对待的,将其当成普通类的时候,我们测试的时候需要两个点:
- 我们需要注意由于 StrengthPipe 的构造函数中没有构造参数,所以我们实例化的时候也不需要传递任何参数,下面是名为
StrengthPipe的管道中的代码:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'strength'
})
export class StrengthPipe implements PipeTransform {
transform(value: number): string {
if(value < 10) {
return value + " (weak)";
} else if(value >= 10 && value < 20) {
return value + " (strong)";
} else {
return value + " (unbelievable)";
}
}
}
- 我们测试的实际上是
class StrengthPipe上的transform方法。最后使用ctrl + c停住刚才的测试,然后使用yarn test重启测试。
8. 测试服务类
在这个小节中,我们对 Angular 中的 service 进行测试,我们在 src/app 目录中创建名为 message.service.spec.ts 的文件作为对服务 message.service.ts 的测试。touch src/app/message.service.spec.ts 此文件的内容如下:
import { MessageService } from "./message.service";
describe('MessageService', () => {
let service: MessageService;
beforeEach(() => {
service = new MessageService();
})
it('should have no messages to start', () => {
expect(service.messages.length).toBe(0);
})
it('should add a message when add is called', () => {
service.add('message 1');
expect(service.messages.length).toBe(1);
})
})
我们不难看出来,测试一个服务和测试一个管道是非常相似的,都是将其作为一个普通的类进行测试,测试的目标是其上的方法。下面是该服务的全部代码:
import { Injectable } from '@angular/core';
@Injectable()
export class MessageService {
messages: string[] = [];
add(message: string) {
this.messages.push(message);
}
clear() {
this.messages = [];
}
}
9. 对组件进行测试
我们测试的组件名为 HeroesComponent 这个组件的代码如下所示:
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css'],
})
export class HeroesComponent implements OnInit {
heroes: Hero[];
constructor(private heroService: HeroService) {}
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes));
}
add(name: string): void {
name = name.trim();
var strength = 11;
if (!name) {
return;
}
this.heroService.addHero({ name, strength } as Hero).subscribe((hero) => {
this.heroes.push(hero);
});
}
delete(hero: Hero): void {
this.heroes = this.heroes.filter((h) => h !== hero);
this.heroService.deleteHero(hero).subscribe();
}
}
在 src/app/heroes 这个目录中创建名为 heroes.components.spec.ts 的测试文件 touch src/app/heroes/heroes.components.spec.ts 。然后在其中键入以下代码:
import { HeroesComponent } from "./heroes.component";
describe('HeroComponents', () => {
let component: HeroesComponent;
let HEROES;
let mockHeroService;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
component = new HeroesComponent(mockHeroService);
})
describe('delete', () => {
it('should remove the indicated hero from the heroes list', () => {
component.heroes = HEROES;
component.delete(HEROES[2]);
expect(component.heroes.length).toBe(2);
})
})
})
上述代码结合被测试的组件代码可以看出:
- 我们测试组件,本质上还是将其作为一个类进行测量的,测试其上的一些方法。
- 既然将其作为类进行测试了,那么就需要对其进行实例化,观察 HeroesComponent 的构造函数发现其需要一个名为
HeroService类型的服务注入。 - 我们使用的是 jasmine 框架上面的 createSpyObj 方法去模拟这个服务类,传递的构造参数是一个数组其中有三个元素,都是字符串,表示的是用
jasmine.createSpyObj模拟的服务所具有的三个方法的名称。 - 注意在后续的测试中,我们没有使用到这个服务类的功能,但是作为测试组件的必经环节又必须注入此服务,这个时候我们采用的方式就是使用 jasmine 进行模拟。让我们看一下真正的服务类的代码:
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { Hero } from './hero';
import { MessageService } from './message.service';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};
@Injectable()
export class HeroService {
private heroesUrl = 'api/heroes'; // URL to web api
constructor(
private http: HttpClient,
private messageService: MessageService) { }
/** GET heroes from the server */
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(heroes => this.log(`fetched heroes`)),
catchError(this.handleError('getHeroes', []))
);
}
/** GET hero by id. Return `undefined` when id not found */
getHeroNo404<Data>(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/?id=${id}`;
return this.http.get<Hero[]>(url)
.pipe(
map(heroes => heroes[0]), // returns a {0|1} element array
tap(h => {
const outcome = h ? `fetched` : `did not find`;
this.log(`${outcome} hero id=${id}`);
}),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/** GET hero by id. Will 404 if id not found */
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get<Hero>(url).pipe(
tap(_ => this.log(`fetched hero id=${id}`)),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
// if not search term, return empty hero array.
return of([]);
}
return this.http.get<Hero[]>(`api/heroes/?name=${term}`).pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
//////// Save methods //////////
/** POST: add a new hero to the server */
addHero (hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
/** DELETE: delete the hero from the server */
deleteHero (hero: Hero | number): Observable<Hero> {
const id = typeof hero === 'number' ? hero : hero.id;
const url = `${this.heroesUrl}/${id}`;
return this.http.delete<Hero>(url, httpOptions).pipe(
tap(_ => this.log(`deleted hero id=${id}`)),
catchError(this.handleError<Hero>('deleteHero'))
);
}
/** PUT: update the hero on the server */
updateHero (hero: Hero): Observable<any> {
return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
tap(_ => this.log(`updated hero id=${hero.id}`)),
catchError(this.handleError<any>('updateHero'))
);
}
/**
* Handle Http operation that failed.
* Let the app continue.
* @param operation - name of the operation that failed
* @param result - optional value to return as the observable result
*/
private handleError<T> (operation = 'operation', result?: T) {
return (error: any): Observable<T> => {
// TODO: send the error to remote logging infrastructure
console.error(error); // log to console instead
// TODO: better job of transforming error for user consumption
this.log(`${operation} failed: ${error.message}`);
// Let the app keep running by returning an empty result.
return of(result as T);
};
}
/** Log a HeroService message with the MessageService */
private log(message: string) {
this.messageService.add('HeroService: ' + message);
}
}
这下能够感受到 mock 的乐趣了吗?
实际上上面的测试没有办法通过,会报错,如下所示:
原因也很简单,就是我们忘记写 mockHeroService.deleteHero.and.returnValue(of(true)) 现在补充完整:
import { of } from "rxjs";
import { HeroesComponent } from "./heroes.component";
describe('HeroComponents', () => {
let component: HeroesComponent;
let HEROES;
let mockHeroService;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
component = new HeroesComponent(mockHeroService);
})
describe('delete', () => {
it('should remove the indicated hero from the heroes list', () => {
mockHeroService.deleteHero.and.returnValue(of(true));
component.heroes = HEROES;
component.delete(HEROES[2]);
expect(component.heroes.length).toBe(2);
})
})
})
上面这段代码是使用Angular和Jasmine测试框架编写的,目的是对HeroesComponent组件中的delete方法进行单元测试。下面是对代码的逐句解释:
-
import { of } from "rxjs";- 这行代码从
rxjs库中导入of函数。of是一个创建Observable的函数,它可以接受一个或多个参数,并将这些参数按顺序发出。
- 这行代码从
-
import { HeroesComponent } from "./heroes.component";- 这行代码从当前目录下的
heroes.component文件中导入HeroesComponent组件。
- 这行代码从当前目录下的
-
describe('HeroComponents', () => {- 这是一个Jasmine测试套件的定义,它描述了要测试的组件或功能,这里是
HeroComponents。describe函数接受两个参数,第一个参数是测试套件的名称,第二个参数是一个函数,其中包含了所有的测试用例。
- 这是一个Jasmine测试套件的定义,它描述了要测试的组件或功能,这里是
-
let component: HeroesComponent;- 在测试套件的作用域内声明一个变量
component,它的类型是HeroesComponent。这个变量将用于实例化HeroesComponent组件,以便进行测试。
- 在测试套件的作用域内声明一个变量
-
let HEROES;- 声明一个变量
HEROES,用于存储英雄列表的示例数据。
- 声明一个变量
-
let mockHeroService;- 声明一个变量
mockHeroService,用于存储模拟的英雄服务。
- 声明一个变量
-
beforeEach(() => {beforeEach是一个Jasmine钩子,它在每个测试用例执行之前运行。这里用于设置每个测试用例的初始状态。
-
HEROES = [ ... ];- 初始化
HEROES变量,为其分配一个包含三个英雄对象的数组。
- 初始化
-
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);- 使用Jasmine的
createSpyObj函数创建一个模拟的英雄服务对象,这个对象包含getHeroes、addHero和deleteHero三个方法的间谍(spy)。
- 使用Jasmine的
-
component = new HeroesComponent(mockHeroService);- 使用模拟的英雄服务实例化
HeroesComponent组件。
- 使用模拟的英雄服务实例化
-
describe('delete', () => {- 定义一个子测试套件,专门用于测试
HeroesComponent组件中的delete方法。
- 定义一个子测试套件,专门用于测试
-
it('should remove the indicated hero from the heroes list', () => {- 使用
it函数定义一个测试用例,这个测试用例验证delete方法是否正确地从英雄列表中移除了指定的英雄。
- 使用
-
mockHeroService.deleteHero.and.returnValue(of(true));- 设置模拟的英雄服务的
deleteHero方法的返回值。当这个方法被调用时,它会返回一个Observable,该Observable发出一个true值。
- 设置模拟的英雄服务的
-
component.heroes = HEROES;- 将
HEROES数组赋值给组件的heroes属性。
- 将
-
component.delete(HEROES[2]);- 调用组件的
delete方法,传入HEROES数组中的第三个英雄对象(索引为2)作为参数。
- 调用组件的
-
expect(component.heroes.length).toBe(2);- 使用Jasmine的
expect函数断言组件的heroes数组的长度是否为2。这是为了验证delete方法是否成功地从数组中移除了一个英雄对象。
- 使用Jasmine的
10. xit 和 it
在测试框架中,it和xit都用于定义测试用例,但两者在执行行为上有所不同。it是定义正常执行的测试用例,测试运行时会被执行。而xit则是定义了一个被“忽略”的测试用例,它不会被执行。使用xit可以帮助暂时禁用某个测试用例,但保留其在测试文件中的位置,便于后续重新启用或参考。 在上述代码中,“should remove the indicated hero from the heroes list” 是一个正常执行的测试用例,而 “should call deleteHero” 则被标记为xit,表示这个测试用例当前不会被执行。
我们甚至还可以对调用函数时候传入的参数进行断言:
describe('delete', () => {
it('should remove the indicated hero from the heroes list', () => {
mockHeroService.deleteHero.and.returnValue(of(true));
component.heroes = HEROES;
component.delete(HEROES[2]);
expect(component.heroes.length).toBe(2);
})
xit('should call deteleHero', () => {
mockHeroService.deleteHero.and.returnValue(of(true));
component.heroes = HEROES;
component.delete(HEROES[2]);
// expect(mockHeroService.deleteHero).toHaveBeenCalled();
expect(mockHeroService.deleteHero).toHaveBeenCalledWith(HEROES[2]);
})
})
11. Angular 中的浅测试和深测试
上节中所示的对于 HeroesComponent 组件的测试就是 浅测试,所谓浅测试指的就是只测试本组件中的属性和方法,实际上 HeroesComponent 组件的模板中还套用了一个子组件,如下所示:
<h2>My Heroes</h2>
<div>
<label>Hero name:
<input #heroName />
</label>
<!-- (click) passes input value to add() and then clears the input -->
<button (click)="add(heroName.value); heroName.value=''">
add
</button>
</div>
<ul class="heroes">
<li *ngFor="let hero of heroes">
<app-hero [hero]="hero" (delete)="delete(hero)"></app-hero>
</li>
</ul>
我们上述的组件测试从未涉及过 app-hero 子组件的测试,因此被称为浅测试。
而与之对应的当然就是深测试了。所谓深测试,就是递归测试,不仅测试最外层组件,而是对其内部的组件也进行测试。关于这个话题我们下节在深入讨论。
12. Angular 中的深测试
本节将通过从最外层组件测试开始,在排除错误的过程中一步步的引入深测试。在正式开始之前需要介绍 @angular/core/testing 这个库中的 TestBed 对象,这个对象上有两个方法和这个小节息息相关:
- 用来模拟 NgModule 装饰器的
TestBed.configureTestingModule. - 用来创建模拟 component 的
Test.createComponent. - 除此之外需要额外介绍 TestBed.configureTestingModule 配置参数中的
schemas: [NO_ERRORS_SCHEMA],, 这个配置可以在我们初步学习,无法模拟组件模板中复杂结构的时候直接忽略错误,将测试成功跑起来。等到后面深入学习之后,能够对组件模板中的常见结构进行 mock 的时候,再将其关掉。
12.1
好的现在让我们开始测试最外层的组件。在 Angular 正常运行的过程中,组件的渲染一般由其所在的模块提供编译环境,在测试环境中,通过上面的代码也可以看出来,我们是将 component 当成一个 class 来测试的,所以其中的 html 的编译环境由谁来提供呢?
实际上,我们的 Angular 框架中提供了相应的模块 TestBed, 而 TestBed 则是从 @angular/core/testing 中导出的!如下所示:
import { TestBed } from "@angular/core/testing";
...
...
TestBed.configureTestingModule({
declarations: [HeroesComponent, FakeHeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}]
});
使用 TestBed 中的配置方法进行配置的时机是在 beforeEach(() => {}) 中进行的!
通过上面的分析,我们知道,在外层组件中,镶嵌名为 app-hero 的子组件,那么这个子组件是需要体现在 TestBed.configureTestingModule 中的, 如何做呢?
实际上,我们可以做一个假的子组件,如下所示:
@Component({
selector: 'app-hero',
template: '<div></div>',
})
class FakeHeroComponent {
@Input() hero: Hero;
// @Output()delete = new EventEmitter();
}
通过上述代码的配置,我们可以在测试文件中现场造一个假的标签为 app-hero 的子组件,用来替代真的组件。
除了 declarations 中的假的子组件,我们还在配置对象中使用别名的方式模拟了 HeroService, 注意到使用 TestBed.configureTestingModule 的配置模拟的方式是可以注入模拟服务的,也就是说,我们不必像上面一样在实例化组件的时候手动注入模拟的服务了,取而代之的是另外一种方式:TestBed.createComponent 如下所示:
import { HeroesComponent } from "./heroes.component";
import { Component, Input } from "@angular/core";
import { Hero } from "../hero";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing"
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture;
@Component({
selector: 'app-hero',
template: '<div></div>',
})
class FakeHeroComponent {
@Input() hero: Hero;
}
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, FakeHeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}]
});
fixture = TestBed.createComponent(HeroesComponent);
})
describe('delete', () => {
it('should be true', () => {
expect(true).toBe(true);
})
})
})
在 before Each 中,我们通过 TestBed 中的 createComponent 方法模拟了 HeroesComponent 这个组件,并将其保存在全局变量 fixture 中,而 fixture 本身的含义正是 固定装置。
12.2
你可能会想,我们为什么非要做一个假的子组件呢,既然现在可以模拟 module 中的配置行为了,我直接将真的子组件声明不就可以了吗?例如:
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}]
});
fixture = TestBed.createComponent(HeroesComponent);
})
describe('delete', () => {
it('should be true', () => {
expect(true).toBe(true);
})
})
})
这肯定是可以的!但是,有个问题就是,比起 mock 的子组件,如果要使用真正的组件,那么保不齐子组件中还会不会有孙子组件(最常见的情况就是子组件中的哪些和路由跳转相关的 directives 如 routerLink 等)?如果有的话那岂不是又报错了?
为了解决这种无休止的嵌套问题,我们可以在配置的时候设置遇到不认识的东西不要报错,即:
import { NO_ERRORS_SCHEMA } from "@angular/core";
...
...
schemas: [NO_ERRORS_SCHEMA],
...
这样做依然有风险,那就是如果编码中真的有错误,比如写了一个<btuton></btuton>这样的标签,测试过程中真的不会报错的。
12.3
fixture 的初始化。使用 TestBed 上面的 createComponent 创建新的组件之后,这个组件在测试环境中的行为和在正常开发环境中不同,具体表现在不会自动的调用组件的生命周期函数。也就是说你必须手动的调用 ngOnInit,实际上,我们不会去直接调用这个钩子函数的,取而代之的是如下的做法:fixture.detectChanges(); 此时整个测试文件的代码就如下所示了:
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
})
describe('delete', () => {
it('should be true', () => {
expect(true).toBe(true);
})
})
})
由于 HeroesComponent 组件在 ngOnInit 钩子中调用了 HeroService 上面的方法,所以我们必须使用下面的代码以防止模拟的服务类报错:
ngOnInit() {
this.getHeroes();
}
//
//
fixture = TestBed.createComponent(HeroesComponent);
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
13. 测试的时候不要使用 sourceMap
将我们的 script 修改成如下的样子:
"test": "ng test --source-map=false"
14. 对组件 component 模板的测试
- 在上面的内容中,我们通过
TestBed.configTestingModule配置了组件所需的测试环境。 - 为了解决模板中缺少实际内容而导致的错误,我们使用了
schemas来阻止错误的弹出。 - 尽管如此,我们还没有对组件的内部功能进行实质性的测试。
- 下一步,我们将展示如何修改组件的属性,并且验证这些属性的更改是否能够正确地反映在组件的视图上。
- 为了进行这项测试,我们需要找到组件在视图中对应的标签,并针对这些标签进行测试。
-
如何拿到创建的 component 的实例? 注意到我们的 fixture 的值为 TestBed.createComponent(HeroesComponent), 为了拿到创建的组件实例,我们可以对 fixture 进行类型约束:
let fixture: ComponentFixture<HeroesComponent>;其中:import { ComponentFixture } from "@angular/core/testing";这样一来键入 fixture 的时候,编译器会自动弹出名为componentInstance的属性。这就是我们所需要的实例。 -
如何修改实例上的属性? 修改实例属性通过两步走的方式:直接修改 + 更新,如下代码所示:
const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
fixture.componentInstance.heroes = heroes;
fixture.detectChanges(); // 改变组件值之后不要忘记执行此句进行更新
- 如何测试 component 上面的属性?直接测试就可以了,如下所示
expect(fixture.componentInstance.heroes[0].name).toEqual(heroes[0].name);
- 如何获取 fixture 的视图层中的标签对象? 我们使用 fixture 上除了 componentInstance 的另外一个属性 nativeElement 获取此 fixture 整个的视图层。nativeElement 可以看成是这个组件渲染完成之后的最外层的 container, 例如我们可以用它结合 query 类的方法获取 dom 树上的其它 tag:
fixture.nativeElement.querySelector('a')
总结一下,我们想要对 component 中的内容进行测试主要用到的是 fixture 上面的两个属性,一个是 componentInstance 表示的是 class 层,一个是 nativeElement 表示的是 template 层。
全部的测试代码为:
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
})
it('should have the correct hero', () => {
const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
fixture.componentInstance.heroes = heroes;
fixture.detectChanges(); // 改变组件值之后不要忘记执行此句进行更新
expect(fixture.componentInstance.heroes[0].name).toEqual(heroes[0].name);
})
it('should have the correct hero name in an anchor tag', () => {
const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
fixture.componentInstance.heroes = heroes;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('a').textContent).toContain(`${heroes[0].id} ${heroes[0].name}`);
})
})
被测试的组件为:
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button class="delete" (click)="onDeleteClick($event)">x</button>
补充一点:除了使用 nativeElement 我们还可以使用 debugElement, 下面的代码具有相同的功效:
import { By } from "@angular/platform-browser";
fixture.debugElement.query(By.css('a')).nativeElement.textContent;
fixture.nativeElement.querySelector('a').textContent;
这里使用 query(By.css('*')) 的组合,但是查询得到之后需要用一层 nativeElement.
it('should have the correct hero name in an anchor tag', () => {
const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
fixture.componentInstance.heroes = heroes;
fixture.detectChanges();
// expect(fixture.nativeElement.querySelector('a').textContent).toContain(`${heroes[0].id} ${heroes[0].name}`);
expect(fixture.debugElement.query(By.css('a')).nativeElement.textContent).toBe(`${heroes[0].id} ${heroes[0].name}`);
})
值得一说的是,debugElement 是可以指回到 componentInstance 的:
console.log('are they equal?', fixture.debugElement.componentInstance === fixture.componentInstance); // true
15. 测试 component 的时候 mock 服务
让我们先看一下 component 的内容:
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css'],
})
export class HeroesComponent implements OnInit {
heroes: Hero[];
constructor(private heroService: HeroService) {}
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes));
}
add(name: string): void {
name = name.trim();
var strength = 11;
if (!name) {
return;
}
this.heroService.addHero({ name, strength } as Hero).subscribe((hero) => {
this.heroes.push(hero);
});
}
delete(hero: Hero): void {
this.heroes = this.heroes.filter((h) => h !== hero);
this.heroService.deleteHero(hero).subscribe();
}
}
不难看出来,在 HeroesComponent 组件的构造函数中,需要注入名为 HeroService 的服务。之前没有提供这个服务的注入也没有报错是因为我们使用了 schemas 对错误进行了压制。下面我们就 mock 这个 HeroService 服务,完成更加真实的测试环境。这里提前说一下,虽然我们对 Hero Service 进行 mock 但是 schemas 还是不能移除,原因很简单,那就是还需要用其完成对其它错误进行压制。
我们观察到 component 中使用了 HeroService 中的三个方法打,分别为:getHeroes addHero deleteHero. 所以 mock 这个服务本质上就是 mock 一个拥有这三个方法的普通的 JavaScript 对象。
模拟普通对象我们使用的是 jasmine 这个内置对象给的 createSpyObj 方法,如下代码所示:
// 创建
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']); // 这样就模拟了三个方法
// 配置
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);只能保证 mockHeroService 中有这三个方法,但是却没有指定这些方法的返回值,所以我们需要使用mockHeroService.getHeroes.and.returnValue(of(HEROES));这样的方式指定 mock 方法的返回值是什么。 完整的测试代码如下所示:
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
})
it('should set heroes correctly from the service', () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
expect(fixture.componentInstance.heroes.length).toBe(3);
})
it('should create one li for each hero', () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(3);
})
})
让我们来简单总结一下:
TestBed-- 这个是测试component的基础Fixtures-- 子组件DebugElements-- 除了nativeElement之外测试视图层的另外一个对象Child directives-- 子组件上的指令也需要模拟,如果能够模拟那么我们就无需在使用schemas来压制错误了。而这对应的则是Deep integration tests.
16. 使用 By.directive 找到子组件
站在 Angular 的测试角度,子组件可以看成是父组件的一种 directive, 因此我们可以使用 By.directive 方法获取子组件。这可能看上去有些奇怪,但是事实就是这样的。
it('should render each hero as a HeroComponent', () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponentDEs = fixture.debugElement.queryAll(By.directive(HeroComponent));
expect(heroComponentDEs.length).toEqual(3);
expect((heroComponentDEs[0].componentInstance as HeroComponent).hero.name).toBe('SpiderDude');
})
注意,上述的测试和查询 li 元素的个数这种做法还不一样。上述代码中,我们断言的是:子组件 HeroComponent 渲染了三次,因为 HEROES 的长度为 3
for 循环和断言 -- loop and assertion
如下代码所示,对于列表的测试我们可以将 for 循环和断言结合起来:
it('should render each hero as a HeroComponent', () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponentDEs = fixture.debugElement.queryAll(By.directive(HeroComponent));
expect(heroComponentDEs.length).toEqual(3);
for (let i = 0; i < HEROES.length; i++) {
expect((<HeroComponent>heroComponentDEs[i].componentInstance).hero.name).toBe(HEROES[i].name);
}
})
17. 测试网络通信服务 -- Service integration tests
现在将注意力转到子组件 HeroComponent 中,使用 touch src/app/hero.service.spec.ts 创建测试文件。
先观察测试对象 src/app/hero.service.ts 中的内容:
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { Hero } from './hero';
import { MessageService } from './message.service';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};
@Injectable()
export class HeroService {
private heroesUrl = 'api/heroes'; // URL to web api
constructor(
private http: HttpClient,
private messageService: MessageService) { }
/** GET heroes from the server */
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(heroes => this.log(`fetched heroes`)),
catchError(this.handleError('getHeroes', []))
);
}
/** GET hero by id. Return `undefined` when id not found */
getHeroNo404<Data>(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/?id=${id}`;
return this.http.get<Hero[]>(url)
.pipe(
map(heroes => heroes[0]), // returns a {0|1} element array
tap(h => {
const outcome = h ? `fetched` : `did not find`;
this.log(`${outcome} hero id=${id}`);
}),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/** GET hero by id. Will 404 if id not found */
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get<Hero>(url).pipe(
tap(_ => this.log(`fetched hero id=${id}`)),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
// if not search term, return empty hero array.
return of([]);
}
return this.http.get<Hero[]>(`api/heroes/?name=${term}`).pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
//////// Save methods //////////
/** POST: add a new hero to the server */
addHero (hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
/** DELETE: delete the hero from the server */
deleteHero (hero: Hero | number): Observable<Hero> {
const id = typeof hero === 'number' ? hero : hero.id;
const url = `${this.heroesUrl}/${id}`;
return this.http.delete<Hero>(url, httpOptions).pipe(
tap(_ => this.log(`deleted hero id=${id}`)),
catchError(this.handleError<Hero>('deleteHero'))
);
}
/** PUT: update the hero on the server */
updateHero (hero: Hero): Observable<any> {
return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
tap(_ => this.log(`updated hero id=${hero.id}`)),
catchError(this.handleError<any>('updateHero'))
);
}
/**
* Handle Http operation that failed.
* Let the app continue.
* @param operation - name of the operation that failed
* @param result - optional value to return as the observable result
*/
private handleError<T> (operation = 'operation', result?: T) {
return (error: any): Observable<T> => {
// TODO: send the error to remote logging infrastructure
console.error(error); // log to console instead
// TODO: better job of transforming error for user consumption
this.log(`${operation} failed: ${error.message}`);
// Let the app keep running by returning an empty result.
return of(result as T);
};
}
/** Log a HeroService message with the MessageService */
private log(message: string) {
this.messageService.add('HeroService: ' + message);
}
}
这个自定义的服务的构造函数为:
constructor(
private http: HttpClient,
private messageService: MessageService) { }
为了测试它,我们必须对 HttpClient 和 MessageService 进行 mock. 对于这两个,我们采用分而治之的策略,对于 HttpClient 我们使用 Angular 框架中提供的现成的服务;对于 后者我们使用 jasmine 中的 createSpyObj 进行模拟。
根据上面的分析我们可以得到一个基本的测试内容:
import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
describe('HeroService', () => {
let mockMessageService;
let service;
let httpTestingController: HttpTestingController;
beforeEach(() => {
mockMessageService = jasmine.createSpyObj(['add']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
HeroService,
{ provide: MessageService, useValue: mockMessageService },
]
})
// 使用 TestBed.get 方法获取 HttpTestingController 和 HeroService 的实例。
httpTestingController = TestBed.get(HttpTestingController);
service = TestBed.get(HeroService);
})
describe('getHero', () => {
it('should call get with the correct URL', () => {
service.getHero(4).subscribe(); // service.getHero(4).subscribe() 发起一个 HTTP GET 请求,模拟获取 ID 为 4 的英雄。
const req = httpTestingController.expectOne('api/heroes/4'); // 使用 httpTestingController.expectOne('api/heroes/4') 捕获发出的请求,并验证请求的 URL 是否正确。
req.flush({ id: 4, name: 'SuperDude', strength: 100 }); // 使用 req.flush 方法模拟服务器响应,返回一个英雄对象。
httpTestingController.verify(); // 使用 httpTestingController.verify 方法验证没有未处理的请求,确保所有预期的请求都已经被处理。
});
})
})
上面的代码是一个 Angular 服务的单元测试示例,具体来说是针对 HeroService 的测试。下面是对每一行代码的详细解释:
- 导入依赖
import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
TestBed是 Angular 测试的核心工具,用于配置和初始化测试环境。HeroService是我们要测试的服务。MessageService是HeroService可能依赖的另一个服务,这里我们使用 Jasmine 的createSpyObj方法来创建一个它的模拟对象。HttpClientTestingModule和HttpTestingController用于模拟 HTTP 请求和响应。
- 测试配置
describe('HeroService', () => {
let mockMessageService;
let service;
let httpTestingController: HttpTestingController;
- 使用
describe函数定义一个测试套件,用于测试HeroService。 mockMessageService用于存储MessageService的模拟对象。service用于存储HeroService的实例。httpTestingController用于模拟 HTTP 请求和响应。
- 初始化测试环境
beforeEach(() => {
mockMessageService = jasmine.createSpyObj(['add']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
HeroService,
{ provide: MessageService, useValue: mockMessageService },
]
})
httpTestingController = TestBed.get(HttpTestingController);
service = TestBed.get(HeroService);
})
- 在每个测试之前,使用
beforeEach函数初始化测试环境。 - 使用 Jasmine 的
createSpyObj方法创建一个MessageService的模拟对象,并指定要模拟的方法(这里是add方法)。 - 使用
TestBed.configureTestingModule方法配置测试模块,导入HttpClientTestingModule以模拟 HTTP 请求,并提供HeroService和模拟的MessageService。 - 使用
TestBed.get方法获取HttpTestingController和HeroService的实例。
- 定义测试
describe('getHero', () => {
it('should call get with the correct URL', () => {
service.getHero(4).subscribe();
const req = httpTestingController.expectOne('api/heroes/4');
req.flush({ id: 4, name: 'SuperDude', strength: 100 });
httpTestingController.verify();
});
})
- 使用
describe函数定义一个子测试套件,用于测试HeroService的getHero方法。 - 使用
it函数定义一个测试用例,描述测试的预期行为。 - 调用
service.getHero(4).subscribe()发起一个 HTTP GET 请求,模拟获取 ID 为 4 的英雄。 - 使用
httpTestingController.expectOne('api/heroes/4')捕获发出的请求,并验证请求的 URL 是否正确。 - 使用
req.flush方法模拟服务器响应,返回一个英雄对象。 - 使用
httpTestingController.verify方法验证没有未处理的请求,确保所有预期的请求都已经被处理。
18. 测试 component template 的点击事件
使用 touch src/app/heroes/heroes.component.deep.spec.ts 创建新的测试文件。
然后观察 HeroComponent 上面的点击事件的回调函数:
// @ts-nocheck
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { Hero } from "../hero";
@Component({
selector: "app-hero",
templateUrl: "./hero.component.html",
styleUrls: ["./hero.component.css"],
})
export class HeroComponent {
@Input() hero: Hero;
@Output() delete = new EventEmitter();
onDeleteClick($event): void {
$event.stopPropagation();
this.delete.next();
}
}
这里着重强调一下 $event.stopPropagation();
最后搭建测试的基本框架:
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
})
it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
})
})
接下来看我们如何触发 HeroComponent template 上面的点击事件:
it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy
heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
// expect(fixture.componentInstance.delete).toHaveBeenCalled();
expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})
测试原理为:找到子组件上面的 button 按钮然后使用 triggerEventHandler 的方式触发 click 事件,此时子组件的 onDeleteClick 中会调用
this.delete.next();通过<app-hero [hero]="hero" (delete)="delete(hero)"></app-hero>调用 HeroesComponent 上面的 delete 方法。于是我们只需要检查 HeroesComponent 上面的 delete 方法有没有被调用以及调用的时候的入参是什么就可以了。注意点 1:
triggerEventHandler('click', { stopPropagation: () => { } });传递的第二个参数实际上是在模拟自动生成的事件对象event$因为onDeleteClick($event)需要此形参。注意点2:我们需要
spyOn(fixture.componentInstance, 'delete');将 delete 方法转换为 spy 类型的,这样后面的 expect 才会生效,倘若没有这一步,则会报错:Error: <toHaveBeenCalledWith> : Expected a spy, but got Function.
上面是通过 triggerEventHandler 在视图层发起点击事件,我们现在切换到 class 层来触发点击事件。
it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy
// heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
(<HeroComponent>heroComponents[0].componentInstance).delete.emit(undefined);
(<HeroComponent>heroComponents[0].componentInstance).delete.next(undefined);
// expect(fixture.componentInstance.delete).toHaveBeenCalled();
expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})
不论用的是 emit 还是 next 都可以成功的通过测试。那么这样做有什么意义呢?通过 view 或者 class 触发 click 事件的不同之处或者说后者有什么优势呢?
后者的优势在于你不用 mock 并传递一个
event$了。
除了上面的做法,你还可以直接使用子组件的实例触发 click 的回调函数链:
heroComponents[0].triggerEventHandler('delete', null);
现在 deep.spec.ts 的内容为:
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
})
it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy
// heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
// (<HeroComponent>heroComponents[0].componentInstance).delete.emit(undefined);
// (<HeroComponent>heroComponents[0].componentInstance).delete.next(undefined);
heroComponents[0].triggerEventHandler('delete', null);
// expect(fixture.componentInstance.delete).toHaveBeenCalled();
expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})
})
19. 测试 component template 点击事件触发的网络数据通信结果
在本小节中,我们的测试流程为:点击添加按钮,然后会在页面上多出来一个 HeroComponent 子组件,并且其中的内容满足添加的时候传递的参数。我们仍然在 heroes.component.deep.spec.ts 中完成测试:
it('should add a new hero to the hero list when the add button is clicked', () => {
// 模拟 heroService 服务的 getHeroes 方法的返回值
mockHeroService.getHeroes.and.returnValue(of(HEROES));
let originLen = HEROES.length;
// 初始化组件
fixture.detectChanges();
const name = "Mr. Ice";
// 模拟 heroService 服务的 addHero 方法的返回值
mockHeroService.addHero.and.returnValue(of({ id: 5, name, strength: 4 }));
// 找到添加按钮和输入框,在输入框中填入值之后点击添加按钮
const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
const addButton = fixture.debugElement.queryAll(By.css('button'))[0];
inputElement.value = name;
addButton.triggerEventHandler('click', null);
// 添加之后强制刷新此组件
fixture.detectChanges();
// 检查视图,进行断言
const heroText = fixture.debugElement.query(By.css('ul')).nativeElement.textContent;
expect(heroText).toContain(name);
// 通过子组件实例化的个数进行判断
const len = fixture.debugElement.queryAll(By.directive(HeroComponent)).length;
expect(len).toBe(++originLen); // 这里不能用 HEROES.length + 1 因为 HEROES 已经变化了
})
上面的代码就是测试此流程的测试用例。
补充一个测试原则:永远不要测试所使用的框架,并一直假定其没有任何缺陷,只测试自己写的代码!
20. 对路由进行测试
之前的内容中,我们总是使用 schemas 对路由产生的错误进行压制,也就是遇到了注入 routerLink 这类的指令,就算不认识页不会报错。在本节中,着重解决此问题,看看我们是如何在不使用 schemas 的前提之下 mock 相关的路由指令及相关内容的。
本节分成两个部分,第一个部分解决服务 ActivatedRoute, 因为只有这个服务被注入到 component 中的时候服务中才可以使用其实例获取路由上携带的数据:const id = +this.route.snapshot.paramMap.get('id');
第二个部分解决和路由跳转相关的指令,如 routerLink 等。
关于 ActivatedRoute 的 mock
首先,我们使用 touch src/app/hero-detail/hero-detail.component.spec.ts 创建测试文件。
然后这之前完全相同,我们总是先观察我们要测试的对象,在这里,组件 HeroDetailComponent 的内容为:
import { Component, OnInit, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent implements OnInit {
@Input() hero: Hero;
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
private location: Location
) {}
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
}
goBack(): void {
this.location.back();
}
save(): void {
this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
}
}
不难看出来,在此组件初始化的时候需要注入三个对象,它们是 ActivatedRoute HeroService Location. 我们本节测试的对象是 ActivatedRoute, 对于其它的两个服务实例我们通过 jasmine 的 createSpyObj 方法进行模拟即可。更加具体一些,HeroService 上面需要模拟的方法有 getHero 和 updateHero, 而 Location 服务中用到的方法则为 back.
根据上述信息,我们的测试代码初步可以写成如下的形式:
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
describe('test on detail hero component', () => {
let fixture: ComponentFixture<HeroDetailComponent>;
let mockActivatedRoute, mockHeroService, mockLocation;
beforeEach(()=>{
mockActivatedRoute = {
snapShot: {
paramMap: {
get: () => '3'
}
}
};
mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);
mockLocation = jasmine.createSpyObj(['back']);
TestBed.configureTestingModule({
declarations: [HeroDetailComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: HeroService, useValue: mockHeroService},
{provide: Location, useValue: mockLocation},
]
})
fixture = TestBed.createComponent(HeroDetailComponent);
})
})
在 HeroDetailComponent 组件的构造函数中就已经调用了 heroService 上面的 getHero 方法。
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
}
因此,我们必须在调用 fixture.detectChanges(); 的前面规定这个方法的返回值。因此,将上面的代码补充为:
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
describe('test on detail hero component', () => {
let fixture: ComponentFixture<HeroDetailComponent>;
let mockActivatedRoute, mockHeroService, mockLocation;
beforeEach(() => {
mockActivatedRoute = {
snapshot: {
paramMap: {
get: () => '3'
}
}
};
mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);
mockLocation = jasmine.createSpyObj(['back']);
TestBed.configureTestingModule({
declarations: [HeroDetailComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: HeroService, useValue: mockHeroService },
{ provide: Location, useValue: mockLocation },
]
})
fixture = TestBed.createComponent(HeroDetailComponent);
mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));
})
it('test before formal testing', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h2').textContent).toContain('SUPERDUDE');
})
})
然后测试没有通过,原因很简单,在于 template 上面,见下面的 html 文件内容:
<div *ngIf="hero">
<h2>{{ hero.name | uppercase }} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label>name:
<input [(ngModel)]="hero.name" placeholder="name"/>
</label>
</div>
<button (click)="goBack()">go back</button>
<button (click)="save()">save</button>
</div>
由于我们去掉了 schemas 所以,当测试框架不认识 <input [(ngModel)]="hero.name" placeholder="name"/> 中的 ngModule 相关的指令的时候就开始报错了!
对于 Form 相关的指令的处理,我们采用的策略是直接引入,也就是像下面一样。
import { FormsModule } from "@angular/forms";
...
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [HeroDetailComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: HeroService, useValue: mockHeroService },
{ provide: Location, useValue: mockLocation },
]
})
...
整体代码为:
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
import { FormsModule } from "@angular/forms";
describe('test on detail hero component', () => {
let fixture: ComponentFixture<HeroDetailComponent>;
let mockActivatedRoute, mockHeroService, mockLocation;
beforeEach(() => {
mockActivatedRoute = {
snapshot: {
paramMap: {
get: () => '3'
}
}
};
mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);
mockLocation = jasmine.createSpyObj(['back']);
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [HeroDetailComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: HeroService, useValue: mockHeroService },
{ provide: Location, useValue: mockLocation },
]
})
fixture = TestBed.createComponent(HeroDetailComponent);
mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));
})
it('test before formal testing', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h2').textContent).toContain('SUPERDUDE');
})
})
小结一下,我们对 ActivatedRouter 的模拟既不会用到 jasmine.createSpyObj 也没有其它备用的方法,而是直接使用一个实现了特殊接口的 plainObject 就可以了:
mockActivatedRoute = {
snapshot: {
paramMap: {
get: () => '3'
}
}
};
注意这里的 snapshot 的第二个 s 不是大写的!
关于路由相关指令的 mock
我们使用覆盖的方式自造路由指令。首先回到 heroes.component.deep.spec.ts 中,然后增加测试代码,如下所示:
import { RouterLinkDirectiveStub } from './routerLinkDirectiveStub';
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [
HeroesComponent,
HeroComponent,
RouterLinkDirectiveStub,
],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
// schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
})
it('should have the correct route for the first hero', () => {
// 模拟HeroService的getHeroes方法,使其返回一个Observable对象,该对象包含HEROES数组。
mockHeroService.getHeroes.and.returnValue(of(HEROES));
// 触发Angular的变更检测机制,以便更新视图。
fixture.detectChanges();
// 查询所有的HeroComponent实例。
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
// 获取第一个HeroComponent实例中的RouterLinkDirectiveStub实例。
// 这是通过查询RouterLinkDirectiveStub指令,并从其注入器中获取实例来实现的。
let routerLink = heroComponents[0].query(By.directive(RouterLinkDirectiveStub)).injector.get(RouterLinkDirectiveStub);
// 模拟点击第一个HeroComponent中的链接(<a>标签)。
heroComponents[0].query(By.css('a')).triggerEventHandler('click', null);
// 断言:期望RouterLinkDirectiveStub的navigatedTo属性值为'/detail/1',
// 这表示当点击第一个英雄时,应该导航到'/detail/1'这个路由。
expect(routerLink.navigatedTo).toBe('/detail/1');
})
})
上面的代码中,我们关闭了作弊器 schemas: [NO_ERRORS_SCHEMA], 然后在 declarations 中添加了自建指令 RouterLinkDirectiveStub 其中 RouterLinkDirectiveStub 的代码如下所示:
import { Directive, Input } from "@angular/core";
@Directive({
selector: '[routerLink]',
host: { '(click)': 'onClick()' }
})
export class RouterLinkDirectiveStub {
@Input('routerLink') linkParams: any;
navigatedTo: any = null;
onClick() {
this.navigatedTo = this.linkParams;
}
}
这个指令的作用为在绑定的元素被点击的时候将 routerLink 绑定的值存储在内部属性 navigatedTo 中。
最为关键的是下面的这行代码:
let routerLink = heroComponents[0].query(By.directive(RouterLinkDirectiveStub)).injector.get(RouterLinkDirectiveStub);
这句代码很长,可将其分成两个部分来看,其中前半部分 heroComponents[0].query(By.directive(RouterLinkDirectiveStub)) 的含义在于通过 By.directive 方法找到子组件中的 a 标签,因为只有 a 标签上带了名为 routerLink 的指令:
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button class="delete" (click)="onDeleteClick($event)">x</button>
后半部分是找到 a 标签对象之后从其属性 .injector 上面获取所有的指令集合,然后使用 .get() 方法从这个集合中获取某个特定的指令,在这里我们获取的就是 RouterLinkDirectiveStub. 这里注意一下,我们通过 RouterLinkDirectiveStub 指令找到标签和最终通过 RouterLinkDirectiveStub 找到想要的指令是两码事。
最后我们在 view 层找到 a 标签并发起点击事件,这个时候发生了跳转,我们断言的结论是:routerLink 中存储的路由和我们期待的路由地址相同!
现在的代码写成:
import { RouterLinkDirectiveStub } from './routerLinkDirectiveStub';
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [
HeroesComponent,
HeroComponent,
RouterLinkDirectiveStub,
],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
// schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
})
it('should have the correct route for the first hero', () => {
let _ = 2;
// 模拟HeroService的getHeroes方法,使其返回一个Observable对象,该对象包含HEROES数组。
mockHeroService.getHeroes.and.returnValue(of(HEROES));
// 触发Angular的变更检测机制,以便更新视图。
fixture.detectChanges();
// 查询所有的HeroComponent实例。
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
// 获取第一个HeroComponent实例中的RouterLinkDirectiveStub实例。
// 这是通过查询RouterLinkDirectiveStub指令,并从其注入器中获取实例来实现的。
let routerLink = heroComponents[_].query(By.directive(RouterLinkDirectiveStub)).injector.get(RouterLinkDirectiveStub);
// 模拟点击第一个HeroComponent中的链接(<a>标签)。
heroComponents[_].query(By.css('a')).triggerEventHandler('click', null);
// 断言:期望RouterLinkDirectiveStub的navigatedTo属性值为'/detail/1',
// 这表示当点击第一个英雄时,应该导航到'/detail/1'这个路由。
expect(routerLink.navigatedTo).toBe(`/detail/${HEROES[_].id}`);
})
})
21. 对异步代码进行测试
在这个小节中介绍大概三种方法完成异步代码的测试,让我们回到 hero-detail 中,将 component 的代码修改成下面的样子:
// @ts-nocheck
import { Component, OnInit, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent implements OnInit {
@Input() hero: Hero;
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
private location: Location
) { }
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
}
goBack(): void {
this.location.back();
}
// save(): void {
// var p = new Promise((resolve) => {
// this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
// resolve();
// });
// }
save(): void {
debounce(() => {
this.heroService.updateHero(this.hero)
.subscribe(() => this.goBack());
}, 250, false)();
}
}
function debounce(func, wait, immediate) {
var timeout;
return function () {
var context = this,
args = arguments;
var later = function () {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
修改之后的代码对于其上面的保存方法加上了节流,加上节流之后,save 方法就变成了异步执行的代码,那么对于现在的 save 又该如何测试呢?
1. 使用 done
虽然标题是 使用 done,但是 done 的作用仅仅是为了将测试流程进行下去,和我们的测试异步代码的技巧没有关系。
我们的技巧其实是,节流的间隔是 250ms 那我就在这里等 250ms 之后再调用 expect 进行断言不就不用担心在 测试的同步代码执行完毕之后 expect 断言还没有执行的问题了吗。使用 done 只是告诉测试框架你可以完成了。
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
import { FormsModule } from "@angular/forms";
describe('test on detail hero component', () => {
let fixture: ComponentFixture<HeroDetailComponent>;
let mockActivatedRoute, mockHeroService, mockLocation;
beforeEach(() => {
mockActivatedRoute = {
snapshot: {
paramMap: {
get: () => '3'
}
}
};
mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);
mockLocation = jasmine.createSpyObj(['back']);
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [HeroDetailComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: HeroService, useValue: mockHeroService },
{ provide: Location, useValue: mockLocation },
]
})
fixture = TestBed.createComponent(HeroDetailComponent);
mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));
})
it('test before formal testing', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h2').textContent).toContain('SUPERDUDE');
})
it('should call updateHero when save is called', (done) => {
mockHeroService.updateHero.and.returnValue(of({}));
fixture.detectChanges();
fixture.componentInstance.save();
setTimeout(() => {
expect(mockHeroService.updateHero).toHaveBeenCalled();
done();
}, 300)
})
})
这里注意一下,如果去掉 done 那么测试程序就会一直跑下去,不会停止,也得不到什么结果。
2. 使用 fakeAsync
上面的 setTimeout 方法有什么问题吗?最大的问题就是它会让我们的测试过程异常的耗时,因为它是真等呀!
因此在 @angular/core/testing 中提供了名为 fakeAsync 的方法,使用这个方法也可以完成对异步代码的测试。如下所示:
// import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
it('should call updateHero when save is called', fakeAsync(() => {
mockHeroService.updateHero.and.returnValue(of({}));
fixture.detectChanges();
fixture.componentInstance.save();
tick(250);
expect(mockHeroService.updateHero).toHaveBeenCalled();
}))
这里的 tick(250) 就相当于在这里等了 250ms.
3. 使用 fixture.whenStable
上面的两个方法都必须明确的知道需要等待多久,假如我不知道要等多久呢,250ms 还是 30ms. 那么这个时候就可以使用 fixture 上面的 whenStable 方法了。单从这个名字就可以看出来,里面的回调会等到对应的组件稳定下来才执行,所谓稳定下来指的就是所有的宏任务和微任务走完,因此这种做法也可以用来测试异步代码。
it('should call updateHero when save is called', () => {
mockHeroService.updateHero.and.returnValue(of({}));
fixture.detectChanges();
fixture.componentInstance.save();
fixture.whenStable().then(() => {
expect(mockHeroService.updateHero).toHaveBeenCalled();
})
})
我们尝试将 save 改成 Promise 类型的,
save(): void {
var p = new Promise((resolve) => {
this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
resolve();
});
}
测试也是可以通过的!
小结一下:本节介绍了三种异步代码测试的方法。
22. 代码测试覆盖率
在 package.json 中增加如下的脚本:
"gencode": "ng test --code-coverage"
执行之后,会在控制台打印出简短的覆盖率信息:
以及详细的覆盖率说明文件:
最后
我们应该怎样选择合适的测试类型?
默认情况下我们使用的是
isolated测试类型;只有在必要的时候才使用
integration类型,并且尽可能的使用 shallow 而不是 deep;只有在极少数情况下才会使用 deep integration test.
实践与练习
最后的最后,让我们将本文学到的东西总结一下,粗略的回顾一下,也是收获满满呢!
1. 学会如何测试管道
分两步骤:先实例化再测试此实例 transform 方法的返回值。
import { StrengthPipe } from './strength.pipe';
describe('StrengthPipe', () => {
it('should display waek if strength is 5', () => {
let pipe = new StrengthPipe();
expect(pipe.transform(5)).toEqual('5 (week)');
})
})
2. 学会如何测试一个服务
测试一个服务的过程甚至比测试一个管道还要简单。测试管道的时候测试的是固定接口(transform), 而测试服务的时候测试的是其上的各种方法。这并不难理解,因为管道实现了特定的接口。
import { MessageService } from "./message.service";
describe('MessageService', () => {
let service: MessageService;
beforeEach(() => {
service = new MessageService();
})
it('should have no messages to start', () => {
expect(service.messages.length).toBe(0);
})
it('should add a message when add is called', () => {
service.add('message 1');
expect(service.messages.length).toBe(1);
})
})
3. 学会如何 mock 一个 service
注意,这里不是要测试某个服务,而是在测试以 component 为代表的其它部分的时候需要对 service 进行 mock; mock service 的理由也很充分:它不是重点;真实的服务可能是耗时的;此服务暂时还没有被开发出来,等。
我们使用 jasmine 上面的 createSpyObj mock 服务实例(而不是类),如下所示:
describe('HeroComponents', () => {
let mockHeroService;
beforeEach(() => {
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
})
})
方法名中的 Spy 有说法的,如果你 new 一个真的 Service 实例,还需要额外对某个方法进行处理:
spyOn(serviceInstance, 'delete');
4. 学会如何粗糙的测试一个 component
所谓粗糙的测试一个组件,指的是使用 new 一个实例的方法创建此组件实例,同时注入其所需要的构造函数,这并不是测试推荐的方法。
import { HeroesComponent } from "./heroes.component";
describe('HeroComponents', () => {
let component: HeroesComponent;
let HEROES;
let mockHeroService;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
component = new HeroesComponent(mockHeroService);
})
describe('delete', () => {
it('should remove the indicated hero from the heroes list', () => {
component.heroes = HEROES;
component.delete(HEROES[2]);
expect(component.heroes.length).toBe(2);
})
})
})
我们把 component 当成一个普通 class 来测试,这固然能够说明一些问题,但是 component 的视图层完全不会被测试,也就是此 component 在页面的表现完全不可控。
5. 学会指定 mock 服务中方法的返回值
我们使用 mockHeroService.deleteHero.and.returnValue(of(true)) 的方式指定 mock 的 object 中的方法的返回值,需要注意的是,在每个 it 块中只能指定一次。
6. xit 以及常见的断言
如果你将 it 块改成 xit 块,那么此部分测试会出现在测试报告中,但是会被标记为未测。
7. 进行浅的集成测试的时候如何避免报错
我们在测试 component 中使用到了 TestBed 上面的,名为 configureTestingModule 的方法,并在其配置项中,使用键值对 schemas: [NO_ERRORS_SCHEMA], 抑制此组件测试过程中由于编译器不认识某些语法导致的错误。常用于快速测试过程中,但是这样做是有风险的,因为它可能让你忽略真正的错误。
import { TestBed } from "@angular/core/testing";
TestBed.configureTestingModule({
declarations: [HeroesComponent, HeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
schemas: [NO_ERRORS_SCHEMA],
});
8. 深度集成测试的时候如何配置 module 的编译环境以及更好的创建组件实例的方法
真正的测试中,不会使用 new 一个 component 的方式获取组件实例。取而代之的方法是我们使用 TestBed 上面提供的名为 createComponent 的方法去创建待测组件实例。此方法的返回值我们一般使用名为 fixture 的变量储存,如下所示:
let HEROES;
let mockHeroService;
let fixture: ComponentFixture<HeroesComponent>;
beforeEach(() => {
HEROES = [
{ id: 1, name: 'SpiderDude', strength: 8 },
{ id: 2, name: 'Wonderful Woman', strength: 24 },
{ id: 3, name: 'SuperDude', strength: 55 },
];
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
TestBed.configureTestingModule({
declarations: [
HeroesComponent,
HeroComponent,
RouterLinkDirectiveStub,
],
providers: [{
provide: HeroService, useValue: mockHeroService,
}],
// schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(HeroesComponent);
})
fixture 这个单词的含义就是 固定装置,私以为比 handler 的语义好一些。注意我们在写 fixture = TestBed.createComponent(HeroesComponent); 这句代码的时候,并没有向 HeroesComponent 传递其所需要的构造函数,而是将 mockHeroService 放在了 providers 数组中。
9. 如何创建一个假的子组件
如果我们决定不用 schemas: [NO_ERROR_SCHEMA] 抑制找不到子组件对应的 selector 产生的报错,转而 mock 一个子组件的话,我们完全可以在 describe 块内部,任何 it 块外部,使用 @Component 生成一个同 selector 的子组件。
@Component({
selector: 'app-hero',
template: '<div></div>',
})
class FakeHeroComponent {
@Input() hero: Hero;
// @Output()delete = new EventEmitter();
}
以及:
import { HeroesComponent } from "./heroes.component";
import { Component, Input } from "@angular/core";
import { Hero } from "../hero";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing"
describe('HeroComponents', () => {
let HEROES;
let mockHeroService;
let fixture;
@Component({
selector: 'app-hero',
template: '<div></div>',
})
class FakeHeroComponent {
@Input() hero: Hero;
}
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [HeroesComponent, FakeHeroComponent],
providers: [{
provide: HeroService, useValue: mockHeroService,
}]
});
})
describe('delete', () => {
})
})
这样做的话父组件的 template 在被渲染的时候就不会因为解析不了 <app-hero></app-hero> 而报错了。
10. 创建完组件实例之后如何初始化
使用 TestBed.createComponent(*) 的方式创建组件实例之后,组件的生命周期函数 ngOnInit 是不会被执行的。因此我们必须手动触发,但不是直接调用同名函数,而是使用下面的方式间接调用:
fixture.detectChanges();
11. 如何创建组件的 fixture 以及如何通过 fixture 找到组件实例和视图层最外层 dom
使用 TestBed.createComponent(SomeComponent) 创建 fixture, 然后使用其上面的 componentInstance 属性表示组件实例,使用其上的 debugElement 属性表示最外层dom
12. debugElement 和 By.* 的联合使用
如下所示:
fixture.debugElement.queryAll(By.directive(HeroComponent));
13. 使用 By.directive 方法找到子组件,以及原理
代码如上所示,其原理比较奇特:在测试环境中,父组件可以将其 template 中的子组件视为 directive, 正是因为此,所以可以使用 By.directive(ChildComponent) 的方式找到子组件。
14. 循环和断言的联合使用
如下所示,这种适合检测列表的渲染是否正确:
it('should render each hero as a HeroComponent', () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponentDEs = fixture.debugElement.queryAll(By.directive(HeroComponent));
expect(heroComponentDEs.length).toEqual(3);
for (let i = 0; i < HEROES.length; i++) {
expect((<HeroComponent>heroComponentDEs[i].componentInstance).hero.name).toBe(HEROES[i].name);
}
})
15. 测试网络通信服务类
测试网络通信服务的时候,其核心在于对 HTTPClient 的模拟。
我们使用 HttpClientTestingModule 和 HttpTestingController 来 mock 网络通信相关过程。基本过程分为:
- 引入网络 mock 模块
- 声明被测 service 实例和 controller
- 注入 HttpClientTestingModule
- 得到 controller 实例和被测服务实例
- 待测实例发起网络请求
- 使用 controller 实例结合断言进行测试
import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; // step 1
describe('HeroService', () => {
let mockMessageService;
let service; // step 2
let httpTestingController: HttpTestingController; // step 2
beforeEach(() => {
mockMessageService = jasmine.createSpyObj(['add']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // step 3
providers: [
HeroService,
{ provide: MessageService, useValue: mockMessageService },
]
})
// 使用 TestBed.get 方法获取 HttpTestingController 和 HeroService 的实例。
httpTestingController = TestBed.get(HttpTestingController); // step 4
service = TestBed.get(HeroService); // step 4
})
describe('getHero', () => { // step 5, 6
it('should call get with the correct URL', () => {
service.getHero(4).subscribe(); // service.getHero(4).subscribe() 发起一个 HTTP GET 请求,模拟获取 ID 为 4 的英雄。
const req = httpTestingController.expectOne('api/heroes/4'); // 使用 httpTestingController.expectOne('api/heroes/4') 捕获发出的请求,并验证请求的 URL 是否正确。
req.flush({ id: 4, name: 'SuperDude', strength: 100 }); // 使用 req.flush 方法模拟服务器响应,返回一个英雄对象。
httpTestingController.verify(); // 使用 httpTestingController.verify 方法验证没有未处理的请求,确保所有预期的请求都已经被处理。
});
})
})
16. 使用 TestBed.get 获取对应的实例
// 使用 TestBed.get 方法获取 HttpTestingController 和 HeroService 的实例。
httpTestingController = TestBed.get(HttpTestingController);
service = TestBed.get(HeroService);
17. 从 view 或者 class 层触发点击事件并检测点击事件的回调有没有执行
由于 fixture 可以提供 class 层和 view 层的顶层对象;因此不论是从 view 层发起的点击事件还是从 class 层发起的点击事件,其源头都是 fixture. 不同之处在于使用的 api 不同罢了。
1. 从 view 层发起事件
走的路线为:debugElement -> query -> triggerEventHandler
2. 从 class 层发起事件
相当于是简单的示例上面的方法的调用。
it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();
const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy
// heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
(<HeroComponent>heroComponents[0].componentInstance).delete.emit(undefined);
(<HeroComponent>heroComponents[0].componentInstance).delete.next(undefined);
// expect(fixture.componentInstance.delete).toHaveBeenCalled();
expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})
18. 对 ActivatedRoute 服务的 mock
相对简单,分下面几个步骤完成即可:
- 从 @angular/router 中引入真正的 ActivatedRoute 作为类型约束。
- 定义 mockActivatedRoute 全局变量,并赋予其值,实现下面格式的接口:
interface ActivatedRoute {
snapshot: {
paramMap: {
get(param: string): string;
};
};
}
- 将其注入到 TestBed.configureTestingModule 中去。
TestBed.configureTestingModule({
imports: [*],
declarations: [*],
providers: [
...,
{ provide: HeroService, useValue: mockHeroService },
...,
]
})
全部代码如下所示:
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
import { FormsModule } from "@angular/forms";
describe('test on detail hero component', () => {
let fixture: ComponentFixture<HeroDetailComponent>;
let mockActivatedRoute: ActivatedRoute;
let mockHeroService, mockLocation;
beforeEach(() => {
mockActivatedRoute = {
snapshot: {
paramMap: {
get: () => '3',
}
}
};
mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);
mockLocation = jasmine.createSpyObj(['back']);
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [HeroDetailComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: HeroService, useValue: mockHeroService },
{ provide: Location, useValue: mockLocation },
]
})
fixture = TestBed.createComponent(HeroDetailComponent);
mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));
})
})
19. 对 routerLink 等指令的 mock
这个比起 ActivatedRoute 的 mock 就复杂多了,原理在于覆盖。我们自己将 routerLink 对应的模块实现一边,然后用自己实现的模块对 @angular/router 的内置 RouterModule 模块进行覆盖即可。首先创建一个名为 routerLinkDirectiveStub 的文件。
import { Directive, Input } from "@angular/core";
@Directive({
selector: '[routerLink]',
host: { '(click)': 'onClick()' }
})
export class RouterLinkDirectiveStub {
@Input('routerLink') linkParams: any;
navigatedTo: any = null;
onClick() {
this.navigatedTo = this.linkParams;
}
}
然后在 TestBed.configureTestingModule 的 declarations 中进行声明即可!
TestBed.configureTestingModule({
declarations: [
...,
RouterLinkDirectiveStub,
],
providers: [{
// provide: HeroService, useValue: mockHeroService,
}],
// schemas: [NO_ERRORS_SCHEMA],
});
20. 测试异步代码的三种方式
setTimeout + done方式fakeAsync + tick方式fixture.whenStable方式
21. 得到测试覆盖率报告
"gencode": "ng test --code-coverage"
yarn gencode
结果图示: