Search code examples
angularjasminekarma-jasmineangular-routingangular-test

Angular - Testing Routerguard


I'm currently struggling on unit testing my canActivate() method from my Routerguard Service. The Service looks as follows:

import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router} from '@angular/router';
import {AuthService} from '../../auth/auth.service';
import {Observable, of} from 'rxjs';
import {NotificationService} from '../../../../shared/services/notification.service';
import {concatMap, map, take, tap} from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class ProfileGuard implements CanActivate {

  constructor(private auth: AuthService, private router: Router,
              private notification: NotificationService) {
  }

  canActivate(next: ActivatedRouteSnapshot): Observable<boolean> {
    // checks for user if not - page not found
    return this.auth.getUserEntity(next.params.uid).pipe(concatMap(user => {
      if (user) {
        // checks for permission if not - redirect to user overview
          return this.auth.currentUser.pipe(
            take(1),
            map(current => this.auth.canEditProfile(current, next.params)),
            tap(canEdit => {
              if (!canEdit) {
                this.router.navigate([`/profile/${next.params.uid}`]).then(() =>
                  this.notification.danger('Access denied. Must have permission to edit profile.'));
              }
            })
          );
      } else {
        this.router.navigate(['/page-not-found']);
        return of(false);
      }
    }));
  }
}

It actually looks more complicated than it is: The first observer checks if there is a user in the db with the params value as unique identifier. The second observer checks then for the permission to edit this user. Now on the unit testing part of things:

describe('RouterGuardService', () => {

  const routerStub: Router = jasmine.createSpyObj('Router', ['navigate']);
  const authStub: AuthService = jasmine.createSpyObj('AuthService', ['getUserEntity', 'currentUser', 'canEditProfile']);
  const notificationStub: NotificationService = jasmine.createSpyObj('NotificationService', ['danger']);

  function createInputRoute(url: string): ActivatedRouteSnapshot {
    const route: ActivatedRouteSnapshot = new ActivatedRouteSnapshot();
    const urlSegs: UrlSegment[] = [];
    urlSegs.push(new UrlSegment(url, {}));
    route.url = urlSegs;
    route.params = {
      uid: url.replace('/profile/', '')
        .replace('/edit', '')
    };
    return route;
  }

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {provide: AuthService, useValue: authStub},
        {provide: Router, useValue: routerStub},
        {provide: NotificationService, useValue: notificationStub},
        ProfileGuard]
    });
  });

 it('should redirect to user overview - if has not permission', inject([ProfileGuard], (service: ProfileGuard) => {
  (<jasmine.Spy>authStub.canEditProfile).and.returnValue(false);
  authStub.currentUser = of(<any>{uid: 'jdkffdjjfdkls', role: Role.USER});
  (<jasmine.Spy>authStub.getUserEntity).and.returnValue(of({uid: 'jdkffdjjfdkls', role: Role.USER}));

  const spy = (<jasmine.Spy>routerStub.navigate).and.stub();
  const notifySpy = (<jasmine.Spy>notificationStub.danger).and.stub();

  const url: ActivatedRouteSnapshot = createInputRoute('/profile/BBB/edit');
  service.canActivate(url).subscribe(res => {
    console.log(res);
    expect(spy).toHaveBeenCalledWith(['/BBB']);
    expect(notifySpy).toHaveBeenCalledWith('Access denied. Must have permission to edit profile.');
    expect(res).toBe(false);
  }, err => console.log(err));
}));
});

But my test does not check my expect methods instead it console logs the error. Can maybe anyone help me on this?


Solution

  • First issue - when you create authStub:

    const authStub: AuthService = jasmine.createSpyObj('AuthService', ['getUserEntity', 'currentUser', 'canEditProfile']);
    

    In this case you add currentUser as a Method but not as a property. The correct way to create jasmine spyObj both with methods and properties:

      const authStub = {
        ...jasmine.createSpyObj('authStub', ['getUserEntity', 'canEditProfile']),
        currentUser: of(<any>{ uid: 'jdkffdjjfdkls', role: Role.USER })
      } as jasmine.SpyObj<AuthService>;
    

    Notice, that in your example - this object mutation inside the test does not affect anything:

    authStub.currentUser = of(<any>{uid: 'jdkffdjjfdkls', role: Role.USER});
    

    The reason is that you're using useValue when providing services to TestBed and it means that the tests already got the instance of auth service which doesn't have currentUser property. This is why it's important to initialize it before running configureTestingModule method.

    Second issue - since your guard code is async, you have to write your unit tests asynchronously (you can use done, sync or fakeAsync&tick).

    Here is the final solution:

    describe('RouterGuardService', () => {
      const routerStub: Router = jasmine.createSpyObj('Router', ['navigate']);
    
      const authStub = {
        ...jasmine.createSpyObj('authStub', ['getUserEntity', 'canEditProfile']),
        currentUser: of(<any>{ uid: 'jdkffdjjfdkls', role: Role.USER })
      } as jasmine.SpyObj<AuthService>;
    
      const notificationStub: NotificationService = jasmine.createSpyObj('NotificationService', ['danger']);
    
      let profileGuardService: ProfileGuard;
    
      function createInputRoute(url: string): ActivatedRouteSnapshot {
        // ...
      }
    
      beforeEach(() => {
        TestBed.configureTestingModule({
          // ...
        });
        profileGuardService = TestBed.get(ProfileGuard);
      });
    
      it('should redirect to user overview - if has not permission', fakeAsync(() => {
        (<jasmine.Spy>authStub.canEditProfile).and.returnValue(false);
        (<jasmine.Spy>authStub.getUserEntity).and.returnValue(of({ uid: 'jdkffdjjfdkls', role: Role.USER }));
    
        const spy = (<jasmine.Spy>routerStub.navigate).and.callFake(() => Promise.resolve());
        const notifySpy = (<jasmine.Spy>notificationStub.danger).and.stub();
    
        const url: ActivatedRouteSnapshot = createInputRoute('/profile/BBB/edit');
        let expectedRes;
        profileGuardService.canActivate(url).subscribe(res => {
          expectedRes = res;
        }, err => console.log(err));
    
        tick();
        expect(spy).toHaveBeenCalledWith(['/profile/BBB']);
        expect(notifySpy).toHaveBeenCalledWith('Access denied. Must have permission to edit profile.');
        expect(expectedRes).toBe(false);
      }));
    });
    

    If you want to have different currentUser-s for each test dynamically, you can do this trick - initialize currentUser property in authStub with BehaviorSubject:

    const authStub = {
      ...jasmine.createSpyObj('authStub', ['getUserEntity', 'canEditProfile']),
      currentUser: new BehaviorSubject({})
    } as jasmine.SpyObj<AuthService>;
    

    And then inside the unit tests itself you can call next method in order to set necessary current user mock:

    it('should redirect to user overview - if has not permission', fakeAsync(() => {
      (<BehaviorSubject<any>>authStub.currentUser).next(<any>{ uid: 'jdkffdjjfdkls', role: Role.USER });
      // ...