angularasynchronousjasmineobservablereplay

How to test Angular service with Observable and replay directive?


I have the following service in Angular16:

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private readonly userRoles$: Observable<string[]> =
    this.fetchUserRoles().pipe(shareReplay(1)); //<-- get user info only once

  constructor(private readonly externalService: ExternalService) {}

  fetchUserRoles(): Observable<string[]> {
    return new Observable<string[]>((observer) => {
      this.externalService.fetchUserInfo((error, claims) => {
        let userRoles: string[] = [];

        if (error) {
          console.error(
            'An error has occurred',
            error
          );
          throw new Error(error.message);
        }
        const authorizations = claims.user_authorization.find((auth) => auth.resource === 'MY_APP')?
                 .permissions.map((permission) => permission.name) ?? [];
        observer.next(userRoles);
      });
    });
  }

  isUserAuthorized$(): Observable<boolean> {
    return this.userRoles$.pipe(map((roles) => roles.length > 0));
  }
}

I want to test the isUserAuthorized$() method and I created the following spec class:

describe('AuthService', () => {
  let externalService: jasmine.SpyObj<ExternalService>;
  let authService: AuthService;

  beforeEach(() => {
    const externalServiceSpy = jasmine.createSpyObj('ExternalService', [
      'fetchUserInfo'
    ]);

    TestBed.configureTestingModule({
      providers: [
        { provide: ExternalService, useValue: externalService },
        AuthService
      ]
    });
    externalService = TestBed.inject(
      ExternalService
    ) as jasmine.SpyObj<ExternalService>;
    authService = TestBed.inject(AuthService);
  });

  it('Should mark user as authorized because he has ADMIN role', fakeAsync(() => {
    externalService.fetchUserInfo.and.stub();
    spyOn(authService, 'fetchUserRoles').and.returnValue(of(['ADMIN']));
    let result;
    authService.isUserAuthorized$().subscribe((value) => (result = value));
    tick();
    expect(result).toBeTrue();
  }));
});

Unfortunately I keep on getting the error: Expected undefined to be true. My guess is that I mock the fetchUserRoles method too late (after the service is instantiated and the real fetchUserRoles method is called). I also tried the async / await test approach, but I get a timeout error. Can you please help?


Solution

  • One problem I notice is that you need to set the spy object for useValue, but you are setting the actual class.

    The main problem is you are setting fetchUserRoles after the class is initialized, so you will not get proper values for this.userRoles$ since during execution, it did not have the spy value. To fix this, you can reinitialize the property with the proper value.

    describe('AuthService', () => {
      let externalService: jasmine.SpyObj<ExternalService>;
      let authService: AuthService;
    
      beforeEach(() => {
        const externalServiceSpy = jasmine.createSpyObj('ExternalService', [
          'fetchUserInfo'
        ]);
    
        TestBed.configureTestingModule({
          providers: [
            { provide: ExternalService, useValue: externalServiceSpy },
            AuthService
          ]
        });
        externalService = TestBed.inject(
          ExternalService
        ) as jasmine.SpyObj<ExternalService>;
        authService = TestBed.inject(AuthService);
      });
    
      it('Should mark user as authorized because he has ADMIN role', fakeAsync(() => {
        externalService.fetchUserInfo.and.stub();
        spyOn(authService, 'fetchUserRoles').and.returnValue(of(['ADMIN']));
        (authService as any).userRoles$ = authService.fetchUserRoles().pipe(shareReplay(1)); // <-- override the field here
        let result;
        authService.isUserAuthorized$().subscribe((value) => (result = value));
        flush();
        expect(result).toBeTrue();
      }));
    });