stijn.aerts
stijn.aerts

Reputation: 6206

Mocking router.events.subscribe() Angular2

In my app.component.ts I have the following ngOnInit function:

ngOnInit() {
    this.sub = this.router.events.subscribe(e => {
      if (e instanceof NavigationEnd) {
        if (!e.url.includes('login')) {
          this.loggedIn = true;
        } else {
          this.loggedIn = false;
        }
      }
    });
  }

Currently I'm testing if the sub is not null but I want to test the function with a 100% coverage.

I want to mock the router object so that I can simulate the URL and then test if the this.loggedIn is correctly set.

How would I proceed to mock this function? I tried it but I don't know how I would take this on with the callback involved and with the NavigationEnd.

Upvotes: 41

Views: 49280

Answers (8)

Patronaut
Patronaut

Reputation: 1370

The easiest method is probably this:

(router.events as any) = new BehaviorSubject(
      new NavigationEnd(0, 'http://localhost:4200/plain', `http://localhost:4200/plain`)
    );

If you are using Jest for testing and you are detecting the NavigationEnd event of the Router in ngOnInit() of a component, make sure to run the test in the waitForAsync() test method.

Here is an example:

export class CatchPageComponent implements OnInit {
  constructor(private myService: MyService, private router: Router) {}

  ngOnInit(): void {
    this.router.events
      .pipe(
        filter((e): e is NavigationEnd => e instanceof NavigationEnd),
        take(1) //needed to prevent infinite loop since we are triggering more navigation events in the subscription
      )
      .subscribe((navEnd) => {
        const url = new URL(navEnd.urlAfterRedirects, 'http://localhost:8080'); //base is not relevant, just to be able to create URL to parse
        const path = url.pathname;
        if ('/fancy' === path) {
          this.myService.myFunction()
        } else {
          this.router.navigate(['plain']);
        }
      });
  }
}
describe('CatchPageComponent', () => {
  let component: CatchPageComponent;
  let fixture: ComponentFixture<CatchPageComponent>;
  let myService: MyService;
  let router: Router;

beforeEach(async () => {
    await TestBed.configureTestingModule({
      providers: [
        MyService,
      ],
      imports: [
        CatchPageModule,
        RouterTestingModule,
      ],
      declarations: [CatchPageComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(CatchPageComponent);
    router = TestBed.inject(Router);
    myService = TestBed.inject(MyService);
    component = fixture.componentInstance;

    fixture.detectChanges();
  });

  it('should call myService when route is fancy', waitForAsync(() => {
   //simple mock of the NavigationEnd event
    (router.events as any) = new BehaviorSubject(
      new NavigationEnd(0, 'http://localhost:4200/fancy', `http://localhost:4200/fancy`)
    );

    const myFunctionSpy = jest.spyOn(myService, 'myFunction');

    component.ngOnInit();

    expect(myFunctionSpy).toHaveBeenCalledTimes(1);
  }));
});

EDIT 1:

So I have realized this.router.navigate(['plain']); kept the second test hanging. If you need it in your implementation, you should mock it either in the beforeEach() method or in a specific test like this jest.spyOn(router, 'navigate').mockImplementation(() => Promise.resolve(true));

Here it is:

  it('should redirect to plain page', waitForAsync(() => {
    jest.spyOn(router, 'navigate').mockImplementation(() => Promise.resolve(true));  //mocking navigate

    (router.events as any) = new BehaviorSubject(
      new NavigationEnd(0, 'http://localhost:4200/plain', `http://localhost:4200/plain`)
    );

    component.ngOnInit();

    expect(router.navigate).toHaveBeenCalledWith(['plain']);
  }));

Upvotes: 0

Yuqiu G.
Yuqiu G.

Reputation: 345

  • use ReplaySubject<RouterEvent> to mock the router.events
  • export it to a service so that you can test it more independently from the component, and other components may also want to use this service ;)
  • use filter instead of instanceof
  • then add the tests for the component, I think that will be trivial right :)

source code

import {Injectable} from '@angular/core';
import {NavigationEnd, Router, RouterEvent} from '@angular/router';
import {filter, map} from 'rxjs/operators';
import {Observable} from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class RouteEventService {
  constructor(private router: Router) {
  }

  subscribeToRouterEventUrl(): Observable<string> {
    return this.router.events
      .pipe(
        filter(event => event instanceof NavigationEnd),
        map((event: RouterEvent) => event.url)
      );
  }
}

test code

import {TestBed} from '@angular/core/testing';

import {RouteEventService} from './route-event.service';
import {NavigationEnd, NavigationStart, Router, RouterEvent} from '@angular/router';
import {Observable, ReplaySubject} from 'rxjs';

describe('RouteEventService', () => {
  let service: RouteEventService;
  let routerEventReplaySubject: ReplaySubject<RouterEvent>;
  let routerMock;

  beforeEach(() => {
    routerEventReplaySubject = new ReplaySubject<RouterEvent>(1);
    routerMock = {
      events: routerEventReplaySubject.asObservable()
    };

    TestBed.configureTestingModule({
      providers: [
        {provide: Router, useValue: routerMock}
      ]
    });    
    service = TestBed.inject(RouteEventService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  describe('subscribeToEventUrl should return route equals to mock url on firing', () => {
    it('NavigationEnd', () => {
      const result: Observable<string> = service.subscribeToRouterEventUrl();
      const url = '/mock';

      result.subscribe((route: string) => {
        expect(route).toEqual(url);
      });

      routerEventReplaySubject.next(new NavigationEnd(1, url, 'redirectUrl'));
    });

    it('NavigationStart', () => {
      const result: Observable<string> = service.subscribeToRouterEventUrl();
      const url = '/mock';

      result.subscribe((route: string) => {
        expect(route).toBeNull();
      });

      routerEventReplaySubject.next(new NavigationStart(1, url, 'imperative', null));
    });
  });
});

Upvotes: 5

Tonio
Tonio

Reputation: 5044

The accepted answer is correct but this is a bit simpler, you can replace

public ne = new NavigationEnd(0, 'http://localhost:4200/login', 'http://localhost:4200/login');
public events = new Observable(observer => {
    observer.next(this.ne);
    observer.complete();
  });

by:

public events = Observable.of( new NavigationEnd(0, 'http://localhost:4200/login', 'http://localhost:4200/login'));

And find below a full test file to test the function in the question:

import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
  async,
  TestBed,
  ComponentFixture
} from '@angular/core/testing';

/**
 * Load the implementations that should be tested
 */
import { AppComponent } from './app.component';

import { NavigationEnd, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';


class MockServices {
  // Router
  public events = Observable.of( new NavigationEnd(0, 'http://localhost:4200/login', 'http://localhost:4200/login'));
}

describe(`App`, () => {
  let comp: AppComponent;
  let fixture: ComponentFixture<AppComponent>;
  let router: Router;

  /**
   * async beforeEach
   */
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ AppComponent ],
      schemas: [NO_ERRORS_SCHEMA],
      providers: [
        { provide: Router, useClass: MockServices },
      ]
    })
    /**
     * Compile template and css
     */
    .compileComponents();
  }));

  /**
   * Synchronous beforeEach
   */
  beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp    = fixture.componentInstance;

    router = fixture.debugElement.injector.get( Router);

    /**
     * Trigger initial data binding
     */
    fixture.detectChanges();
  });

  it(`should be readly initialized`, () => {
    expect(fixture).toBeDefined();
    expect(comp).toBeDefined();
  });

  it('ngOnInit() - test that this.loggedIn is initialised correctly', () => {
    expect(comp.loggedIn).toEqual(true);
  });

});

