前端analysis | 3w & 1h

《angular8》-Angular8多场景下单元测试实践指南

2020-08-05

开篇三问:

为何要进行单元测试?

单元测试有什么好处?

如何编写angular单元测试?

没有单元测试会如何?

或者换句话说,为何要开发编写单元测试?
在业务开发紧张的情况下,往往会忽略单元测试,直接采用,然后开启下方的难忘人生回忆~

单元测试有啥好处?

我们在开发完毕,加入单元测试环节,下划线部分可能就不存在了~

如何进行angular单元测试?

angular前提背景知识

  • 构建angular框架,angular-cli命令可以,在创建service、pipe、component时候,同时创建对应的测试用例**.spec.ts文件
  • 运行单元测试
    1
    ng test --no-watch --code-coverage //根目录下会生成coverage目录,其中index.html记录组件覆盖率
  • 查看

编写angular8 单元测试

测试service-无依赖

框架new实例测试

代码如下:

1
2
3
4
5
6
@Injectable() //交给angular管理,帮忙注入依赖
export class ValueService {
value:string;
constructor() { }
getValue() { return this.value}
}

测试用例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1.直接new service 实例
let service: ValueService;
beforeEach(() => { service = new ValueService(); });

it('#getValue should return real value', () => {
expect(service.getValue()).toBe('real value');
});

# or

# 2.直接获取服务实例进行测试,通过调用服务,校验逻辑
let service: ValueService;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [ValueService] }); //等效于useClass
});
it('should use ValueService', () => {
service = TestBed.get(ValueService);
expect(service.getValue()).toBe('real value');
});

测试service - 有依赖

利用spyOn mock

代码如下:

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

获取真实的依赖服务,常因为服务中依赖原因,难以顺利创建。此时spy,跳过真正的服务业务逻辑,进行单独测试,是最简单的方法。** 不跳过依赖,则属于集成测试范畴。**

测试如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let masterService: MasterService;
let valueServiceSpy: jasmine.SpyObj<ValueService>;

beforeEach(() => {
const spy = jasmine.createSpyObj('ValueService', ['getValue']);//需要注意位置,在beforeEach

TestBed.configureTestingModule({
// Provide both the service-to-test and its (spy) dependency
providers: [
MasterService,
//注入服务,mock提供依赖服务的支持,完成MasterService实例创建
{ provide: ValueService, useValue: spy }
]
});
// Inject both the service-to-test and its (spy) dependency
masterService = TestBed.get(MasterService);
valueServiceSpy = TestBed.get(ValueService);
});
1
2
3
4
5
6
7
it('#getValue should return stubbed value from a spy', () => {
const stubValue = 'stub value';
# mock 返回值
valueServiceSpy.getValue.and.returnValue(stubValue);
expect(masterService.getValue())
.toBe(stubValue, 'service returned stub value'); //利用mock依赖返回的值,进行期望判断业务逻辑
});

测试组件-无依赖

代码如下:

