You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.31 18:15:59