efarley
efarley

Reputation: 8681

Angular 2 how do I test a component that uses router

I am trying to write some tests for a component that calls router.navigate() and I'm stuck on errors with declaring the routes. I've read a lot of things and tried all of them but they all lead to some error or another. I am using Angular 4.0.0.

Component:

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
  constructor(
    private activatedRoute: ActivatedRoute,
    private authService: AuthService,
    private formBuilder: FormBuilder,
    private jwtService: JwtService,
    private router: Router,
    private storageService: StorageService
  ) { ... }

  ngOnInit() {
  }

  private login(formData: any): void {
    const credentials: any = {
      email: formData.controls.email.value,
      password: formData.controls.password.value
    };
    this.authService.login(credentials).subscribe(res => {
      this.activatedRoute.params.subscribe(params => {
        if (params.returnUrl) {
          this.router.navigate([params.returnUrl]);
        } else {
          this.router.navigate(['/dashboard']);
        }
      });
    }, error => { ... });
  }
}

Test:

describe('LoginComponent', () => {
  let component: any;
  let fixture: ComponentFixture<LoginComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ LoginComponent ],
      imports: [
        SharedModule,
        RouterTestingModule
      ],
      providers: [{
        provide: AuthService,
        useClass: MockAuthService
      }, {
        provide: JwtService,
        useClass: MockJwtService
      }, {
        provide: StorageService,
        useClass: MockStorageService
      }],
      schemas: [ NO_ERRORS_SCHEMA ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  describe('login', () => {
    it('should call router.navigate with the dashboard route if the login is successful', () => {
      spyOn(component.router, 'navigate');
      component.authService.login.and.returnValue(Observable.of({ access_token: 'fake_token' }));
      component.login(component.loginForm);
      expect(component.router.navigate).toHaveBeenCalledWith(['/dashboard']);
    });
  });
});

This all gives me the following error:

zone.js:569 Unhandled Promise rejection: Cannot match any routes. URL Segment: 'dashboard'

So from there I looked into adding the route with withRoutes. I don't like that I need to include the DashboardComponent since it seems like there should be some mocked/blank component available for this especially since I don't want to actually navigate and load another route but I couldn't find anything like that:

TestBed.configureTestingModule({
  declarations: [ LoginComponent ],
  imports: [
    SharedModule,
    RouterTestingModule.withRoutes([{
      path: 'dashboard',
      component: DashboardComponent
    }])
  ],
  ...
})
.compileComponents();

However that just give me a new error:

Component DashboardComponent is not part of any NgModule or the module has not been imported into your module.

So I thought that maybe I needed to declare the DashboardComponent, so I added it to the declarations array:

TestBed.configureTestingModule({
  declarations: [ LoginComponent, DashboardComponent ],
  ..
})
.compileComponents();

However that just lead to yet another error:

Unhandled Promise rejection: Cannot find primary outlet to load 'DashboardComponent'

At this point it seems like there must be a simpler way to do this as it's a very common scenario but I've tried everything others say they used and everything just leads further down this rabbit hole.

Upvotes: 4

Views: 5815

Answers (4)

mtpultz
mtpultz

Reputation: 18328

I found a slightly different solution then @efarley, which takes his answer and changes what the injector is provided. Thought I'd add this in as well in case it helps anyone. I used the Angular CLI so the spec is setup with the default blueprint for a test, and I've removed bits for brevity.

// NOTE: Other required test imports removed for brevity so this snippet
// only represents the minimum viable code required to test a route
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let de: DebugElement;
  let el: HTMLElement;

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

  beforeEach(() => {
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    de = fixture.debugElement;
    fixture.detectChanges();
  });

  it('form submission login and navigating to the dashboard', () => {
    el = de.nativeElement.querySelector('button[type="submit"]');

    // **ONLY** difference between the solutions is what is passed to the injector
    spyOn(de.injector.get(Router), 'navigate');
    el.click();

    expect(de.injector.get(Router).navigate)
      .toHaveBeenCalledWith(['dashboard']);
  });

Upvotes: 2

efarley
efarley

Reputation: 8681

The solution turned out to be really simple...

Simply adding the RouterTestingModule was almost there, only I needed to spy on router.navigate in all tests to prevent them from trying to actually navigate to another route.

describe('LoginComponent', () => {
  let component: any;
  let fixture: ComponentFixture<LoginComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ LoginComponent ],
      imports: [
        SharedModule,
        RouterTestingModule   // This provides the mock router, location and routerLink
      ],
      providers: [{
        provide: AuthService,
        useClass: MockAuthService
      }, {
        provide: JwtService,
        useClass: MockJwtService
      }, {
        provide: StorageService,
        useClass: MockStorageService
      }],
      schemas: [ NO_ERRORS_SCHEMA ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    spyOn(component.router, 'navigate');    // This prevents every test from calling the real router.navigate which means I don't need to add routes to RouterTestingModule
  });

  describe('login', () => {
    it('should call router.navigate with the dashboard route if the login is successful', () => {
      spyOn(component.router, 'navigate');
      component.authService.login.and.returnValue(Observable.of({ access_token: 'fake_token' }));
      component.login(component.loginForm);
      expect(component.router.navigate).toHaveBeenCalledWith(['/dashboard']);
    });
  });
});

Upvotes: 4

Paul Samsotha
Paul Samsotha

Reputation: 209112

spyOn(component.router, 'navigate');

You can't do this because the real Router need a bunch of other things to be configured in order to work. You should just create a mock of the Router that doesn't require anything.

providers: [
  {
    provide: Router,
    useValue: { navigate: jasmine.createSpy('navigate') }
  }
]

Now you can do

expect(component.router.navigate).toHaveBeenCalledWith(['/dashboard']);

But the other problem is that you can call it synchronously. In side your login method, you have two different levels of asynchronous calls

this.authService.login(credentials).subscribe(res => {
  this.activatedRoute.params.subscribe(params => {

So you need to wait for these to complete. You could just use fakeAsync and tick

it('..', fakeAsync(() => {
  ..login()
  tick()
  expect(component.router.navigate).toHaveBeenCalledWith(['/dashboard']);
}))

I'm not quite sure how it would work in this case as you have two levels of asynchronous calls. I'm not sure if the tick() will just wait one turn or if it will also capture the second asynchronous call also. If it doesn't work, you can try to call tick again or call tick with a delay tick(someMilliseconds)

Upvotes: 1

JayChase
JayChase

Reputation: 11525

You can use the RouterTestingModule. The test guide has information here.

Upvotes: 0

Related Questions