Upvotes: 18

Chris Lang
Chris Lang

Reputation: 384

The Angular Testing Documentation shows how to do this using a Jasmine spy:

const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);
const heroServiceSpy = jasmine.createSpyObj('HeroService', ['getHeroes']);

TestBed.configureTestingModule({
  providers: [
    { provide: HeroService, useValue: heroServiceSpy },
    { provide: Router,      useValue: routerSpy }
  ]
})

...

it('should tell ROUTER to navigate when hero clicked', () => {

  heroClick(); // trigger click on first inner <div class="hero">

  // args passed to router.navigateByUrl() spy
  const spy = router.navigateByUrl as jasmine.Spy;
  const navArgs = spy.calls.first().args[0];

  // expecting to navigate to id of the component's first hero
  const id = comp.heroes[0].id;
  expect(navArgs).toBe('/heroes/' + id,
    'should nav to HeroDetail for first hero');
});

Upvotes: 1

stijn.aerts
stijn.aerts

Reputation: 6206

I have found the answer, if someone is looking for it:

import { NavigationEnd } from '@angular/router';
import { Observable } from 'rxjs/Observable';

class MockRouter {
  public ne = new NavigationEnd(0, 'http://localhost:4200/login', 'http://localhost:4200/login');
  public events = new Observable(observer => {
    observer.next(this.ne);
    observer.complete();
  });
}