1
2
3
4
5
6
7
8
9
10
11
@Component({
selector: 'lightswitch-comp',
template: `
<button (click)="clicked()">Click me!</button>
<span>{{message}}</span>`
})
export class LightswitchComponent {
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
17
18
19
20
21
22
23
24
25
//直接new 
it('#clicked() should set #message to "is on"', () => {
const comp = new LightswitchComponent();
expect(comp.message).toMatch(/is off/i, 'off at first');
comp.clicked();
expect(comp.message).toMatch(/is on/i, 'on after clicked');
});

//or 获取组件实例,交给框架创建new
let comp:LightswitchComponent;
beforeEach(() => {
TestBed.configureTestingModule({
// provide the component-under-test and dependent service
providers: [
LightswitchComponent,
]
});
// inject both the component and the dependent service.
comp = TestBed.get(LightswitchComponent);
});
it('#clicked() should set #message to "is on"', () => {
expect(comp.message).toMatch(/is off/i, 'off at first');
comp.clicked();
expect(comp.message).toMatch(/is on/i, 'on after clicked');
});

测试组件-有input、output

1
2
3
4
5
export class DashboardHeroComponent {
@Input() hero: Hero;
@Output() selected = new EventEmitter<Hero>();
click() { this.selected.emit(this.hero); }
}

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let comp:DashboardHeroComponent;
beforeEach(() => {
TestBed.configureTestingModule({
// provide the component-under-test and dependent service
providers: [
DashboardHeroComponent,
]
});
// inject both the component and the dependent service.
comp = TestBed.get(DashboardHeroComponent);
});
it('raises the selected event when clicked', () => {
const hero: Hero = { id: 42, name: 'Test' };
comp.hero = hero;

comp.selected.subscribe((selectedHero: Hero) => expect(selectedHero).toBe(hero));
comp.click();
});

测试组件 - 有依赖

WelcomeComponent 依赖于 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
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# spect.ts
class MockUserService {
isLoggedIn = true;
user = { name: 'Test User'};
};

beforeEach(() => {
TestBed.configureTestingModule({
// provide the component-under-test and dependent service
providers: [
WelcomeComponent,
{ provide: UserService, useClass: MockUserService }
// {provide: UserService, useVale: userServiceSpy} # 两者都可以,不同方式而已
]
});
// inject both the component and the dependent service.
comp = TestBed.get(WelcomeComponent);
//容易记住,也不太冗长。但是,只有当Angular在测试的根注入器中将带有服务实例的组件注入组件时,它才起作用。
userService = TestBed.get(UserService);
//userService = fixture.debugElement.injector.get(UserService);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
it('should not have welcome message after construction', () => {
expect(comp.welcome).toBeUndefined();
});
it('should welcome logged in user after Angular calls ngOnInit', () => {
comp.ngOnInit();
expect(comp.welcome).toContain(userService.user.name);
});
it('should ask user to log in if not logged in after ngOnInit', () => {
userService.isLoggedIn = false;
comp.ngOnInit();
expect(comp.welcome).not.toContain(userService.user.name);
expect(comp.welcome).toContain('log in');
});

组件中dom元素测试

组件创建测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BannerComponent } from './banner.component';
describe('BannerComponent', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
# 只有在组件创建初期有意义,后面添加业务单元测试,推荐删除的
it('should create', () => {
expect(component).toBeDefined();
});

});

页面元素固定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 it('should contain "banner works!"', () => {
const bannerElement: HTMLElement = fixture.nativeElement;
expect(bannerElement.textContent).toContain('banner works!');
});

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

it('should find the <p> with fixture.debugElement.nativeElement)', () => {
const bannerDe: DebugElement = fixture.debugElement;
const bannerEl: HTMLElement = bannerDe.nativeElement;
const p = bannerEl.querySelector('p');
expect(p.textContent).toEqual('banner works!');
});

如果querySelector不能使用,

1
2
3
4
5
6
7
import { By } from '@angular/platform-browser';
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;
expect(p.textContent).toEqual('banner works!');
});

页面元素动态修改

页面元素动态修改,测试

1
2
3
4
5
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges(); //显示的进行修改检测
expect(h1.textContent).toContain('Test Title');
});

除去上述显示声明detectChanges,使用自动检测也可以实现

1
2
3
4
5
6
7
import { ComponentFixtureAutoDetect } from '@angular/core/testing';
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
});

Render2 样式测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {Type ,Render2 } from 'angular/core';

