Angular9 组件中的单元测试异步代码
Unit test asynchronous code in a component in Angular 9
更新二:
事实证明,我的新测试最有可能在首次加载时响应前一个测试的 url 参数。这显然不是我想要的,可能意味着我需要更改路由。是在 afterEach 中重置路由参数的唯一解决方案吗?有没有更好的方法来处理这个问题?
更新:
经过更多的挖掘,我不再认为这是一个异步问题,但我不确定是什么问题。我认为这与路由有关,或者可能与订阅有关。
这是我投入组件的一些控制台日志中的失败测试场景示例。
//This is an emulated copy of what I see in the console
new request:
> {pageId: 3, testCount: 1}
retrievePage: making the request with:
> {pageId: 3, testCount: 1}
new request:
> {id: 2, testCount: 2}
retrieveItem: making the request with:
> {id: 2, testCount: 2}
retrieveItem: made the request with:
> {id: 2, testCount: 2}
FAILED MyComponent when the url matches item/:id should load the appropriate item
retrievePage: made the request with:
> {pageId: 3, testCount: 1}
> error properties: Object({ originalStack: 'Error: Expected spy service.retrievePage not to have been called.....
我更新了下面的代码片段以反映我添加的控制台日志。
正如您从日志中看到的那样,出于某种原因,params subscribe 在第一次测试中第二次获得 运行(第一次测试 运行 并且完成得很好,记录了“新请求" 和 "making..."/"made..." 消息仅一次)。我不知道是否有办法确定这是哪个测试组件运行(如果由于某种原因生成了新组件并响应先前测试的参数)或什么。我不确定到底是什么问题或如何进一步调试。
原题:
我在测试我的 运行 一些异步代码的组件时遇到问题。由于 NDA,我无法 post 实际代码,但我提供了一个尽可能接近我的真实代码的简单表示。
我有一个组件 运行 像这样的异步代码(仅包含相关位,隐含默认 imports/setup):
...
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { MyService } from 'services/my-service';
@Component({
...
})
export class MyComponent implements OnInit, OnDestroy {
private paramsSubscription: Subscription;
constructor(private route: ActivatedRoute, private service: MyService) {}
ngOnInit(): void {
this.paramsSubscription = this.route.params.subscribe(async params => {
console.log('new request: ', params);
let result;
let itemId = params.id;
if (params.pageId) {
console.log('retrievePage: making the request with: ', params);
let page = await this.service.retrievePage(params.pageId).toPromise();
console.log('retrievePage: made the request with: ', params);
itemId = page.items[0].id;
}
if (itemId) {
console.log('retrieveItem: making the request with: ', params);
result = await this.service.retrieveItem(itemId).toPromise();
console.log('retrieveItem: made the request with: ', params);
}
...
});
}
ngOnDestroy() {
if (this.paramsSubscription) this.paramsSubscription.unsubscribe();
}
}
然后我有一个这样的单元测试(也是示例代码):
...
import { fakeAsync, tick } from '@angular/core/testing';
import { of, ReplaySubject } from 'rxjs';
import { ActivatedRoute, Params } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MyService } from 'services/my-service';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let service = jasmine.createSpyObj('MyService', {
retrieveItem: of([]),
retrievePage: of([])
});
let route: ActivatedRoute;
let routeParams = new ReplaySubject<Params>(1);
let testCount = 0;
let item = {
id: 2,
...
};
let page = {
id: 3,
items: [item]
...
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MyComponent],
imports: [RouterTestingModule],
providers: [
{ provide: MyService, useValue: service },
{ provide: ActivatedRoute, useValue:
jasmine.createSpyObj('ActivatedRoute',[], {
params: routeParams.asObservable()
})
},
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
service = TestBed.inject(MyService);
route = TestBed.inject(ActivatedRoute);
testCount++;
fixture.detectChanges();
});
afterEach(() => {
service.retrieveItem.calls.reset();
service.retrievePage.calls.reset();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when the url matches: item/:id', () => {
beforeEach(fakeAsync(() => {
routeParams.next({id: item.id, testCount: testCount});
tick();
fixture.detectChanges();
}));
it('should load the appropriate item', () => {
expect(service.retrieveItem).toHaveBeenCalledWith(item.id);
expect(service.retrievePage).not.toHaveBeenCalled();
});
...
});
describe('when the url matches: item', () => {
beforeEach(fakeAsync(() => {
routeParams.next({testCount: testCount});
tick();
fixture.detectChanges();
}));
it('should load the first item', () => {
expect(service.retrievePage).not.toHaveBeenCalled();
expect(service.retrieveItem).toHaveBeenCalledWith(1);
});
...
});
describe('when the url matches: item/:pageId', () => {
beforeEach(fakeAsync(() => {
routeParams.next({pageId: page.id, testCount: testCount});
tick();
fixture.detectChanges();
}));
it('should load the item from the page', () => {
expect(service.retrievePage).toHaveBeenCalledWith(page.id);
expect(service.retrieveItem).toHaveBeenCalledWith(page.items[0].id);
});
...
});
});
我遇到的问题是在我的单元测试中,我的组件的 await 调用在进入下一个单元测试之前尚未完成,因此我的 expect 调用无法正常运行。当我以完美的顺序测试 运行 时,一切都很好。但有时我的测试 运行 以不同的顺序说 1、3、2。当他们这样做时,测试 # 2 失败,因为它期望 retrieveItem 已使用 id 2 调用,但之前的调用以 id 1 触发了它。 (这只是一个人为的例子,我的实际代码比这更复杂)
我想知道的是,如何告诉 jasmine 等待组件内触发的调用 return。我已经尝试查看关于 jasmine https://jasmine.github.io/tutorials/async 的异步代码的教程,但这些教程似乎都提到了在我的测试中手动进行异步调用,这不是我想要做的。
带有 tick() 的 fakeAsync 是我试图让它等待路由和一切完成的尝试,但它似乎没有解决我的问题。有谁知道我做错了什么或如何解决我的问题?
我也试过:
- 在刻度内输入一个值,例如打勾(10)
- 使用 fixture.whenStable().then(() => { //期待内容})
此外,如果我添加 routeParams.next({});到我的 afterEach() 它解决了这个问题,但我觉得这违背了测试 运行dom 顺序的目的,因为这意味着我的代码只有在你没有参数的情况下才会起作用,这在我的实际实现中您可以在 url 中使用各种参数到达这里(例如,我可以从 pageId: 1 到 id: 2,没有中间步骤)。
更多信息:在我的真实代码中,它是这样发生的:
//This is pseudo code
params.subscribe
if(params.first) param1 = params.first;
if(params.second) param2 = params.second;
if (param1) {
item = function1();
param2 = item.param2;
}
if(param2) {
otherItem = await function2();
} else if (item) {
result = await function3();
}
我的测试是检查当 activatedRoute 传递各种参数时是否调用了 function1、function2 和 function3。我在我的组件中添加了控制台日志,以确认 test2 将启动,并且在 test2 启动后调用 test1 中的 function2。所以我检查以确保 function2 没有被调用,但失败了,因为它从 test1 延迟了。
非常感谢任何建议或提示。
经过大量的挖掘和谷歌搜索,我自己找到了解决问题的方法,所以我在这里分享它,以防其他人和我一样困惑。
https://angular.io/guide/testing-components-scenarios#testing-with-activatedroutestub
在 angular 文档中,当他们使用 activatedRoutes 时,我注意到他们首先触发路由,然后创建组件。通过观察应用程序的正常工作方式(而不是在测试期间),它更符合 angular 的实际功能 - 路由发生在组件创建之前。基于我的解决方案,我最终将我的测试重新设计如下:
...
import { async } from '@angular/core/testing';
import { of, ReplaySubject } from 'rxjs';
import { ActivatedRoute, Params } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MyService } from 'services/my-service';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let service = jasmine.createSpyObj('MyService', {
retrieveItem: of([]),
retrievePage: of([])
});
let route: ActivatedRoute;
let routeParams = new ReplaySubject<Params>(1);
let testCount = 0;
let item = {
id: 2,
...
};
let page = {
id: 3,
items: [item]
...
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MyComponent],
imports: [RouterTestingModule],
providers: [
{ provide: MyService, useValue: service },
{ provide: ActivatedRoute, useValue:
jasmine.createSpyObj('ActivatedRoute',[], {
params: routeParams.asObservable()
})
},
]
}).compileComponents();
}));
beforeEach(() => {
service = TestBed.inject(MyService);
route = TestBed.inject(ActivatedRoute);
});
function createComponent() {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}
afterEach(() => {
service.retrieveItem.calls.reset();
service.retrievePage.calls.reset();
});
it('should create', () => {
createComponent();
expect(component).toBeTruthy();
});
describe('when the url matches: item/:id', () => {
beforeEach(async () => {
routeParams.next({id: item.id, testCount: testCount});
createComponent();
});
it('should load the appropriate item', () => {
expect(service.retrieveItem).toHaveBeenCalledWith(item.id);
expect(service.retrievePage).not.toHaveBeenCalled();
});
...
});
describe('when the url matches: item', () => {
beforeEach(async () => {
routeParams.next({testCount: testCount});
createComponent();
});
it('should load the first item', () => {
expect(service.retrievePage).not.toHaveBeenCalled();
expect(service.retrieveItem).toHaveBeenCalledWith(1);
});
...
});
describe('when the url matches: item/:pageId', () => {
beforeEach(async () => {
routeParams.next({pageId: page.id, testCount: testCount});
createComponent();
));
it('should load the item from the page', () => {
expect(service.retrievePage).toHaveBeenCalledWith(page.id);
expect(service.retrieveItem).toHaveBeenCalledWith(page.items[0].id);
});
...
});
});
更新二: 事实证明,我的新测试最有可能在首次加载时响应前一个测试的 url 参数。这显然不是我想要的,可能意味着我需要更改路由。是在 afterEach 中重置路由参数的唯一解决方案吗?有没有更好的方法来处理这个问题?
更新:
经过更多的挖掘,我不再认为这是一个异步问题,但我不确定是什么问题。我认为这与路由有关,或者可能与订阅有关。 这是我投入组件的一些控制台日志中的失败测试场景示例。
//This is an emulated copy of what I see in the console
new request:
> {pageId: 3, testCount: 1}
retrievePage: making the request with:
> {pageId: 3, testCount: 1}
new request:
> {id: 2, testCount: 2}
retrieveItem: making the request with:
> {id: 2, testCount: 2}
retrieveItem: made the request with:
> {id: 2, testCount: 2}
FAILED MyComponent when the url matches item/:id should load the appropriate item
retrievePage: made the request with:
> {pageId: 3, testCount: 1}
> error properties: Object({ originalStack: 'Error: Expected spy service.retrievePage not to have been called.....
我更新了下面的代码片段以反映我添加的控制台日志。
正如您从日志中看到的那样,出于某种原因,params subscribe 在第一次测试中第二次获得 运行(第一次测试 运行 并且完成得很好,记录了“新请求" 和 "making..."/"made..." 消息仅一次)。我不知道是否有办法确定这是哪个测试组件运行(如果由于某种原因生成了新组件并响应先前测试的参数)或什么。我不确定到底是什么问题或如何进一步调试。
原题:
我在测试我的 运行 一些异步代码的组件时遇到问题。由于 NDA,我无法 post 实际代码,但我提供了一个尽可能接近我的真实代码的简单表示。
我有一个组件 运行 像这样的异步代码(仅包含相关位,隐含默认 imports/setup):
...
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { MyService } from 'services/my-service';
@Component({
...
})
export class MyComponent implements OnInit, OnDestroy {
private paramsSubscription: Subscription;
constructor(private route: ActivatedRoute, private service: MyService) {}
ngOnInit(): void {
this.paramsSubscription = this.route.params.subscribe(async params => {
console.log('new request: ', params);
let result;
let itemId = params.id;
if (params.pageId) {
console.log('retrievePage: making the request with: ', params);
let page = await this.service.retrievePage(params.pageId).toPromise();
console.log('retrievePage: made the request with: ', params);
itemId = page.items[0].id;
}
if (itemId) {
console.log('retrieveItem: making the request with: ', params);
result = await this.service.retrieveItem(itemId).toPromise();
console.log('retrieveItem: made the request with: ', params);
}
...
});
}
ngOnDestroy() {
if (this.paramsSubscription) this.paramsSubscription.unsubscribe();
}
}
然后我有一个这样的单元测试(也是示例代码):
...
import { fakeAsync, tick } from '@angular/core/testing';
import { of, ReplaySubject } from 'rxjs';
import { ActivatedRoute, Params } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MyService } from 'services/my-service';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let service = jasmine.createSpyObj('MyService', {
retrieveItem: of([]),
retrievePage: of([])
});
let route: ActivatedRoute;
let routeParams = new ReplaySubject<Params>(1);
let testCount = 0;
let item = {
id: 2,
...
};
let page = {
id: 3,
items: [item]
...
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MyComponent],
imports: [RouterTestingModule],
providers: [
{ provide: MyService, useValue: service },
{ provide: ActivatedRoute, useValue:
jasmine.createSpyObj('ActivatedRoute',[], {
params: routeParams.asObservable()
})
},
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
service = TestBed.inject(MyService);
route = TestBed.inject(ActivatedRoute);
testCount++;
fixture.detectChanges();
});
afterEach(() => {
service.retrieveItem.calls.reset();
service.retrievePage.calls.reset();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when the url matches: item/:id', () => {
beforeEach(fakeAsync(() => {
routeParams.next({id: item.id, testCount: testCount});
tick();
fixture.detectChanges();
}));
it('should load the appropriate item', () => {
expect(service.retrieveItem).toHaveBeenCalledWith(item.id);
expect(service.retrievePage).not.toHaveBeenCalled();
});
...
});
describe('when the url matches: item', () => {
beforeEach(fakeAsync(() => {
routeParams.next({testCount: testCount});
tick();
fixture.detectChanges();
}));
it('should load the first item', () => {
expect(service.retrievePage).not.toHaveBeenCalled();
expect(service.retrieveItem).toHaveBeenCalledWith(1);
});
...
});
describe('when the url matches: item/:pageId', () => {
beforeEach(fakeAsync(() => {
routeParams.next({pageId: page.id, testCount: testCount});
tick();
fixture.detectChanges();
}));
it('should load the item from the page', () => {
expect(service.retrievePage).toHaveBeenCalledWith(page.id);
expect(service.retrieveItem).toHaveBeenCalledWith(page.items[0].id);
});
...
});
});
我遇到的问题是在我的单元测试中,我的组件的 await 调用在进入下一个单元测试之前尚未完成,因此我的 expect 调用无法正常运行。当我以完美的顺序测试 运行 时,一切都很好。但有时我的测试 运行 以不同的顺序说 1、3、2。当他们这样做时,测试 # 2 失败,因为它期望 retrieveItem 已使用 id 2 调用,但之前的调用以 id 1 触发了它。 (这只是一个人为的例子,我的实际代码比这更复杂)
我想知道的是,如何告诉 jasmine 等待组件内触发的调用 return。我已经尝试查看关于 jasmine https://jasmine.github.io/tutorials/async 的异步代码的教程,但这些教程似乎都提到了在我的测试中手动进行异步调用,这不是我想要做的。
带有 tick() 的 fakeAsync 是我试图让它等待路由和一切完成的尝试,但它似乎没有解决我的问题。有谁知道我做错了什么或如何解决我的问题?
我也试过:
- 在刻度内输入一个值,例如打勾(10)
- 使用 fixture.whenStable().then(() => { //期待内容})
此外,如果我添加 routeParams.next({});到我的 afterEach() 它解决了这个问题,但我觉得这违背了测试 运行dom 顺序的目的,因为这意味着我的代码只有在你没有参数的情况下才会起作用,这在我的实际实现中您可以在 url 中使用各种参数到达这里(例如,我可以从 pageId: 1 到 id: 2,没有中间步骤)。
更多信息:在我的真实代码中,它是这样发生的:
//This is pseudo code
params.subscribe
if(params.first) param1 = params.first;
if(params.second) param2 = params.second;
if (param1) {
item = function1();
param2 = item.param2;
}
if(param2) {
otherItem = await function2();
} else if (item) {
result = await function3();
}
我的测试是检查当 activatedRoute 传递各种参数时是否调用了 function1、function2 和 function3。我在我的组件中添加了控制台日志,以确认 test2 将启动,并且在 test2 启动后调用 test1 中的 function2。所以我检查以确保 function2 没有被调用,但失败了,因为它从 test1 延迟了。
非常感谢任何建议或提示。
经过大量的挖掘和谷歌搜索,我自己找到了解决问题的方法,所以我在这里分享它,以防其他人和我一样困惑。
https://angular.io/guide/testing-components-scenarios#testing-with-activatedroutestub
在 angular 文档中,当他们使用 activatedRoutes 时,我注意到他们首先触发路由,然后创建组件。通过观察应用程序的正常工作方式(而不是在测试期间),它更符合 angular 的实际功能 - 路由发生在组件创建之前。基于我的解决方案,我最终将我的测试重新设计如下:
...
import { async } from '@angular/core/testing';
import { of, ReplaySubject } from 'rxjs';
import { ActivatedRoute, Params } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MyService } from 'services/my-service';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let service = jasmine.createSpyObj('MyService', {
retrieveItem: of([]),
retrievePage: of([])
});
let route: ActivatedRoute;
let routeParams = new ReplaySubject<Params>(1);
let testCount = 0;
let item = {
id: 2,
...
};
let page = {
id: 3,
items: [item]
...
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MyComponent],
imports: [RouterTestingModule],
providers: [
{ provide: MyService, useValue: service },
{ provide: ActivatedRoute, useValue:
jasmine.createSpyObj('ActivatedRoute',[], {
params: routeParams.asObservable()
})
},
]
}).compileComponents();
}));
beforeEach(() => {
service = TestBed.inject(MyService);
route = TestBed.inject(ActivatedRoute);
});
function createComponent() {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}
afterEach(() => {
service.retrieveItem.calls.reset();
service.retrievePage.calls.reset();
});
it('should create', () => {
createComponent();
expect(component).toBeTruthy();
});
describe('when the url matches: item/:id', () => {
beforeEach(async () => {
routeParams.next({id: item.id, testCount: testCount});
createComponent();
});
it('should load the appropriate item', () => {
expect(service.retrieveItem).toHaveBeenCalledWith(item.id);
expect(service.retrievePage).not.toHaveBeenCalled();
});
...
});
describe('when the url matches: item', () => {
beforeEach(async () => {
routeParams.next({testCount: testCount});
createComponent();
});
it('should load the first item', () => {
expect(service.retrievePage).not.toHaveBeenCalled();
expect(service.retrieveItem).toHaveBeenCalledWith(1);
});
...
});
describe('when the url matches: item/:pageId', () => {
beforeEach(async () => {
routeParams.next({pageId: page.id, testCount: testCount});
createComponent();
));
it('should load the item from the page', () => {
expect(service.retrievePage).toHaveBeenCalledWith(page.id);
expect(service.retrieveItem).toHaveBeenCalledWith(page.items[0].id);
});
...
});
});