Skip to content

Commit

Permalink
feat(routing): add option to disable stubs and use RouterTestingModule (
Browse files Browse the repository at this point in the history
#188)

Closes #179.
  • Loading branch information
dirkluijk committed Sep 20, 2019
1 parent b3d243d commit 2fb7390
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 39 deletions.
61 changes: 56 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ describe('ButtonComponent', () => {
});
```

### Updating Route
### Triggering a navigation
The `SpectatorRouting` API includes convenient methods for updating the current route:

```ts
Expand Down Expand Up @@ -379,11 +379,62 @@ interface SpectatorRouting<C> extends Spectator<C> {
}
```

### 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

Expand Down
8 changes: 4 additions & 4 deletions projects/spectator/jest/src/lib/mock.ts
Original file line number Diff line number Diff line change
@@ -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<T> = BaseSpyObject<T> & { [P in keyof T]: T[P] & (T[P] extends (...args: any[]) => infer R ? jest.Mock<R> : T[P]) };

/**
* @internal
*/
export function createSpyObject<T>(type: Type<T>, template?: Partial<Record<keyof T, any>>): SpyObject<T> {
export function createSpyObject<T>(type: InjectableType<T>, template?: Partial<Record<keyof T, any>>): SpyObject<T> {
const mock: any = template || {};

installProtoMethods(mock, type.prototype, () => {
Expand Down Expand Up @@ -36,7 +36,7 @@ export function createSpyObject<T>(type: Type<T>, template?: Partial<Record<keyo
/**
* @publicApi
*/
export function mockProvider<T>(type: Type<T>, properties?: Partial<Record<keyof T, any>>): FactoryProvider {
export function mockProvider<T>(type: InjectableType<T>, properties?: Partial<Record<keyof T, any>>): FactoryProvider {
return {
provide: type,
useFactory: () => createSpyObject(type, properties)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});

Expand Down
6 changes: 4 additions & 2 deletions projects/spectator/src/lib/mock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** Credit: Valentin Buryakov */
import { FactoryProvider, Type } from '@angular/core';

import { InjectableType } from './token';

type Writable<T> = { -readonly [P in keyof T]: T[P] };

/**
Expand Down Expand Up @@ -69,7 +71,7 @@ export function installProtoMethods<T>(mock: any, proto: any, createSpyFn: Funct
/**
* @publicApi
*/
export function createSpyObject<T>(type: Type<T>, template?: Partial<Record<keyof T, any>>): SpyObject<T> {
export function createSpyObject<T>(type: InjectableType<T>, template?: Partial<Record<keyof T, any>>): SpyObject<T> {
const mock: any = { ...template } || {};

installProtoMethods<T>(mock, type.prototype, name => {
Expand All @@ -89,7 +91,7 @@ export function createSpyObject<T>(type: Type<T>, template?: Partial<Record<keyo
/**
* @publicApi
*/
export function mockProvider<T>(type: Type<T>, properties?: Partial<Record<keyof T, any>>): FactoryProvider {
export function mockProvider<T>(type: InjectableType<T>, properties?: Partial<Record<keyof T, any>>): FactoryProvider {
return {
provide: type,
useFactory: () => createSpyObject(type, properties)
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -71,6 +71,8 @@ export function createRoutingFactory<C>(typeOrOptions: Type<C> | SpectatorRoutin

const spectator = createSpectatorRouting(options, props);

spectator.router.initialNavigation();

if (options.detectChanges && detectChanges) {
spectator.detectChanges();
}
Expand All @@ -85,5 +87,5 @@ function createSpectatorRouting<C>(options: Required<SpectatorRoutingOptions<C>>

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));
}
41 changes: 31 additions & 10 deletions projects/spectator/src/lib/spectator-routing/initial-module.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,56 @@
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';

import { ActivatedRouteStub } from './activated-route-stub';
import { SpectatorRoutingOptions } from './options';
import { RouterLinkDirectiveStub } from './router-link-stub';
import { RouterStub } from './router-stub';

/**
* @internal
*/
export function initialRoutingModule<S>(options: Required<SpectatorRoutingOptions<S>>): 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<Event>(),
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;
}
8 changes: 7 additions & 1 deletion projects/spectator/src/lib/spectator-routing/options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Routes } from '@angular/router';

import { merge } from '../internals/merge';
import { getSpectatorDefaultOptions, SpectatorOptions } from '../spectator/options';
import { OptionalsRequired } from '../types';
Expand All @@ -7,6 +9,8 @@ import { RouteOptions } from './route-options';
export type SpectatorRoutingOptions<C> = SpectatorOptions<C> &
RouteOptions & {
mockRouterLinks?: boolean;
stubsEnabled?: boolean;
routes?: Routes;
};

const defaultRoutingOptions: OptionalsRequired<SpectatorRoutingOptions<any>> = {
Expand All @@ -15,7 +19,9 @@ const defaultRoutingOptions: OptionalsRequired<SpectatorRoutingOptions<any>> = {
queryParams: {},
data: {},
fragment: null,
mockRouterLinks: true
mockRouterLinks: true,
stubsEnabled: true,
routes: []
};

/**
Expand Down
9 changes: 9 additions & 0 deletions projects/spectator/src/lib/spectator-routing/router-stub.ts
Original file line number Diff line number Diff line change
@@ -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;
}
68 changes: 57 additions & 11 deletions projects/spectator/src/lib/spectator-routing/spectator-routing.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,7 +16,8 @@ export class SpectatorRouting<C> extends Spectator<C> {
fixture: ComponentFixture<any>,
debugElement: DebugElement,
instance: C,
private readonly activatedRouteStub: ActivatedRouteStub
public readonly router: Router,
public readonly activatedRouteStub?: ActivatedRouteStub
) {
super(fixture, debugElement, instance, debugElement.nativeElement);
}
Expand All @@ -23,6 +26,10 @@ export class SpectatorRouting<C> extends Spectator<C> {
* 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);
}
Expand All @@ -46,36 +53,75 @@ export class SpectatorRouting<C> extends Spectator<C> {
* 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;
}
}
Loading

0 comments on commit 2fb7390

Please sign in to comment.