angularjasmineangular-test

How to test an Angular service with inner subscriptions?


I have an angular 18 service which I am trying to unit test. I have a setSelected method that a consumer simply calls, and it will update the subject when it's ready

@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);
  }
}

How do I test this? I need to test that when the method is called it updates all the subjects as I expect them to, but I don't know how to either hook into or wait on that subscription inside

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);
});

Right now with the above test I just get this error

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

and I'm not sure what I'm doing wrong. How can I call the service method and test the values of my externally-facing Observables before and after the subscription completes?


Solution

  • You can use fakeAsync and flush combination to make the testcase wait for the observable.

    For BehaviorSubject, you can directly access the .value property and get the inner value.

    For accessing private properties inside an object use the test['someProperty'] syntax.

    An explanation to why testcases are commented is in the comments.

    it('should set the selected timesheet based on a passed in date', fakeAsync(() => {
      const mockSelfTimesheet = {};
      // const weekEnding = DateTime.fromISO('2024-11-02');
      // const expectedInterval = Interval.fromDateTimes(weekEnding, weekEnding);
      mockedApiService.getTimesheet$.and.returnValue(of([mockSelfTimesheet]));
    
      service.setSelected('2024-11-02');
    
      // you cannot test the below subjects because they are async actions just like the API call, so it will wait for both to complete by that time the subject becomes false
      // expect(service['isLoadingSubject$'].value).toBeTrue();
      // expect(service['selectedTimesheetSubject$'].value).toBeUndefined();
      expect(mockedApiService.getTimesheet$).toHaveBeenCalledOnceWith(
        '2024-11-02'
      );
      flush();
      //How do I check these values AFTER the subscription completes?
      expect(service['isLoadingSubject$'].value).toBeFalse();
      expect(service['selectedTimesheetSubject$'].value).toEqual([
        mockSelfTimesheet,
      ]);
    }));
    

    Stackblitz Demo