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

如何测试包含内部订阅的Angular 18服务?

Angular 18 TimesheetService 单元测试问题

我有一个Angular 18的TimesheetService需要进行单元测试,其中setSelected方法调用后,会通过内部订阅apiService.getTimesheet$来更新BehaviorSubject。当前测试代码出现超时错误,我需要验证该方法调用后,外部可观察对象selectedTimesheet$和isLoading$在订阅完成前后的状态,但不知道如何等待内部订阅完成。

服务代码

@Injectable({ providedIn: 'root' })
export class TimesheetService {
  private readonly apiService = inject(ApiService);
  
  private readonly selectedTimesheetSubject$ = new BehaviorSubject<ITimesheet| undefined>(undefined);
  private readonly isLoadingSubject$ = new BehaviorSubject(false);
  
  public readonly selectedTimesheet$ = this.selectedTimesheetSubject$.asObservable();
  public readonly isLoading$ = this.isLoadingSubject$.asObservable();
  
  public setSelected(weekEndingDate: string): void {
    this.isLoadingSubject$.next(true);
    this.error = undefined;

    this.apiService.getTimesheet$(weekEndingDate).subscribe({
      next: (res) => {
        this.isLoadingSubject$.next(false);
        this.selectedTimesheetSubject$.next(res);
      },
      error: (err) => {
        this.isLoadingSubject$.next(false);
        console.error('error!', err);
      },
    });
  }

  public clearSelected(): void {
    this.selectedTimesheetSubject$.next(undefined);
  }
}

测试代码

it('should set the selected timesheet based on a passed in date', async () => {
  const weekEnding = DateTime.fromISO('2024-11-02');
  const expectedInterval = Interval.fromDateTimes(weekEnding, weekEnding);
  injectedApiService.getTimesheet$.and.nextOneTimeWith([mockSelfTimesheet]);

  service.setSelected('2024-11-02');

  //Check that the initial values are correct BEFORE the subscription completes
  expect(await lastValueFrom(service.isLoading$)).toBeTrue();
  expect(await lastValueFrom(service.selectedTimesheet$)).toBeUndefined();
  expect(injectedApiService.getTimesheet$).toHaveBeenCalledOnceWith(expectedInterval, false);

  //How do I check these values AFTER the subscription completes?
  // expect(await lastValueFrom(service.isLoading$)).toBeFalse();
  // expect(await lastValueFrom(service.selectedTimesheet$)).toEqual(mockSelfTimesheet);
});

当前错误

Error: Timeout - Async function did not complete within 5000ms (set by jasmine.DEFAULT_TIMEOUT_INTERVAL)

解决方案

核心问题是测试中错误地用lastValueFrom订阅了永久流(BehaviorSubject的可观察对象不会主动完成),导致测试一直等待超时;同时未正确同步内部异步订阅的执行时机。

1. 推荐方案:使用fakeAsync + tick同步控制异步流

Angular的fakeAsync可以把异步操作转为同步执行,tick()会触发所有待处理的微任务,确保内部订阅的回调执行完成:

it('should set the selected timesheet based on a passed in date', fakeAsync(() => {
  const weekEnding = DateTime.fromISO('2024-11-02');
  const expectedInterval = Interval.fromDateTimes(weekEnding, weekEnding);
  const mockTimesheet = mockSelfTimesheet;
  // 模拟API返回同步流
  injectedApiService.getTimesheet$.and.returnValue(of(mockTimesheet));

  // 调用目标方法
  service.setSelected('2024-11-02');

  // 捕获初始状态(订阅未完成时)
  let currentLoading: boolean;
  let currentSelected: ITimesheet | undefined;
  
  service.isLoading$.subscribe(val => currentLoading = val);
  service.selectedTimesheet$.subscribe(val => currentSelected = val);
  
  expect(currentLoading).toBeTrue();
  expect(currentSelected).toBeUndefined();
  expect(injectedApiService.getTimesheet$).toHaveBeenCalledOnceWith(expectedInterval, false);

  // 触发所有异步操作完成
  tick();

  // 验证订阅完成后的最终状态
  expect(currentLoading).toBeFalse();
  expect(currentSelected).toEqual(mockTimesheet);
}));

2. 替代方案:使用firstValueFrom等待状态变化

如果不想用fakeAsync,可以通过过滤流的方式,等待状态变化的第一个有效值:

it('should set the selected timesheet based on a passed in date', async () => {
  const weekEnding = DateTime.fromISO('2024-11-02');
  const expectedInterval = Interval.fromDateTimes(weekEnding, weekEnding);
  const mockTimesheet = mockSelfTimesheet;
  injectedApiService.getTimesheet$.and.returnValue(of(mockTimesheet));

  // 提前订阅状态,避免错过初始值
  const loading$ = service.isLoading$;
  const selected$ = service.selectedTimesheet$;

  // 调用目标方法
  service.setSelected('2024-11-02');

  // 验证初始状态
  expect(await firstValueFrom(loading$)).toBeTrue();
  expect(await firstValueFrom(selected$)).toBeUndefined();
  expect(injectedApiService.getTimesheet$).toHaveBeenCalledOnceWith(expectedInterval, false);

  // 等待loading变为false的第一个值,确保订阅完成
  await firstValueFrom(loading$.pipe(filter(val => val === false)));
  // 验证最终状态
  expect(await firstValueFrom(selected$)).toEqual(mockTimesheet);
});

关键注意事项

  • 永远不要用lastValueFrom订阅永久流(比如BehaviorSubject导出的可观察对象),它会一直等待流完成,直接导致超时。
  • 使用fakeAsync时,确保所有异步操作都是RxJS或Angular可跟踪的,第三方异步逻辑可能无法被捕获。
  • 模拟API接口时,尽量返回同步流(比如of()),配合tick()可以让内部订阅立即执行回调。

内容的提问来源于stack exchange,提问作者Chris Barr

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.16 18:55:07