Angular 集成测试 - jasmine Spy - 无法模拟返回 Observable 的服务方法
Angular Integration Testing - jasmine Spy - Can't mock a service method returning Observable
我是集成测试新手。整个事情是如此混乱。
对于我的第一次测试,我的间谍活动似乎并没有像我打算的那样 returning 数据。return 它。给出和错误:预期 0 为 3。如果有人能帮助我理解我做错了什么,那就太好了。
这是我的服务、页面、规范文件以及模板:
我的服务
import { Data } from './../data/data.model';
import { Injectable } from '@angular/core';
import { BehaviorSubject, of } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class MyService {
private _data = new BehaviorSubject<Data[]>([]);
get data() {
return this._data;
}
constructor() {}
getAllData() {
return of([
{
id: '1',
title: 'Rice',
},
{
id: '2',
title: 'Wheat',
},
{
id: '33',
title: 'Water',
},
]).pipe(
tap((data) => {
this._data.next(data);
})
);
}
}
DataPage 组件
import { Component, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
import { MyService } from '../services/my.service';
import { Data } from './data.model';
@Component({
selector: 'app-data',
templateUrl: './data.page.html',
styleUrls: ['./data.page.scss'],
})
export class DataPage implements OnInit {
allData: Data[];
dataServiceSub: Subscription;
isLoading: boolean;
constructor(private myService: MyService) {}
ngOnInit() {
this.dataServiceSub = this.myService.data.subscribe(
(data) => {
console.log(data);
this.allData = data;
}
);
}
ngOnDestroy() {
if (this.dataServiceSub) {
console.log('ngOnDestroy');
this.dataServiceSub.unsubscribe();
}
}
ionViewWillEnter() {
this.isLoading = true;
this.myService.getAllData().subscribe(() => {
console.log('ionViewWillEnter');
this.isLoading = false;
});
}
}
DataPage.spec
import { MyService } from '../services/my.service';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { DataPage } from './data.page';
import { of } from 'rxjs';
describe('DataPage', () => {
let component: DataPage;
let fixture: ComponentFixture<DataPage>;
let serviceSpy: jasmine.SpyObj<MyService>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [DataPage],
providers: [
{
provide: MyService,
useClass: MyService
},
],
imports: [IonicModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(DataPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
fit('Should show list of data if data is available', () => {
serviceSpy = TestBed.get(MyService);
spyOn(serviceSpy, 'getAllData').and.returnValue(of([
{
id: '1',
title: 'Rice',
},
{
id: '2',
title: 'Wheat',
},
{
id: '33',
title: 'Water',
},
]));
fixture.detectChanges();
const element = fixture.nativeElement.querySelectorAll(
'[test-tag="dataList"] ion-item'
);
console.log(
fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
);
expect(element.length).toBe(3);
});
});
HTML
<ion-content>
<div test-tag="empty" class="ion-text-center">
<ion-text color="danger">
<h1>No data</h1>
</ion-text>
</div>
<div test-tag="dataList">
<ion-list>
<ion-item *ngFor="let data of allData">
<ion-label test-tag="title">{{data.title}}</ion-label>
</ion-item>
</ion-list>
</div>
</ion-content>
为了避免 observable 的痛苦,我建议使用像 ng-mocks
这样的模拟库,并查看其文章“如何在 Angular 测试中模拟可观察流”https://ng-mocks.sudo.eu/extra/mock-observables .
在您的情况下,测试可能如下所示:
describe('DataPage', () => {
// mocks everything except DataPage
beforeEach(() => {
return MockBuilder(DataPage)
.mock(IonicModule.forRoot())
.mock(MyService);
});
// We need to stub it because of subscription in ionViewWillEnter.
// in a mock service, ionViewWillEnter does not return anything.
// But we need to tell it to return an empty observable stream
// to avoid errors like cannot call .subscribe on undefined.
// This line can be removed along with the debugging from the
// component.
beforeEach(() => MockInstance(MyService, 'getAllData', () => EMPTY));
it('Should show list of data if data is available', () => {
// spies the getter of the property the component uses.
MockInstance(MyService, 'data', jasmine.createSpy(), 'get')
.and.returnValue(of([
{
id: '1',
title: 'Rice',
},
{
id: '2',
title: 'Wheat',
},
{
id: '33',
title: 'Water',
},
]));
// render (already with detected changes)
const fixture = MockRender(DataPage);
// assertions
const element = fixture.nativeElement.querySelectorAll(
'[test-tag="dataList"] ion-item'
);
console.log(
fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
);
expect(element.length).toBe(3);
});
});
好的,问题来了:
需要调用ionViewWillEnter()
设置this.allData
'
的值
原因:因为您在创建 BehaviorSubject
时有空值。要使用 data
(this._data.next(data)
) 发出值,您需要调用 getAllData()
.
import { MyService } from '../services/my.service';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { DataPage } from './data.page';
import { of } from 'rxjs';
describe('DataPage', () => {
let component: DataPage;
let fixture: ComponentFixture<DataPage>;
let serviceSpy: jasmine.SpyObj<MyService>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [DataPage],
providers: [ MyService ],
imports: [IonicModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(DataPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
fit('Should show list of data if data is available', () => {
component.ionViewWillEnter(); // or create an event which will trigger ionViewWillEnter()
fixture.detectChanges();
const element = fixture.nativeElement.querySelectorAll(
'[test-tag="dataList"] ion-item'
);
console.log(
fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
);
expect(element.length).toBe(3);
});
});
请注意,我做了一些改动:
- 已删除
UseClass
(因为您没有按应有的方式使用它)
- 已删除
spy
(因为您在原始服务中已有硬编码值)
为了更好地理解angular中的测试,您可以参考my article,其中也展示了useClass
的用法,供您参考。
附带说明:尝试使用 asObservable()
,并在创建 Observable
(this.data$.asObservable()
) 时遵循使用 $
的约定。这不是强制性的,而是 JS 社区中公认的做法。
get data() {
return this._data.asObservable();
}
我是集成测试新手。整个事情是如此混乱。
对于我的第一次测试,我的间谍活动似乎并没有像我打算的那样 returning 数据。return 它。给出和错误:预期 0 为 3。如果有人能帮助我理解我做错了什么,那就太好了。
这是我的服务、页面、规范文件以及模板:
我的服务
import { Data } from './../data/data.model';
import { Injectable } from '@angular/core';
import { BehaviorSubject, of } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class MyService {
private _data = new BehaviorSubject<Data[]>([]);
get data() {
return this._data;
}
constructor() {}
getAllData() {
return of([
{
id: '1',
title: 'Rice',
},
{
id: '2',
title: 'Wheat',
},
{
id: '33',
title: 'Water',
},
]).pipe(
tap((data) => {
this._data.next(data);
})
);
}
}
DataPage 组件
import { Component, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
import { MyService } from '../services/my.service';
import { Data } from './data.model';
@Component({
selector: 'app-data',
templateUrl: './data.page.html',
styleUrls: ['./data.page.scss'],
})
export class DataPage implements OnInit {
allData: Data[];
dataServiceSub: Subscription;
isLoading: boolean;
constructor(private myService: MyService) {}
ngOnInit() {
this.dataServiceSub = this.myService.data.subscribe(
(data) => {
console.log(data);
this.allData = data;
}
);
}
ngOnDestroy() {
if (this.dataServiceSub) {
console.log('ngOnDestroy');
this.dataServiceSub.unsubscribe();
}
}
ionViewWillEnter() {
this.isLoading = true;
this.myService.getAllData().subscribe(() => {
console.log('ionViewWillEnter');
this.isLoading = false;
});
}
}
DataPage.spec
import { MyService } from '../services/my.service';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { DataPage } from './data.page';
import { of } from 'rxjs';
describe('DataPage', () => {
let component: DataPage;
let fixture: ComponentFixture<DataPage>;
let serviceSpy: jasmine.SpyObj<MyService>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [DataPage],
providers: [
{
provide: MyService,
useClass: MyService
},
],
imports: [IonicModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(DataPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
fit('Should show list of data if data is available', () => {
serviceSpy = TestBed.get(MyService);
spyOn(serviceSpy, 'getAllData').and.returnValue(of([
{
id: '1',
title: 'Rice',
},
{
id: '2',
title: 'Wheat',
},
{
id: '33',
title: 'Water',
},
]));
fixture.detectChanges();
const element = fixture.nativeElement.querySelectorAll(
'[test-tag="dataList"] ion-item'
);
console.log(
fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
);
expect(element.length).toBe(3);
});
});
HTML
<ion-content>
<div test-tag="empty" class="ion-text-center">
<ion-text color="danger">
<h1>No data</h1>
</ion-text>
</div>
<div test-tag="dataList">
<ion-list>
<ion-item *ngFor="let data of allData">
<ion-label test-tag="title">{{data.title}}</ion-label>
</ion-item>
</ion-list>
</div>
</ion-content>
为了避免 observable 的痛苦,我建议使用像 ng-mocks
这样的模拟库,并查看其文章“如何在 Angular 测试中模拟可观察流”https://ng-mocks.sudo.eu/extra/mock-observables .
在您的情况下,测试可能如下所示:
describe('DataPage', () => {
// mocks everything except DataPage
beforeEach(() => {
return MockBuilder(DataPage)
.mock(IonicModule.forRoot())
.mock(MyService);
});
// We need to stub it because of subscription in ionViewWillEnter.
// in a mock service, ionViewWillEnter does not return anything.
// But we need to tell it to return an empty observable stream
// to avoid errors like cannot call .subscribe on undefined.
// This line can be removed along with the debugging from the
// component.
beforeEach(() => MockInstance(MyService, 'getAllData', () => EMPTY));
it('Should show list of data if data is available', () => {
// spies the getter of the property the component uses.
MockInstance(MyService, 'data', jasmine.createSpy(), 'get')
.and.returnValue(of([
{
id: '1',
title: 'Rice',
},
{
id: '2',
title: 'Wheat',
},
{
id: '33',
title: 'Water',
},
]));
// render (already with detected changes)
const fixture = MockRender(DataPage);
// assertions
const element = fixture.nativeElement.querySelectorAll(
'[test-tag="dataList"] ion-item'
);
console.log(
fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
);
expect(element.length).toBe(3);
});
});
好的,问题来了:
需要调用ionViewWillEnter()
设置this.allData
'
原因:因为您在创建 BehaviorSubject
时有空值。要使用 data
(this._data.next(data)
) 发出值,您需要调用 getAllData()
.
import { MyService } from '../services/my.service';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { DataPage } from './data.page';
import { of } from 'rxjs';
describe('DataPage', () => {
let component: DataPage;
let fixture: ComponentFixture<DataPage>;
let serviceSpy: jasmine.SpyObj<MyService>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [DataPage],
providers: [ MyService ],
imports: [IonicModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(DataPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
fit('Should show list of data if data is available', () => {
component.ionViewWillEnter(); // or create an event which will trigger ionViewWillEnter()
fixture.detectChanges();
const element = fixture.nativeElement.querySelectorAll(
'[test-tag="dataList"] ion-item'
);
console.log(
fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
);
expect(element.length).toBe(3);
});
});
请注意,我做了一些改动:
- 已删除
UseClass
(因为您没有按应有的方式使用它) - 已删除
spy
(因为您在原始服务中已有硬编码值)
为了更好地理解angular中的测试,您可以参考my article,其中也展示了useClass
的用法,供您参考。
附带说明:尝试使用 asObservable()
,并在创建 Observable
(this.data$.asObservable()
) 时遵循使用 $
的约定。这不是强制性的,而是 JS 社区中公认的做法。
get data() {
return this._data.asObservable();
}