Angular中void异步方法loadIssues()的测试阻塞问题求解
问题描述
在Angular的BurndownComponent组件中定义了返回值为void的loadIssues()方法,该方法嵌套调用多个异步方法(GitlabService的异步方法及组件自身的异步方法),最终填充组件的issues数组。编写测试用例时,因方法返回void无法直接用async/await等待异步操作完成,尝试setTimeout也无法实现同步等待,导致测试始终失败,求有效测试方案。
相关代码
loadIssues()实现
loadIssues(): void { this.selectedMilestone = this.milestone; this.issues = []; console.log(this.selectedMilestone.reviewDate); this.gitlabService.getIssues(this.selectedMilestone?.title).then(issues => { let allIssues = issues.filter(i => [RequirementLabel.StoryTask, RequirementLabel.Task, RequirementLabel.Story].includes(i.requirement)); this.getIssueEvents(allIssues).then(issues => { allIssues = issues; console.log('allIssues ', allIssues.length); // issues could be moved out of the milestone towards the end of it // we consider a limit of 3 days before the review meeting date if (new Date().getTime() >= this.selectedMilestone.reviewDate.getTime() - (3 * MILLISECONDS_PER_DAY)) { this.getMilestoneRolledIssues().then(rolledIssues => { const issuesIds = allIssues.map(i => i.id); console.log(issuesIds); allIssues = allIssues.concat(...rolledIssues.filter(i => !issuesIds.includes(i.id))); // removes duplicated issues this.gitlabService.getDiscussions(allIssues).then(discussions => { allIssues.forEach((issue, index) => issue.discussions = discussions[index]); this.issues = allIssues; }); }); } else { this.gitlabService.getDiscussions(allIssues).then(discussions => { allIssues.forEach((issue, index) => issue.discussions = discussions[index]); this.issues = allIssues; }); } }); }); }
测试尝试代码
describe('BurndownComponent', () => { let component: BurndownComponent; let fixture: ComponentFixture<BurndownComponent>; const data: object = jsonData; let httpMock: object; let stubGitLabService: GitlabService; beforeEach(async () => { httpMock = { 'get': (url, headers): Observable<object[]> => { const endpoint = 'https://git.tecgraf.puc-rio.br/api/v4/'; const discussions = data['discussions-test'][0]['discussions'] .map(d => Discussion.getDiscussion(d)); const urlDiscussions = [ `${endpoint}projects/1710/issues/120/discussions`, `${endpoint}projects/1710/issues/97/discussions`, `${endpoint}projects/1210/issues/920/discussions` ]; if(urlDiscussions.includes(url)) { return new Observable(subscriber => discussions[urlDiscussions.indexOf(url)]); } return new Observable(subscriber => null); } } stubGitLabService = new GitlabService(<any> httpMock); await TestBed.configureTestingModule({ declarations: [ BurndownComponent ], providers: [ { provide: GitlabService, useValue: stubGitLabService } ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(BurndownComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('loads all issues - loadIssues()', async () => { const milestoneData = Milestone.getMilestone(data['milestone-test'][0]); const milestoneEventsData = data['all-milestone-events'][0]['events'] .map(me => MilestoneEvent.getMilestoneEvent(me)); const labelEventsData = data['label-events-burndown-component-test'][0]['events'] .map(le => LabelEvent.getLabelEvent(le)); const issues = data['issues-test-passed-milestone'] .map(i => Issue.getIssue(i)); const discussions = data['discussions-test'][0]['discussions'] .map(d => Discussion.getDiscussion(d)); issues.forEach((issue, index) => { issue.labelEvents = labelEventsData.map(le => LabelEvent.copy(le)); issue.milestoneEvents = milestoneEventsData.map(me => MilestoneEvent.copy(me)); }); component.milestone = milestoneData; stubGitLabService['getDiscussions'] = (issues: Issue[]): Promise<Discussion[][]> => { return new Promise<Discussion[][]>(resolve => resolve(discussions)) }; const spyMilestoneRolledIssues = spyOn(component, 'getMilestoneRolledIssues') .and .returnValue(Promise.resolve(issues)); const spyIssueEvents = spyOn(component, 'getIssueEvents') .and .returnValue(Promise.resolve(issues)); const getDiscussionsSpy = spyOn(stubGitLabService, 'getDiscussions') .and .returnValue(new Promise( resolve => { console.log('discussions'); resolve(discussions) } )); await component.loadIssues(); expect(component.issues.length).toBe(3); expect(spyMilestoneRolledIssues).toHaveBeenCalled(); expect(getDiscussionsSpy).toHaveBeenCalled(); }); });
解决方案
方案1:重构loadIssues()返回Promise(推荐)
虽然原方法是void,但可以修改它返回Promise,让测试能直接用async/await等待异步流程完成:
loadIssues(): Promise<void> { this.selectedMilestone = this.milestone; this.issues = []; console.log(this.selectedMilestone.reviewDate); return this.gitlabService.getIssues(this.selectedMilestone?.title).then(issues => { let allIssues = issues.filter(i => [RequirementLabel.StoryTask, RequirementLabel.Task, RequirementLabel.Story].includes(i.requirement)); return this.getIssueEvents(allIssues).then(issues => { allIssues = issues; console.log('allIssues ', allIssues.length); if (new Date().getTime() >= this.selectedMilestone.reviewDate.getTime() - (3 * MILLISECONDS_PER_DAY)) { return this.getMilestoneRolledIssues().then(rolledIssues => { const issuesIds = allIssues.map(i => i.id); allIssues = allIssues.concat(...rolledIssues.filter(i => !issuesIds.includes(i.id))); return this.gitlabService.getDiscussions(allIssues).then(discussions => { allIssues.forEach((issue, index) => issue.discussions = discussions[index]); this.issues = allIssues; }); }); } else { return this.gitlabService.getDiscussions(allIssues).then(discussions => { allIssues.forEach((issue, index) => issue.discussions = discussions[index]); this.issues = allIssues; }); } }); }); }
修改后测试用例只需保留await component.loadIssues(),即可确保异步操作完成后再执行断言。
方案2:不修改原代码,用fakeAsync+tick()
利用Angular测试工具fakeAsync模拟可控异步环境,tick()快进时间等待所有异步操作完成:
it('loads all issues - loadIssues()', fakeAsync(() => { const milestoneData = Milestone.getMilestone(data['milestone-test'][0]); const milestoneEventsData = data['all-milestone-events'][0]['events'] .map(me => MilestoneEvent.getMilestoneEvent(me)); const labelEventsData = data['label-events-burndown-component-test'][0]['events'] .map(le => LabelEvent.getLabelEvent(le)); const issues = data['issues-test-passed-milestone'] .map(i => Issue.getIssue(i)); const discussions = data['discussions-test'][0]['discussions'] .map(d => Discussion.getDiscussion(d)); issues.forEach((issue, index) => { issue.labelEvents = labelEventsData.map(le => LabelEvent.copy(le)); issue.milestoneEvents = milestoneEventsData.map(me => MilestoneEvent.copy(me)); }); component.milestone = milestoneData; stubGitLabService['getDiscussions'] = (issues: Issue[]): Promise<Discussion[][]> => { return Promise.resolve(discussions); }; const spyMilestoneRolledIssues = spyOn(component, 'getMilestoneRolledIssues') .and.returnValue(Promise.resolve(issues)); const spyIssueEvents = spyOn(component, 'getIssueEvents') .and.returnValue(Promise.resolve(issues)); const getDiscussionsSpy = spyOn(stubGitLabService, 'getDiscussions') .and.returnValue(Promise.resolve(discussions)); component.loadIssues(); // 快进时间,等待所有Promise完成 tick(); // 触发变更检测,确保组件属性更新 fixture.detectChanges(); expect(component.issues.length).toBe(3); expect(spyMilestoneRolledIssues).toHaveBeenCalled(); expect(getDiscussionsSpy).toHaveBeenCalled(); }));
关键说明:
fakeAsync包裹测试函数,创建一个可控制的异步上下文tick()会等待所有处于Pending状态的Promise完成,实现同步等待异步流程结束fixture.detectChanges()确保组件的属性变更被检测到,断言能捕获到最新值
方案3:覆盖日期分支逻辑
loadIssues()包含日期判断分支,需分别测试两种场景:
// 测试日期在3天缓冲期内的场景(调用getMilestoneRolledIssues) it('loads rolled issues when within 3 days of review date', fakeAsync(() => { const milestoneData = Milestone.getMilestone(data['milestone-test'][0]); // 设置reviewDate为当前时间+2天,使当前时间处于缓冲期内 milestoneData.reviewDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); component.milestone = milestoneData; const spyMilestoneRolledIssues = spyOn(component, 'getMilestoneRolledIssues') .and.returnValue(Promise.resolve([])); component.loadIssues(); tick(); expect(spyMilestoneRolledIssues).toHaveBeenCalled(); })); // 测试日期在3天缓冲期外的场景(不调用getMilestoneRolledIssues) it('does not load rolled issues when outside 3 days of review date', fakeAsync(() => { const milestoneData = Milestone.getMilestone(data['milestone-test'][0]); // 设置reviewDate为当前时间+4天,使当前时间处于缓冲期外 milestoneData.reviewDate = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000); component.milestone = milestoneData; const spyMilestoneRolledIssues = spyOn(component, 'getMilestoneRolledIssues') .and.returnValue(Promise.resolve([])); component.loadIssues(); tick(); expect(spyMilestoneRolledIssues).not.toHaveBeenCalled(); }));
内容的提问来源于stack exchange,提问作者igryn
相关产品推荐
相关产品推荐

