使用 Jasmine 模拟 Angular 9 个服务
Mocking Angular 9 Services with Jasmine
使用 Angular 7,我可以通过创建一些 classes 来用 Jasmine 模拟我的 SearchService
。第一个是 helper.ts
文件,其中有一个 class 可以扩展。
/// <reference path="../../../../../node_modules/@types/jasmine/index.d.ts"/>
export interface GuinessCompatibleSpy extends jasmine.Spy {
/** By chaining the spy with and.returnValue, all calls to the function will return a specific
* value. */
andReturn(val: any): void;
/** By chaining the spy with and.callFake, all calls to the spy will delegate to the supplied
* function. */
andCallFake(fn: Function): GuinessCompatibleSpy;
/** removes all recorded calls */
reset();
}
export class SpyObject {
static stub(object = null, config = null, overrides = null) {
if (!(object instanceof SpyObject)) {
overrides = config;
config = object;
object = new SpyObject();
}
const m = {};
Object.keys(config).forEach((key) => m[key] = config[key]);
Object.keys(overrides).forEach((key) => m[key] = overrides[key]);
for (const key in m) {
object.spy(key).andReturn(m[key]);
}
return object;
}
constructor(type = null) {
if (type) {
for (const prop in type.prototype) {
let m = null;
try {
m = type.prototype[prop];
} catch (e) {
// As we are creating spys for abstract classes,
// these classes might have getters that throw when they are accessed.
// As we are only auto creating spys for methods, this
// should not matter.
}
if (typeof m === 'function') {
this.spy(prop);
}
}
}
}
spy(name) {
if (!this[name]) {
this[name] = this._createGuinnessCompatibleSpy(name);
}
return this[name];
}
prop(name, value) { this[name] = value; }
/** @internal */
_createGuinnessCompatibleSpy(name): GuinessCompatibleSpy {
const newSpy: GuinessCompatibleSpy = <any>jasmine.createSpy(name);
newSpy.andCallFake = <any>newSpy.and.callFake;
newSpy.andReturn = <any>newSpy.and.returnValue;
newSpy.reset = <any>newSpy.calls.reset;
// revisit return null here (previously needed for rtts_assert).
newSpy.and.returnValue(null);
return newSpy;
}
}
这是我要测试的search.service.ts
:
@Injectable({
providedIn: 'root'
})
export class SearchService {
constructor(private http: HttpClient) { }
getAll() {
return this.http.get('assets/data/people.json');
}
search(q: string): Observable<any> {
// implementation
}
get(id: number) {
// implementation
}
save(person: Person) {
// implementation
}
}
这是我的 search.service.mock.ts
:
import { SpyObject } from './helper';
import { SearchService } from '../search.service';
import Spy = jasmine.Spy;
export class MockSearchService extends SpyObject {
getAllSpy: Spy;
getByIdSpy: Spy;
searchSpy: Spy;
saveSpy: Spy;
fakeResponse: any;
constructor() {
super(SearchService);
this.fakeResponse = null;
this.getAllSpy = this.spy('getAll').andReturn(this);
this.getByIdSpy = this.spy('get').andReturn(this);
this.searchSpy = this.spy('search').andReturn(this);
this.saveSpy = this.spy('save').andReturn(this);
}
subscribe(callback: any) {
callback(this.fakeResponse);
}
setResponse(json: any): void {
this.fakeResponse = json;
}
}
然后我在测试中模拟它。
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { MockSearchService } from '../shared/search/mocks/search.service';
import { MockActivatedRoute, MockRouter } from '../shared/search/mocks/routes';
import { SearchService } from '../shared';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { FormsModule } from '@angular/forms';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
let mockSearchService: MockSearchService;
let mockActivatedRoute: MockActivatedRoute;
beforeEach(async(() => {
mockSearchService = new MockSearchService();
mockActivatedRoute = new MockActivatedRoute({'term': 'peyton'});
TestBed.configureTestingModule({
declarations: [ SearchComponent ],
providers: [
{provide: SearchService, useValue: mockSearchService},
{provide: ActivatedRoute, useValue: mockActivatedRoute}
],
imports: [FormsModule, RouterTestingModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
这适用于 Angular 7。但是,当我尝试使用 Angular 9 时,我必须删除 helper.ts
顶部的 // <reference path="..."/>
才能修复一些编译器错误。
ERROR in node_modules/@types/jasmine/index.d.ts:25:1 - error TS6200: Definitions of the following identifiers conflict with those in another file: ImplementationCallback, Func, Constructor, ExpectedRecursive, Expected, SpyObjMethodNames, CustomEqualityTester, CustomMatcherFactory, ExpectationFailed, SpecFunction, SpyObj, jasmine
25 type ImplementationCallback = jasmine.ImplementationCallback;
~~~~
然后我得到两个错误:
Chrome 78.0.3904 (Mac OS X 10.15.1) SearchComponent should create FAILED
Failed: this.getSpy is not a function
at <Jasmine>
并且:
NullInjectorError: R3InjectorError(DynamicTestModule)[SearchService -> HttpClient -> HttpClient]:
NullInjectorError: No provider for HttpClient!
error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'SearchService', 'HttpClient', 'HttpClient' ] })
知道为什么这适用于 Angular 7 而不是 Angular 9 吗?
适用于 Angular 7 的应用程序在 GitHub https://github.com/mraible/ng-demo。
我决定摆脱 helper.ts
并模拟服务返回的内容会更容易。我还更改为 import HttpClientTestingModule
以便可以实例化该服务,即使它的 HttpClient
从未被使用过。这是重构后我的 search.component.spec.ts
:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { MockActivatedRoute } from '../shared/search/mocks/routes';
import { SearchService } from '../shared';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
let mockActivatedRoute: MockActivatedRoute;
let mockSearchService: SearchService;
beforeEach(async(() => {
mockActivatedRoute = new MockActivatedRoute({term: 'nikola'});
TestBed.configureTestingModule({
declarations: [SearchComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute}
],
imports: [FormsModule, RouterTestingModule, HttpClientTestingModule]
}).compileComponents();
}));
beforeEach(() => {
// mock response
mockSearchService = TestBed.inject(SearchService);
mockSearchService.search = jasmine.createSpy().and.returnValue(of([]));
// initialize component
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should search when a term is set and search() is called', () => {
component = fixture.debugElement.componentInstance;
component.query = 'J';
component.search();
expect(mockSearchService.search).toHaveBeenCalledWith('J');
});
it('should search automatically when a term is on the URL', () => {
fixture.detectChanges();
expect(mockSearchService.search).toHaveBeenCalledWith('nikola');
});
});
对于另一个测试,我做了类似的事情,从服务返回了预期的数据。
import { EditComponent } from './edit.component';
import { TestBed } from '@angular/core/testing';
import { Address, Person, SearchService } from '../shared';
import { MockRouter, MockActivatedRoute } from '../shared/search/mocks/routes';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('EditComponent', () => {
let mockSearchService: SearchService;
let mockActivatedRoute: MockActivatedRoute;
let mockRouter: MockRouter;
beforeEach(() => {
mockActivatedRoute = new MockActivatedRoute({id: 1});
mockRouter = new MockRouter();
TestBed.configureTestingModule({
declarations: [EditComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: Router, useValue: mockRouter}
],
imports: [FormsModule, HttpClientTestingModule]
}).compileComponents();
mockSearchService = TestBed.inject(SearchService);
});
it('should fetch a single record', () => {
const fixture = TestBed.createComponent(EditComponent);
const person = new Person({id: 1, name: 'Gary Harris'});
person.address = new Address({city: 'Denver'});
// mock response
spyOn(mockSearchService, 'get').and.returnValue(of(person));
// initialize component
fixture.detectChanges();
// verify service was called
expect(mockSearchService.get).toHaveBeenCalledWith(1);
// verify data was set on component when initialized
const editComponent = fixture.debugElement.componentInstance;
expect(editComponent.editAddress.city).toBe('Denver');
// verify HTML renders as expected
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h3').innerHTML).toBe('Gary Harris');
});
});
Had NullInjectorError: R3InjectorError in a spec.ts which runs fine in Angular 8. 测试创建一个模拟@Injectable 服务。
在 Angular9 中,堆栈跟踪显示 Ivy 编译器正在尝试创建真正的服务,但最终失败了,因为该服务有更多的依赖关系。
作为临时解决方法,禁用 Ivy 可以解决问题
"angularCompilerOptions": {
"enableIvy": false
}
(请注意,这可能需要在 tsconfig.json 或 tsconfig.spec.json 中设置,具体取决于您的设置)
使用 Angular 7,我可以通过创建一些 classes 来用 Jasmine 模拟我的 SearchService
。第一个是 helper.ts
文件,其中有一个 class 可以扩展。
/// <reference path="../../../../../node_modules/@types/jasmine/index.d.ts"/>
export interface GuinessCompatibleSpy extends jasmine.Spy {
/** By chaining the spy with and.returnValue, all calls to the function will return a specific
* value. */
andReturn(val: any): void;
/** By chaining the spy with and.callFake, all calls to the spy will delegate to the supplied
* function. */
andCallFake(fn: Function): GuinessCompatibleSpy;
/** removes all recorded calls */
reset();
}
export class SpyObject {
static stub(object = null, config = null, overrides = null) {
if (!(object instanceof SpyObject)) {
overrides = config;
config = object;
object = new SpyObject();
}
const m = {};
Object.keys(config).forEach((key) => m[key] = config[key]);
Object.keys(overrides).forEach((key) => m[key] = overrides[key]);
for (const key in m) {
object.spy(key).andReturn(m[key]);
}
return object;
}
constructor(type = null) {
if (type) {
for (const prop in type.prototype) {
let m = null;
try {
m = type.prototype[prop];
} catch (e) {
// As we are creating spys for abstract classes,
// these classes might have getters that throw when they are accessed.
// As we are only auto creating spys for methods, this
// should not matter.
}
if (typeof m === 'function') {
this.spy(prop);
}
}
}
}
spy(name) {
if (!this[name]) {
this[name] = this._createGuinnessCompatibleSpy(name);
}
return this[name];
}
prop(name, value) { this[name] = value; }
/** @internal */
_createGuinnessCompatibleSpy(name): GuinessCompatibleSpy {
const newSpy: GuinessCompatibleSpy = <any>jasmine.createSpy(name);
newSpy.andCallFake = <any>newSpy.and.callFake;
newSpy.andReturn = <any>newSpy.and.returnValue;
newSpy.reset = <any>newSpy.calls.reset;
// revisit return null here (previously needed for rtts_assert).
newSpy.and.returnValue(null);
return newSpy;
}
}
这是我要测试的search.service.ts
:
@Injectable({
providedIn: 'root'
})
export class SearchService {
constructor(private http: HttpClient) { }
getAll() {
return this.http.get('assets/data/people.json');
}
search(q: string): Observable<any> {
// implementation
}
get(id: number) {
// implementation
}
save(person: Person) {
// implementation
}
}
这是我的 search.service.mock.ts
:
import { SpyObject } from './helper';
import { SearchService } from '../search.service';
import Spy = jasmine.Spy;
export class MockSearchService extends SpyObject {
getAllSpy: Spy;
getByIdSpy: Spy;
searchSpy: Spy;
saveSpy: Spy;
fakeResponse: any;
constructor() {
super(SearchService);
this.fakeResponse = null;
this.getAllSpy = this.spy('getAll').andReturn(this);
this.getByIdSpy = this.spy('get').andReturn(this);
this.searchSpy = this.spy('search').andReturn(this);
this.saveSpy = this.spy('save').andReturn(this);
}
subscribe(callback: any) {
callback(this.fakeResponse);
}
setResponse(json: any): void {
this.fakeResponse = json;
}
}
然后我在测试中模拟它。
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { MockSearchService } from '../shared/search/mocks/search.service';
import { MockActivatedRoute, MockRouter } from '../shared/search/mocks/routes';
import { SearchService } from '../shared';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { FormsModule } from '@angular/forms';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
let mockSearchService: MockSearchService;
let mockActivatedRoute: MockActivatedRoute;
beforeEach(async(() => {
mockSearchService = new MockSearchService();
mockActivatedRoute = new MockActivatedRoute({'term': 'peyton'});
TestBed.configureTestingModule({
declarations: [ SearchComponent ],
providers: [
{provide: SearchService, useValue: mockSearchService},
{provide: ActivatedRoute, useValue: mockActivatedRoute}
],
imports: [FormsModule, RouterTestingModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
这适用于 Angular 7。但是,当我尝试使用 Angular 9 时,我必须删除 helper.ts
顶部的 // <reference path="..."/>
才能修复一些编译器错误。
ERROR in node_modules/@types/jasmine/index.d.ts:25:1 - error TS6200: Definitions of the following identifiers conflict with those in another file: ImplementationCallback, Func, Constructor, ExpectedRecursive, Expected, SpyObjMethodNames, CustomEqualityTester, CustomMatcherFactory, ExpectationFailed, SpecFunction, SpyObj, jasmine
25 type ImplementationCallback = jasmine.ImplementationCallback;
~~~~
然后我得到两个错误:
Chrome 78.0.3904 (Mac OS X 10.15.1) SearchComponent should create FAILED
Failed: this.getSpy is not a function
at <Jasmine>
并且:
NullInjectorError: R3InjectorError(DynamicTestModule)[SearchService -> HttpClient -> HttpClient]:
NullInjectorError: No provider for HttpClient!
error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'SearchService', 'HttpClient', 'HttpClient' ] })
知道为什么这适用于 Angular 7 而不是 Angular 9 吗?
适用于 Angular 7 的应用程序在 GitHub https://github.com/mraible/ng-demo。
我决定摆脱 helper.ts
并模拟服务返回的内容会更容易。我还更改为 import HttpClientTestingModule
以便可以实例化该服务,即使它的 HttpClient
从未被使用过。这是重构后我的 search.component.spec.ts
:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { MockActivatedRoute } from '../shared/search/mocks/routes';
import { SearchService } from '../shared';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
let mockActivatedRoute: MockActivatedRoute;
let mockSearchService: SearchService;
beforeEach(async(() => {
mockActivatedRoute = new MockActivatedRoute({term: 'nikola'});
TestBed.configureTestingModule({
declarations: [SearchComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute}
],
imports: [FormsModule, RouterTestingModule, HttpClientTestingModule]
}).compileComponents();
}));
beforeEach(() => {
// mock response
mockSearchService = TestBed.inject(SearchService);
mockSearchService.search = jasmine.createSpy().and.returnValue(of([]));
// initialize component
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should search when a term is set and search() is called', () => {
component = fixture.debugElement.componentInstance;
component.query = 'J';
component.search();
expect(mockSearchService.search).toHaveBeenCalledWith('J');
});
it('should search automatically when a term is on the URL', () => {
fixture.detectChanges();
expect(mockSearchService.search).toHaveBeenCalledWith('nikola');
});
});
对于另一个测试,我做了类似的事情,从服务返回了预期的数据。
import { EditComponent } from './edit.component';
import { TestBed } from '@angular/core/testing';
import { Address, Person, SearchService } from '../shared';
import { MockRouter, MockActivatedRoute } from '../shared/search/mocks/routes';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('EditComponent', () => {
let mockSearchService: SearchService;
let mockActivatedRoute: MockActivatedRoute;
let mockRouter: MockRouter;
beforeEach(() => {
mockActivatedRoute = new MockActivatedRoute({id: 1});
mockRouter = new MockRouter();
TestBed.configureTestingModule({
declarations: [EditComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: Router, useValue: mockRouter}
],
imports: [FormsModule, HttpClientTestingModule]
}).compileComponents();
mockSearchService = TestBed.inject(SearchService);
});
it('should fetch a single record', () => {
const fixture = TestBed.createComponent(EditComponent);
const person = new Person({id: 1, name: 'Gary Harris'});
person.address = new Address({city: 'Denver'});
// mock response
spyOn(mockSearchService, 'get').and.returnValue(of(person));
// initialize component
fixture.detectChanges();
// verify service was called
expect(mockSearchService.get).toHaveBeenCalledWith(1);
// verify data was set on component when initialized
const editComponent = fixture.debugElement.componentInstance;
expect(editComponent.editAddress.city).toBe('Denver');
// verify HTML renders as expected
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h3').innerHTML).toBe('Gary Harris');
});
});
Had NullInjectorError: R3InjectorError in a spec.ts which runs fine in Angular 8. 测试创建一个模拟@Injectable 服务。
在 Angular9 中,堆栈跟踪显示 Ivy 编译器正在尝试创建真正的服务,但最终失败了,因为该服务有更多的依赖关系。
作为临时解决方法,禁用 Ivy 可以解决问题
"angularCompilerOptions": {
"enableIvy": false
}
(请注意,这可能需要在 tsconfig.json 或 tsconfig.spec.json 中设置,具体取决于您的设置)