let renderer2: Renderer2;
...
beforeEach(async( () => {
TestBed.configureTestingModule({
...
providers: [Renderer2]
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(BannerComponent);
renderer2 = fixture.componentRef.injector.get<Renderer2>(Renderer2 as Type<Renderer2>);
// and spy on it
spyOn(renderer2, 'addClass').and.callThrough();
// or replace
// spyOn(renderer2, 'addClass').and.callFake(..);
// etc
});

it('should call renderer', () => {
expect(renderer2.addClass).toHaveBeenCalledWith(jasmine.any(Object), 'css-class');
});

Observable测试

代码如下

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

正常返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
beforeEach(() => {
testQuote = 'Test Quote';
const twainServiceSpy = jasmine.createSpyObj('TwainService', ['getQuote']);
getQuoteSpy = twainServiceSpy.getQuote.and.returnValue( of(testQuote) ); //关键在此

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

fixture = TestBed.createComponent(TwainComponent);
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
it('should show quote after component initialized', () => {
fixture.detectChanges(); // onInit()


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

返回异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
beforeEach(() => {
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
getQuoteSpy = twainService.getQuote.and.returnValue( throwError('ops') ); //关键在此

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

fixture = TestBed.createComponent(TwainComponent);
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
it('should show quote after component initialized', () => {
fixture.detectChanges(); // onInit()

expect(errorMessage()).toMatch(/test failure/, 'should display error');
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
});

返回异常,但异步处理

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
31
32
33
34
35
36
37
getQuote() {
this.errorMessage = '';
this.quote = this.twainService.getQuote().pipe(
startWith('...'),
catchError( (err: any) => {
setTimeout(() => this.errorMessage = err.message || err.toString());
return of('...');
})
);

beforeEach(() => {
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
getQuoteSpy = twainService.getQuote.and.returnValue( throwError('ops') ); //关键在此

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

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

it('should display error when TwainService fails', fakeAsync(() => { //fakeAsync不适用与ajax
getQuoteSpy.and.returnValue(
throwError('TwainService test failure'));

fixture.detectChanges(); // onInit()
tick(); // flush the component's setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()

expect(errorMessage()).toMatch(/test failure/, 'should display error');
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
}));

异步代码测试

使用fakeAsync

1
2
3
4
5
6
7
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));

fakeAsync支持以下异步任务:

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • webkitRequestAnimationFrame
  • mozRequestAnimationFrame
  • rxjs - delay、interval等

ajax请求测试

1
2
3
4
5
6
7
8
9
10
it('should show quote after getQuote (async)', async(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).toBe('...', 'should show placeholder');

fixture.whenStable().then(() => { // wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).toBeNull('should not show error');
});
}));

jasmine done

1
2
3
4
5
6
7
8
9
10
11
it('should show quote after getQuote (spy done)', (done: DoneFn) => {
fixture.detectChanges();

// the spy's most recent call returns the observable with the test quote
getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => {
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).toBeNull('should not show error');
done();
});
});

组件嵌套测试

服务依赖错误

1
TypeError: ctor is not a constructor

问题原因:provide中错误的配置

1
2
//错误的
providers: [{provide: OrderService, useClass: new OrderServiceMock()}]

1
2
//正确的
providers: [{provide: OrderService, useValue: new OrderServiceMock()}]

HTTP service测试

类似service测试,使用Spy

使用HttpTestingController

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let service: BlogPostsService;
let backend: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BlogPostsService],
imports: [
HttpClientTestingModule
]
});
});
beforeEach(() => {
service = TestBed.get(BlogPostsService);
backend = TestBed.get(HttpTestingController);
});

expectOne判定url

1
2
3
4
5
it('should expectOne url', () => {
service.getAll().subscribe();
backend.expectOne(`https://rails-rest.herokuapp.com/posts`);
backend.verify();
});

method判定

1
2
3
4
5
6
7
it('should expectOne url and method', () => {
service.getAll().subscribe();
backend.expectOne({url: `https://rails-rest.herokuapp.com/posts`});
service.getAll().subscribe();
backend.expectOne({url: `https://rails-rest.herokuapp.com/posts`, method: 'GET'});
backend.verify();
});

none判定

1
2
3
4
5
it('should not expect one when not subscribed', () => {
service.getAll()// .subscribe();
backend.expectNone(`https://rails-rest.herokuapp.com/posts`);
backend.verify();
});

match 正则判定

1
2
3
4
5
6
7
8
9
10
11
12
it('should match two requests', () => {
service.getAll().subscribe();
service.get(1).subscribe();
const calls = backend.match((request) => {
return request.url.match(/posts/) && # url正则匹配
request.method === 'GET';
});
expect(calls.length).toEqual(2);
expect(calls[0].request.url).toEqual(`https://rails-rest.herokuapp.com/posts`);
expect(calls[1].request.url).toEqual(`https://rails-rest.herokuapp.com/posts/1.json`);
backend.verify();
});

match 不同url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('should match different requests', () => {
service.getAll().subscribe();
service.get(1).subscribe();
const otherCalls = backend.match((request) => {
return request.url == `https://rails-rest.herokuapp.com/posts/1.json` &&
request.method === 'GET';
});
const calls = backend.match((request) => {
return request.url == `https://rails-rest.herokuapp.com/posts` &&
request.method === 'GET';
});
expect(calls.length).toEqual(1);
expect(otherCalls.length).toEqual(1);
expect(calls[0].request.url).toEqual(`https://rails-rest.herokuapp.com/posts`);
expect(otherCalls[0].request.url).toEqual(`https://rails-rest.herokuapp.com/posts/1.json`);
backend.verify();
});

match 判定urlWithParams

1
2
3
4
5
6
7
8
9
10
it('should have url and urlWithParams', () => {
service.getAll({page: 1}).subscribe();
const calls = backend.match((request) => {
return request.url == `https://rails-rest.herokuapp.com/posts` &&
request.urlWithParams == `https://rails-rest.herokuapp.com/posts?page=1` &&
request.method === 'GET';
});
backend.expectNone(`https://rails-rest.herokuapp.com/posts`); // If url with params, use `.match`
backend.verify();
});

match 其余request参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it('should have a few more attributes on request that are useful', () => {
service.getAll({page: 1}).subscribe();
const calls = backend.match((request: HttpRequest<any>) => {
return request.url == `https://rails-rest.herokuapp.com/posts` &&
request.urlWithParams == `https://rails-rest.herokuapp.com/posts?page=1` &&
request.method === 'GET' &&
request.params.get('page') == '1' &&
request.body == null &&
request.headers instanceof HttpHeaders &&
request.responseType == 'json' &&
request.withCredentials == false;
});
backend.expectNone(`https://rails-rest.herokuapp.com/posts`); // If url with params, use `.match`
backend.verify();
});

subscribe 结果验证

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
it('should create post', () => {
service.save({
title: 'Creating a post',
content: 'Another long description...'
}).subscribe((response) => {
expect(response).toEqual(jasmine.objectContaining({
id: 2,
title: 'Creating a post',
content: jasmine.any(String),
created_at: new Date('2017-12-07T04:39:49.447Z'),
updated_at: jasmine.any(Date)
}));
});
const response = {
'id': 2,
'title': 'Creating a post',
'content': 'Another long description...',
'created_at': '2017-12-07T04:39:49.447Z',
'updated_at': '2017-12-07T04:39:49.447Z'
};
const call = backend.expectOne(`https://rails-rest.herokuapp.com/posts`);
expect(call.request.method).toEqual('POST');
call.flush(response); # 返回结果
backend.verify();
});

个人心得

  • 测试用例的编写,应该尽可能的简化测试对象逻辑,分而测之,
  • 避免一次调用,敲定全部测试,这属于集成测试范畴
  • 编写代码时候,需要有意识的拆分代码,便于单元测试,不要一个方法一大屏看不到低

更多推荐

Angular开发提效vscode插件

angular开发需要了解的rxjs操作符实践

Angular8 日常开发避坑指南

参考文献

window 变量

Angular

d3 测试

HttpTestingContrller

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