class MockRouterNoLogin {
  public ne = new NavigationEnd(0, 'http://localhost:4200/dashboard', 'http://localhost:4200/dashboard');
  public events = new Observable(observer => {
    observer.next(this.ne);
    observer.complete();
  });
}

Upvotes: 41

Joey Gutierrez
Joey Gutierrez

Reputation: 329

This is a really old question but I just came across it looking for something better than what i have and in my case i need to test several different events. My basic approach was just to change the Router.events to a non read only value like

 (router as any).events = new BehaviorSubject<any>(null);
 fixture.detectChanges();
 router.events.next(new NavigationEnd(0, 'http://localhost:4200/login', 
 'http://localhost:4200/login'));
  expect(comp.loggedIn).toEqual(true);

Hope maybe that helps someone. I couldn't find an easier solution after looking around

Upvotes: 6

hugo machefer
hugo machefer

Reputation: 21

The previous example public events = Observable.of( new NavigationEnd(0, 'http://localhost..')); does not seem to work according to Karma which complains about:

Failed: undefined is not an object (evaluating 'router.routerState.root') rootRoute@http://localhost:9876/_karma_webpack_/vendor.bundle.js

Despite (mocked) Router instance events' subscription callback has been running successfully in ngOninit() of original app.component.ts, i.e main application component under testing by Karma:

this.sub = this.router.events.subscribe(e => { // successful execution across Karma

Indeed, the way Router has been mocked sort of looks incomplete, inaccurate as a structure from Karma's prospective: because of router.routerState that turns out to be undefined at run time.

Here is how Angular Router has been "stubbed" exactly on my side, including RoutesRecognized events articifically baked as Observables in my case:

class MockRouter {
    public events = Observable.of(new RoutesRecognized(2 , '/', '/',
                                  createRouterStateSnapshot()));
}

const createRouterStateSnapshot = function () {
    const routerStateSnapshot = jasmine.createSpyObj('RouterStateSnapshot', 
                                                     ['toString', 'root']);
    routerStateSnapshot.root = jasmine.createSpyObj('root', ['firstChild']);
    routerStateSnapshot.root.firstChild.data = {
        xxx: false
    };
    return <RouterStateSnapshot>routerStateSnapshot;
};

to fit what ngOnInit() body expects, requiring RoutesRecognized event with deep structure:

ngOnInit() {
   this.router.events.filter((event) => {
        return event instanceof RoutesRecognized;
    }).subscribe((event: RoutesRecognized) => {
        // if (!event.state.root.firstChild.data.xxx) {
        // RoutesRecognized event... to be baked from specs mocking strategy
   });
}

Recap / summary of my <package.json> content:

angular/router: 5.2.9, karma: 2.0.2, jasmine-core: 2.6.4, karma-jasmine: 1.1.2

Upvotes: 2

ForrestLyman
ForrestLyman

Reputation: 1652

I created a version of the router stub from Angular docs that uses this method to implement NavigationEnd event for testing:

import {Injectable} from '@angular/core';
import { NavigationEnd } from '@angular/router';
import {Subject} from "rxjs";

@Injectable()
export class RouterStub {
  public url;
  private subject = new Subject();
  public events = this.subject.asObservable();

  navigate(url: string) {
    this.url = url;
    this.triggerNavEvents(url);
  }

  triggerNavEvents(url) {
    let ne = new NavigationEnd(0, url, null);
    this.subject.next(ne);
  }
}

Upvotes: 16

Related Questions