From 0d4dfa743766df4456a9e786eb67240fd0b204b9 Mon Sep 17 00:00:00 2001 From: timdeschryver <28659384+timdeschryver@users.noreply.github.com> Date: Tue, 10 Sep 2019 08:36:39 +0200 Subject: [PATCH] feat: add navigate for router tests --- projects/testing-library/src/lib/models.ts | 29 +++++++ .../src/lib/testing-library.ts | 38 +++++++-- src/app/app-routing.module.ts | 18 ++++ src/app/app.module.ts | 4 + src/app/examples/04-forms-with-material.ts | 16 ++++ src/app/examples/09-router.spec.ts | 82 +++++++++++++++++++ src/app/examples/09-router.ts | 38 +++++++++ 7 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 src/app/examples/09-router.spec.ts create mode 100644 src/app/examples/09-router.ts diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index d87720d0..03a78808 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,5 +1,6 @@ import { Type } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; +import { Routes } from '@angular/router'; import { BoundFunction, FireObject, Queries, queries } from '@testing-library/dom'; import { UserEvents } from './user-events'; @@ -35,6 +36,12 @@ export interface RenderResult extends RenderResultQueries, FireObject, UserEvent * For more info see https://angular.io/api/core/testing/ComponentFixture */ fixture: ComponentFixture; + /** + * @description + * Navigates to the href of the element or to the path. + * + */ + navigate: (elementOrPath: Element | string, basePath?: string) => Promise; } export interface RenderOptions { @@ -201,4 +208,26 @@ export interface RenderOptions { * }) */ excludeComponentDeclaration?: boolean; + /** + * @description + * The route configuration to set up the router service via `RouterTestingModule.withRoutes`. + * For more info see https://angular.io/api/router/Routes. + * + * @example + * const component = await render(AppComponent, { + * declarations: [ChildComponent], + * routes: [ + * { + * path: '', + * children: [ + * { + * path: 'child/:id', + * component: ChildComponent + * } + * ] + * } + * ] + * }) + */ + routes?: Routes; } diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 1b8b43ff..5b1f288e 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -1,7 +1,9 @@ -import { Component, DebugElement, ElementRef, OnInit, Type } from '@angular/core'; +import { Component, DebugElement, ElementRef, OnInit, Type, NgZone } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; import { fireEvent, FireFunction, FireObject, getQueriesForElement, prettyDOM } from '@testing-library/dom'; import { RenderOptions, RenderResult } from './models'; import { createSelectOptions, createType } from './user-events'; @@ -32,6 +34,7 @@ export async function render( componentProperties = {}, componentProviders = [], excludeComponentDeclaration = false, + routes, } = renderOptions; const isTemplate = typeof templateOrComponent === 'string'; @@ -44,7 +47,7 @@ export async function render( TestBed.configureTestingModule({ declarations: [...declarations, ...componentDeclarations], - imports: addAutoImports(imports), + imports: addAutoImports({ imports, routes }), providers: [...providers], schemas: [...schemas], }); @@ -80,6 +83,20 @@ export async function render( {} as FireFunction & FireObject, ); + let router = routes ? (TestBed.get(Router) as Router) : null; + const zone = TestBed.get(NgZone) as NgZone; + + async function navigate(elementOrPath: Element | string, basePath = '') { + if (!router) { + router = TestBed.get(Router) as Router; + } + + const href = typeof elementOrPath === 'string' ? elementOrPath : elementOrPath.getAttribute('href'); + + await zone.run(() => router.navigate([basePath + href])); + fixture.detectChanges(); + } + return { fixture, container: fixture.nativeElement, @@ -89,6 +106,7 @@ export async function render( ...eventsWithDetectChanges, type: createType(eventsWithDetectChanges), selectOptions: createSelectOptions(eventsWithDetectChanges), + navigate, } as any; } @@ -168,10 +186,16 @@ function declareComponents({ isTemplate, wrapper, excludeComponentDeclaration, t return [templateOrComponent]; } -function addAutoImports(imports: any[]) { - if (imports.indexOf(NoopAnimationsModule) > -1 || imports.indexOf(BrowserAnimationsModule) > -1) { - return imports; - } +function addAutoImports({ imports, routes }: Pick, 'imports' | 'routes'>) { + const animations = () => { + const animationIsDefined = + imports.indexOf(NoopAnimationsModule) > -1 || imports.indexOf(BrowserAnimationsModule) > -1; + return animationIsDefined ? [] : [NoopAnimationsModule]; + }; + + const routing = () => { + return routes ? [RouterTestingModule.withRoutes(routes)] : []; + }; - return [...imports, NoopAnimationsModule]; + return [...imports, ...animations(), ...routing()]; } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 0e7faa3f..2e575bf5 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -9,6 +9,7 @@ import { MaterialFormsComponent } from './examples/04-forms-with-material'; import { ComponentWithProviderComponent } from './examples/05-component-provider'; import { WithNgRxStoreComponent } from './examples/06-with-ngrx-store'; import { WithNgRxMockStoreComponent } from './examples/07-with-ngrx-mock-store'; +import { MasterComponent, DetailComponent, HiddenDetailComponent } from './examples/09-router'; export const examples = [ { @@ -67,6 +68,23 @@ export const examples = [ name: 'With NgRx MockStore', }, }, + { + path: 'with-router', + component: MasterComponent, + data: { + name: 'Router', + }, + children: [ + { + path: 'detail/:id', + component: DetailComponent, + }, + { + path: 'hidden-detail', + component: HiddenDetailComponent, + }, + ], + }, ]; export const routes: Routes = [ diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6cdb32fc..10694925 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -20,6 +20,7 @@ import { MaterialFormsComponent } from './examples/04-forms-with-material'; import { ComponentWithProviderComponent } from './examples/05-component-provider'; import { WithNgRxStoreComponent, reducer } from './examples/06-with-ngrx-store'; import { WithNgRxMockStoreComponent } from './examples/07-with-ngrx-mock-store'; +import { MasterComponent, DetailComponent, HiddenDetailComponent } from './examples/09-router'; @NgModule({ declarations: [ @@ -34,6 +35,9 @@ import { WithNgRxMockStoreComponent } from './examples/07-with-ngrx-mock-store'; ComponentWithProviderComponent, WithNgRxStoreComponent, WithNgRxMockStoreComponent, + MasterComponent, + DetailComponent, + HiddenDetailComponent, ], imports: [ BrowserModule, diff --git a/src/app/examples/04-forms-with-material.ts b/src/app/examples/04-forms-with-material.ts index 554d3065..5b6aa38e 100644 --- a/src/app/examples/04-forms-with-material.ts +++ b/src/app/examples/04-forms-with-material.ts @@ -34,6 +34,22 @@ import { FormBuilder, Validators, ReactiveFormsModule, ValidationErrors } from ' `, + styles: [ + ` + form { + display: flex; + flex-direction: column; + } + + form > * { + width: 100%; + } + + [role='alert'] { + color: red; + } + `, + ], }) export class MaterialFormsComponent { colors = [{ id: 'R', value: 'Red' }, { id: 'B', value: 'Blue' }, { id: 'G', value: 'Green' }]; diff --git a/src/app/examples/09-router.spec.ts b/src/app/examples/09-router.spec.ts new file mode 100644 index 00000000..3e5c72e2 --- /dev/null +++ b/src/app/examples/09-router.spec.ts @@ -0,0 +1,82 @@ +import { render } from '@testing-library/angular'; + +import { DetailComponent, MasterComponent, HiddenDetailComponent } from './09-router'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +test('it can navigate to routes', async () => { + const component = await render(MasterComponent, { + declarations: [DetailComponent, HiddenDetailComponent], + routes: [ + { + path: '', + children: [ + { + path: 'detail/:id', + component: DetailComponent, + }, + { + path: 'hidden-detail', + component: HiddenDetailComponent, + }, + ], + }, + ], + }); + + expect(component.queryByText(/Detail one/i)).not.toBeInTheDocument(); + + await component.navigate(component.getByText(/Load one/)); + expect(component.queryByText(/Detail one/i)).toBeInTheDocument(); + + await component.navigate(component.getByText(/Load three/)); + expect(component.queryByText(/Detail one/i)).not.toBeInTheDocument(); + expect(component.queryByText(/Detail three/i)).toBeInTheDocument(); + + await component.navigate(component.getByText(/Back to parent/)); + expect(component.queryByText(/Detail three/i)).not.toBeInTheDocument(); + + await component.navigate(component.getByText(/Load two/)); + expect(component.queryByText(/Detail two/i)).toBeInTheDocument(); + await component.navigate(component.getByText(/hidden x/)); + expect(component.queryByText(/You found the treasure!/i)).toBeInTheDocument(); +}); + +test('it can navigate to routes with a base path', async () => { + const basePath = 'base'; + const component = await render(MasterComponent, { + declarations: [DetailComponent, HiddenDetailComponent], + routes: [ + { + path: basePath, + children: [ + { + path: 'detail/:id', + component: DetailComponent, + }, + { + path: 'hidden-detail', + component: HiddenDetailComponent, + }, + ], + }, + ], + }); + + expect(component.queryByText(/Detail one/i)).not.toBeInTheDocument(); + + await component.navigate(component.getByText(/Load one/), basePath); + expect(component.queryByText(/Detail one/i)).toBeInTheDocument(); + + await component.navigate(component.getByText(/Load three/), basePath); + expect(component.queryByText(/Detail one/i)).not.toBeInTheDocument(); + expect(component.queryByText(/Detail three/i)).toBeInTheDocument(); + + await component.navigate(component.getByText(/Back to parent/)); + expect(component.queryByText(/Detail three/i)).not.toBeInTheDocument(); + + await component.navigate('base/detail/two'); // possible to just use strings + expect(component.queryByText(/Detail two/i)).toBeInTheDocument(); + await component.navigate('/hidden-detail', basePath); + expect(component.queryByText(/You found the treasure!/i)).toBeInTheDocument(); +}); diff --git a/src/app/examples/09-router.ts b/src/app/examples/09-router.ts new file mode 100644 index 00000000..e0350fcc --- /dev/null +++ b/src/app/examples/09-router.ts @@ -0,0 +1,38 @@ +import { OnInit, Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'app-master', + template: ` + Load one | Load two | + Load three | + +
+ + + `, +}) +export class MasterComponent {} + +@Component({ + selector: 'app-detail', + template: ` +

Detail {{ id | async }}

+ + Back to parent + hidden x + `, +}) +export class DetailComponent { + id = this.route.paramMap.pipe(map(params => params.get('id'))); + constructor(private route: ActivatedRoute) {} +} + +@Component({ + selector: 'app-detail-hidden', + template: ` + You found the treasure! + `, +}) +export class HiddenDetailComponent {}