From 2fb739083aab6081f612c57330320279fe890890 Mon Sep 17 00:00:00 2001 From: Dirk Luijk Date: Fri, 20 Sep 2019 13:47:37 +0200 Subject: [PATCH] feat(routing): add option to disable stubs and use RouterTestingModule (#188) Closes https://github.com/ngneat/spectator/issues/179. --- README.md | 61 ++++++++++++-- projects/spectator/jest/src/lib/mock.ts | 8 +- .../with-routing/my-page.component.spec.ts | 2 +- projects/spectator/src/lib/mock.ts | 6 +- .../lib/spectator-routing/create-factory.ts | 6 +- .../lib/spectator-routing/initial-module.ts | 41 ++++++--- .../src/lib/spectator-routing/options.ts | 8 +- .../src/lib/spectator-routing/router-stub.ts | 9 ++ .../spectator-routing/spectator-routing.ts | 68 ++++++++++++--- .../with-routing/my-page.component.spec.ts | 83 ++++++++++++++++++- .../test/with-routing/my-page.component.ts | 2 +- 11 files changed, 255 insertions(+), 39 deletions(-) create mode 100644 projects/spectator/src/lib/spectator-routing/router-stub.ts diff --git a/README.md b/README.md index 62e89664..fb028a6a 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ describe('ButtonComponent', () => { }); ``` -### Updating Route +### Triggering a navigation The `SpectatorRouting` API includes convenient methods for updating the current route: ```ts @@ -379,11 +379,62 @@ interface SpectatorRouting extends Spectator { } ``` -### Routing Features +### Integration testing with `RouterTestingModule` -* It automatically provides a stub implementation for `ActivatedRoute` -* You can configure the `params`, `queryParams`, `fragments` and `data`. You can also update them, to test how your component reacts on changes. -* It provides a stub for `RouterLink` directives +If you set the `stubsEnabled` option to `false`, you can pass a real routing configuration +and setup an integration test using the `RouterTestingModule` from Angular. + +Note that this requires promises to resolve. One way to deal with this, is by making your test async: + +```ts +describe('Routing integration test', () => { + const createComponent = createRoutingFactory({ + component: MyComponent, + declarations: [OtherComponent], + stubsEnabled: false, + routes: [ + { + path: '', + component: MyComponent + }, + { + path: 'foo', + component: OtherComponent + } + ] + }); + + it('should navigate away using router link', async () => { + const spectator = createComponent(); + + // wait for promises to resolve... + await spectator.fixture.whenStable(); + + // test the current route by asserting the location + expect(spectator.get(Location).path()).toBe('/'); + + // click on a router link + spectator.click('.link-1'); + + // don't forget to wait for promises to resolve... + await spectator.fixture.whenStable(); + + // test the new route by asserting the location + expect(spectator.get(Location).path()).toBe('/foo'); + }); +}); +``` + +### Routing Options + +The `createRoutesFactory` function can take the following options, on top of the default Spectator options: + +* `params`: initial params to use in `ActivatedRoute` stub +* `queryParams`: initial query params to use in `ActivatedRoute` stub +* `data`: initial data to use in `ActivatedRoute` stub +* `fragment`: initial fragment to use in `ActivatedRoute` stub +* `stubsEnabled` (default: `true`): enables the `ActivatedRoute` stub, if set to `false` it uses `RouterTestingModule` instead +* `routes`: if `stubsEnabled` is set to false, you can pass a `Routes` configuration for `RouterTestingModule` ## Testing Directives diff --git a/projects/spectator/jest/src/lib/mock.ts b/projects/spectator/jest/src/lib/mock.ts index cf276b9d..95847c2d 100644 --- a/projects/spectator/jest/src/lib/mock.ts +++ b/projects/spectator/jest/src/lib/mock.ts @@ -1,12 +1,12 @@ -import { FactoryProvider, Type } from '@angular/core'; -import { installProtoMethods, CompatibleSpy, SpyObject as BaseSpyObject } from '@ngneat/spectator'; +import { FactoryProvider } from '@angular/core'; +import { installProtoMethods, CompatibleSpy, SpyObject as BaseSpyObject, InjectableType } from '@ngneat/spectator'; export type SpyObject = BaseSpyObject & { [P in keyof T]: T[P] & (T[P] extends (...args: any[]) => infer R ? jest.Mock : T[P]) }; /** * @internal */ -export function createSpyObject(type: Type, template?: Partial>): SpyObject { +export function createSpyObject(type: InjectableType, template?: Partial>): SpyObject { const mock: any = template || {}; installProtoMethods(mock, type.prototype, () => { @@ -36,7 +36,7 @@ export function createSpyObject(type: Type, template?: Partial(type: Type, properties?: Partial>): FactoryProvider { +export function mockProvider(type: InjectableType, properties?: Partial>): FactoryProvider { return { provide: type, useFactory: () => createSpyObject(type, properties) diff --git a/projects/spectator/jest/test/with-routing/my-page.component.spec.ts b/projects/spectator/jest/test/with-routing/my-page.component.spec.ts index 01ee6a85..1941b390 100644 --- a/projects/spectator/jest/test/with-routing/my-page.component.spec.ts +++ b/projects/spectator/jest/test/with-routing/my-page.component.spec.ts @@ -92,7 +92,7 @@ describe('MyPageComponent', () => { // tslint:disable-next-line:no-unnecessary-type-assertion const link1 = spectator.query('.link-1', { read: RouterLink })!; - expect(link1.routerLink).toEqual(['foo']); + expect(link1.routerLink).toEqual(['/foo']); }); }); diff --git a/projects/spectator/src/lib/mock.ts b/projects/spectator/src/lib/mock.ts index d9aa186f..2d9e90f6 100644 --- a/projects/spectator/src/lib/mock.ts +++ b/projects/spectator/src/lib/mock.ts @@ -1,6 +1,8 @@ /** Credit: Valentin Buryakov */ import { FactoryProvider, Type } from '@angular/core'; +import { InjectableType } from './token'; + type Writable = { -readonly [P in keyof T]: T[P] }; /** @@ -69,7 +71,7 @@ export function installProtoMethods(mock: any, proto: any, createSpyFn: Funct /** * @publicApi */ -export function createSpyObject(type: Type, template?: Partial>): SpyObject { +export function createSpyObject(type: InjectableType, template?: Partial>): SpyObject { const mock: any = { ...template } || {}; installProtoMethods(mock, type.prototype, name => { @@ -89,7 +91,7 @@ export function createSpyObject(type: Type, template?: Partial(type: Type, properties?: Partial>): FactoryProvider { +export function mockProvider(type: InjectableType, properties?: Partial>): FactoryProvider { return { provide: type, useFactory: () => createSpyObject(type, properties) diff --git a/projects/spectator/src/lib/spectator-routing/create-factory.ts b/projects/spectator/src/lib/spectator-routing/create-factory.ts index b4f4903c..37fdc426 100644 --- a/projects/spectator/src/lib/spectator-routing/create-factory.ts +++ b/projects/spectator/src/lib/spectator-routing/create-factory.ts @@ -1,6 +1,6 @@ import { Provider, Type } from '@angular/core'; import { async, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { setProps } from '../internals/query'; import * as customMatchers from '../matchers'; @@ -71,6 +71,8 @@ export function createRoutingFactory(typeOrOptions: Type | SpectatorRoutin const spectator = createSpectatorRouting(options, props); + spectator.router.initialNavigation(); + if (options.detectChanges && detectChanges) { spectator.detectChanges(); } @@ -85,5 +87,5 @@ function createSpectatorRouting(options: Required> const component = setProps(fixture.componentInstance, props); - return new SpectatorRouting(fixture, debugElement, component, TestBed.get(ActivatedRoute)); + return new SpectatorRouting(fixture, debugElement, component, TestBed.get(Router), TestBed.get(ActivatedRoute)); } diff --git a/projects/spectator/src/lib/spectator-routing/initial-module.ts b/projects/spectator/src/lib/spectator-routing/initial-module.ts index 23e8b2c2..37ca3818 100644 --- a/projects/spectator/src/lib/spectator-routing/initial-module.ts +++ b/projects/spectator/src/lib/spectator-routing/initial-module.ts @@ -1,4 +1,6 @@ -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Event, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Subject } from 'rxjs'; import { ModuleMetadata } from '../base/initial-module'; import { initialSpectatorModule } from '../spectator/initial-module'; @@ -6,6 +8,7 @@ import { initialSpectatorModule } from '../spectator/initial-module'; import { ActivatedRouteStub } from './activated-route-stub'; import { SpectatorRoutingOptions } from './options'; import { RouterLinkDirectiveStub } from './router-link-stub'; +import { RouterStub } from './router-stub'; /** * @internal @@ -13,23 +16,41 @@ import { RouterLinkDirectiveStub } from './router-link-stub'; export function initialRoutingModule(options: Required>): ModuleMetadata { const moduleMetadata = initialSpectatorModule(options); - if (options.mockRouterLinks) { + if (options.mockRouterLinks && options.stubsEnabled) { moduleMetadata.declarations.push(RouterLinkDirectiveStub); } - moduleMetadata.providers.push(options.mockProvider(Router)); + if (options.stubsEnabled) { + moduleMetadata.providers.push( + options.mockProvider(RouterStub, { + events: new Subject(), + emitRouterEvent(event: Event): void { + this.events.next(event); + } + }), + { + provide: Router, + useExisting: RouterStub + } + ); - moduleMetadata.providers.push([ - { - provide: ActivatedRoute, - useFactory: () => - new ActivatedRouteStub({ + moduleMetadata.providers.push( + { + provide: ActivatedRouteStub, + useValue: new ActivatedRouteStub({ params: options.params, queryParams: options.queryParams, data: options.data }) - } - ]); + }, + { + provide: ActivatedRoute, + useExisting: ActivatedRouteStub + } + ); + } else { + moduleMetadata.imports.push(RouterTestingModule.withRoutes(options.routes)); + } return moduleMetadata; } diff --git a/projects/spectator/src/lib/spectator-routing/options.ts b/projects/spectator/src/lib/spectator-routing/options.ts index 6f1cab26..f1e23de5 100644 --- a/projects/spectator/src/lib/spectator-routing/options.ts +++ b/projects/spectator/src/lib/spectator-routing/options.ts @@ -1,3 +1,5 @@ +import { Routes } from '@angular/router'; + import { merge } from '../internals/merge'; import { getSpectatorDefaultOptions, SpectatorOptions } from '../spectator/options'; import { OptionalsRequired } from '../types'; @@ -7,6 +9,8 @@ import { RouteOptions } from './route-options'; export type SpectatorRoutingOptions = SpectatorOptions & RouteOptions & { mockRouterLinks?: boolean; + stubsEnabled?: boolean; + routes?: Routes; }; const defaultRoutingOptions: OptionalsRequired> = { @@ -15,7 +19,9 @@ const defaultRoutingOptions: OptionalsRequired> = { queryParams: {}, data: {}, fragment: null, - mockRouterLinks: true + mockRouterLinks: true, + stubsEnabled: true, + routes: [] }; /** diff --git a/projects/spectator/src/lib/spectator-routing/router-stub.ts b/projects/spectator/src/lib/spectator-routing/router-stub.ts new file mode 100644 index 00000000..c571a780 --- /dev/null +++ b/projects/spectator/src/lib/spectator-routing/router-stub.ts @@ -0,0 +1,9 @@ +import { Router, Event } from '@angular/router'; + +export abstract class RouterStub extends Router { + public abstract emitRouterEvent(event: Event): void; +} + +export function isRouterStub(router: Router): router is RouterStub { + return 'emitRouterEvent' in router; +} diff --git a/projects/spectator/src/lib/spectator-routing/spectator-routing.ts b/projects/spectator/src/lib/spectator-routing/spectator-routing.ts index 5de17212..f8aeac47 100644 --- a/projects/spectator/src/lib/spectator-routing/spectator-routing.ts +++ b/projects/spectator/src/lib/spectator-routing/spectator-routing.ts @@ -1,10 +1,12 @@ -import { DebugElement, Type } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; +import { Event, Router } from '@angular/router'; import { Spectator } from '../spectator/spectator'; import { ActivatedRouteStub } from './activated-route-stub'; import { RouteOptions } from './route-options'; +import { isRouterStub } from './router-stub'; /** * @publicApi @@ -14,7 +16,8 @@ export class SpectatorRouting extends Spectator { fixture: ComponentFixture, debugElement: DebugElement, instance: C, - private readonly activatedRouteStub: ActivatedRouteStub + public readonly router: Router, + public readonly activatedRouteStub?: ActivatedRouteStub ) { super(fixture, debugElement, instance, debugElement.nativeElement); } @@ -23,6 +26,10 @@ export class SpectatorRouting extends Spectator { * Simulates a route navigation by updating the Params, QueryParams and Data observable streams. */ public triggerNavigation(options?: RouteOptions): void { + if (!this.checkStubPresent()) { + return; + } + if (options && options.params) { this.activatedRouteStub.setParams(options.params); } @@ -46,36 +53,75 @@ export class SpectatorRouting extends Spectator { * Updates the route params and triggers a route navigation. */ public setRouteParam(name: string, value: string): void { - this.activatedRouteStub.setParam(name, value); - this.triggerNavigationAndUpdate(); + if (this.checkStubPresent()) { + this.activatedRouteStub.setParam(name, value); + this.triggerNavigationAndUpdate(); + } } /** * Updates the route query params and triggers a route navigation. */ public setRouteQueryParam(name: string, value: string): void { - this.activatedRouteStub.setQueryParam(name, value); - this.triggerNavigationAndUpdate(); + if (this.checkStubPresent()) { + this.activatedRouteStub.setQueryParam(name, value); + this.triggerNavigationAndUpdate(); + } } /** * Updates the route data and triggers a route navigation. */ public setRouteData(name: string, value: string): void { - this.activatedRouteStub.setData(name, value); - this.triggerNavigationAndUpdate(); + if (this.checkStubPresent()) { + this.activatedRouteStub.setData(name, value); + this.triggerNavigationAndUpdate(); + } } /** * Updates the route fragment and triggers a route navigation. */ public setRouteFragment(fragment: string | null): void { - this.activatedRouteStub.setFragment(fragment); - this.triggerNavigationAndUpdate(); + if (this.checkStubPresent()) { + this.activatedRouteStub.setFragment(fragment); + this.triggerNavigationAndUpdate(); + } + } + + /** + * Emits a router event + */ + public emitRouterEvent(event: Event): void { + if (!isRouterStub(this.router)) { + // tslint:disable-next-line:no-console + console.warn( + 'No stub for Router present. Set Spectator option "stubsEnabled" to true if you want to use this ' + + 'helper, or use Router navigation to trigger events.' + ); + + return; + } + + this.router.emitRouterEvent(event); } private triggerNavigationAndUpdate(): void { - this.activatedRouteStub.triggerNavigation(); + this.activatedRouteStub!.triggerNavigation(); this.detectChanges(); } + + private checkStubPresent(): this is { readonly activatedRouteStub: ActivatedRouteStub } { + if (!this.activatedRouteStub) { + // tslint:disable-next-line:no-console + console.warn( + 'No stub for ActivatedRoute present. Set Spectator option "stubsEnabled" to true if you want to use this ' + + 'helper, or use Router to trigger navigation.' + ); + + return false; + } + + return true; + } } diff --git a/projects/spectator/test/with-routing/my-page.component.spec.ts b/projects/spectator/test/with-routing/my-page.component.spec.ts index aedec9fb..7c5a24e8 100644 --- a/projects/spectator/test/with-routing/my-page.component.spec.ts +++ b/projects/spectator/test/with-routing/my-page.component.spec.ts @@ -1,5 +1,7 @@ -import { Router, RouterLink } from '@angular/router'; +import { NavigationStart, Router, RouterLink } from '@angular/router'; import { createRoutingFactory } from '@ngneat/spectator'; +import { Component } from '@angular/core'; +import { Location } from '@angular/common'; import { MyPageComponent } from './my-page.component'; @@ -91,7 +93,7 @@ describe('MyPageComponent', () => { const link1 = spectator.query('.link-1', { read: RouterLink })!; - expect(link1.routerLink).toEqual(['foo']); + expect(link1.routerLink).toEqual(['/foo']); }); }); @@ -107,5 +109,82 @@ describe('MyPageComponent', () => { expect(spectator.get(Router).navigate).toHaveBeenCalledWith(['bar']); }); + + it('should trigger router events', async () => { + const spectator = createComponent(); + + const subscriberSpy = jasmine.createSpy('subscriber'); + const subscription = spectator.router.events.subscribe(subscriberSpy); + spyOn(console, 'warn'); + + spectator.emitRouterEvent(new NavigationStart(1, 'some-url')); + + // tslint:disable-next-line:no-console + expect(console.warn).not.toHaveBeenCalled(); + expect(subscriberSpy).toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + }); + + describe('without stubs', () => { + @Component({ + selector: 'dummy', + template: '' + }) + class DummyComponent {} + + const createComponent = createRoutingFactory({ + component: MyPageComponent, + declarations: [DummyComponent], + stubsEnabled: false, + routes: [ + { + path: '', + component: MyPageComponent + }, + { + path: 'foo', + component: DummyComponent + } + ] + }); + + it('should navigate away using router', async () => { + const spectator = createComponent(); + + await spectator.fixture.whenStable(); + expect(spectator.get(Location).path()).toBe('/'); + + await spectator.router.navigate(['/foo']); + expect(spectator.get(Location).path()).toBe('/foo'); + + await spectator.router.navigate(['/']); + expect(spectator.get(Location).path()).toBe('/'); + }); + + it('should navigate away using router link', async () => { + const spectator = createComponent(); + + await spectator.fixture.whenStable(); + expect(spectator.get(Location).path()).toBe('/'); + + spectator.click('.link-1'); + + await spectator.fixture.whenStable(); + expect(spectator.get(Location).path()).toBe('/foo'); + }); + + it('should not trigger router events', async () => { + const spectator = createComponent(); + await spectator.fixture.whenStable(); + + spyOn(console, 'warn'); + + spectator.emitRouterEvent(new NavigationStart(1, 'some-url')); + + // tslint:disable-next-line:no-console + expect(console.warn).toHaveBeenCalled(); + }); }); }); diff --git a/projects/spectator/test/with-routing/my-page.component.ts b/projects/spectator/test/with-routing/my-page.component.ts index 6f4d9aab..ac429a7c 100644 --- a/projects/spectator/test/with-routing/my-page.component.ts +++ b/projects/spectator/test/with-routing/my-page.component.ts @@ -11,7 +11,7 @@ import { map } from 'rxjs/operators';
{{ foo }}
{{ bar }}
{{ baz$ | async }}
- Some link + Some link Other link ` })