From dedac8d3f73ebf4f05b773454e2a22ab5fa4bf7c Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 20 Dec 2022 09:02:06 -0800 Subject: [PATCH] feat(router): Add test helper for trigger navigations in tests (#48552) In order to test components and services which depend on router navigations, such as `ActivatedRoute` instances, tests currently need to provide a fair bit of boilerplate _or_ they can set up a stub for `ActivatedRoute` and list it in the `providers` to override it in `TestBed`. This approach of stubbing the `ActivatedRoute` creates a situation that can easily cause the test to break. The stub often only mocks out the dependencies that the component/service _currently_ needs. This dependencies might change over time and break the test in an unexpected way. In addition, it is difficult to get the structure of `ActivatedRoute` exactly correct. This change will allow unit tests to quickly set up routes, trigger real navigations in the Router, and get instances of component's to test along with real instances of `ActivatedRoute`. This all comes without needing to know that the component depends on `ActivatedRoute` at all. This becomes more important when considering that a component may be refactored in the future to use `@Input` rather than access data on the `ActivatedRoute` instance (see #18967). Tests which mock out `ActivatedRoute` would all break, but those which use `navigateForTest` would continue to work without needing any updates. resolves #15779 resolves #48608 PR Close #48552 --- goldens/public-api/router/testing/index.md | 12 ++ packages.bzl | 1 + packages/examples/router/testing/BUILD.bazel | 38 +++++ .../router_testing_harness_examples.spec.ts | 92 +++++++++++ packages/router/src/private_export.ts | 1 + packages/router/testing/BUILD.bazel | 1 + .../testing/src/router_testing_harness.ts | 154 ++++++++++++++++++ packages/router/testing/src/testing.ts | 1 + packages/router/testing/test/BUILD.bazel | 37 +++++ .../test/router_testing_harness.spec.ts | 126 ++++++++++++++ 10 files changed, 463 insertions(+) create mode 100644 packages/examples/router/testing/BUILD.bazel create mode 100644 packages/examples/router/testing/test/router_testing_harness_examples.spec.ts create mode 100644 packages/router/testing/src/router_testing_harness.ts create mode 100644 packages/router/testing/test/BUILD.bazel create mode 100644 packages/router/testing/test/router_testing_harness.spec.ts diff --git a/goldens/public-api/router/testing/index.md b/goldens/public-api/router/testing/index.md index 7e11dbd987e..e12ace9c730 100644 --- a/goldens/public-api/router/testing/index.md +++ b/goldens/public-api/router/testing/index.md @@ -6,6 +6,7 @@ import { ChildrenOutletContexts } from '@angular/router'; import { Compiler } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { ExtraOptions } from '@angular/router'; import * as i0 from '@angular/core'; import * as i1 from '@angular/router'; @@ -17,9 +18,20 @@ import { Router } from '@angular/router'; import { RouteReuseStrategy } from '@angular/router'; import { Routes } from '@angular/router'; import { TitleStrategy } from '@angular/router'; +import { Type } from '@angular/core'; import { UrlHandlingStrategy } from '@angular/router'; import { UrlSerializer } from '@angular/router'; +// @public +export class RouterTestingHarness { + static create(initialUrl?: string): Promise; + detectChanges(): void; + navigateByUrl(url: string): Promise; + navigateByUrl(url: string, requiredRoutedComponentType: Type): Promise; + get routeDebugElement(): DebugElement | null; + get routeNativeElement(): HTMLElement | null; +} + // @public export class RouterTestingModule { // (undocumented) diff --git a/packages.bzl b/packages.bzl index 2c291841967..73efc12bf54 100644 --- a/packages.bzl +++ b/packages.bzl @@ -73,6 +73,7 @@ DOCS_ENTRYPOINTS = [ "examples/forms", "examples/platform-browser", "examples/router/activated-route", + "examples/router/testing", "examples/service-worker/push", "examples/service-worker/registration-options", "examples/test-utils", diff --git a/packages/examples/router/testing/BUILD.bazel b/packages/examples/router/testing/BUILD.bazel new file mode 100644 index 00000000000..bbaf09459fd --- /dev/null +++ b/packages/examples/router/testing/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "karma_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + "//packages/common", + "//packages/core", + "//packages/core/testing", + "//packages/router", + "//packages/router/testing", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node"], + deps = [ + ":test_lib", + ], +) + +karma_web_test_suite( + name = "test_web", + deps = [ + ":test_lib", + ], +) + +filegroup( + name = "files_for_docgen", + srcs = glob([ + "**/*.ts", + ]), +) diff --git a/packages/examples/router/testing/test/router_testing_harness_examples.spec.ts b/packages/examples/router/testing/test/router_testing_harness_examples.spec.ts new file mode 100644 index 00000000000..40913782fc6 --- /dev/null +++ b/packages/examples/router/testing/test/router_testing_harness_examples.spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AsyncPipe} from '@angular/common'; +import {Component, inject} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ActivatedRoute, CanActivateFn, provideRouter, Router} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; + +describe('navigate for test examples', () => { + // #docregion RoutedComponent + it('navigates to routed component', async () => { + @Component({standalone: true, template: 'hello {{name}}'}) + class TestCmp { + name = 'world'; + } + + TestBed.configureTestingModule({ + providers: [provideRouter([{path: '', component: TestCmp}])], + }); + + const harness = await RouterTestingHarness.create(); + const activatedComponent = await harness.navigateByUrl('/', TestCmp); + expect(activatedComponent).toBeInstanceOf(TestCmp); + expect(harness.routeNativeElement?.innerHTML).toContain('hello world'); + }); + // #enddocregion + + it('testing a guard', async () => { + @Component({standalone: true, template: ''}) + class AdminComponent { + } + @Component({standalone: true, template: ''}) + class LoginComponent { + } + + // #docregion Guard + let isLoggedIn = false; + const isLoggedInGuard: CanActivateFn = () => { + return isLoggedIn ? true : inject(Router).parseUrl('/login'); + }; + + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + {path: 'admin', canActivate: [isLoggedInGuard], component: AdminComponent}, + {path: 'login', component: LoginComponent}, + ]), + ], + }); + + const harness = await RouterTestingHarness.create('/admin'); + expect(TestBed.inject(Router).url).toEqual('/login'); + isLoggedIn = true; + await harness.navigateByUrl('/admin'); + expect(TestBed.inject(Router).url).toEqual('/admin'); + // #enddocregion + }); + + it('test a ActivatedRoute', async () => { + // #docregion ActivatedRoute + @Component({ + standalone: true, + imports: [AsyncPipe], + template: `search: {{(route.queryParams | async)?.query}}` + }) + class SearchCmp { + constructor(readonly route: ActivatedRoute, readonly router: Router) {} + + async searchFor(thing: string) { + await this.router.navigate([], {queryParams: {query: thing}}); + } + } + + TestBed.configureTestingModule({ + providers: [provideRouter([{path: 'search', component: SearchCmp}])], + }); + + const harness = await RouterTestingHarness.create(); + const activatedComponent = await harness.navigateByUrl('/search', SearchCmp); + await activatedComponent.searchFor('books'); + harness.detectChanges(); + expect(TestBed.inject(Router).url).toEqual('/search?query=books'); + expect(harness.routeNativeElement?.innerHTML).toContain('books'); + // #enddocregion + }); +}); diff --git a/packages/router/src/private_export.ts b/packages/router/src/private_export.ts index 960232984c5..a8c86c1683a 100644 --- a/packages/router/src/private_export.ts +++ b/packages/router/src/private_export.ts @@ -12,3 +12,4 @@ export {RestoredState as ɵRestoredState} from './navigation_transition'; export {withPreloading as ɵwithPreloading} from './provide_router'; export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module'; export {flatten as ɵflatten} from './utils/collection'; +export {afterNextNavigation as ɵafterNextNavigation} from './utils/navigations'; diff --git a/packages/router/testing/BUILD.bazel b/packages/router/testing/BUILD.bazel index 4840f6a8134..453a46fd078 100644 --- a/packages/router/testing/BUILD.bazel +++ b/packages/router/testing/BUILD.bazel @@ -11,6 +11,7 @@ ng_module( "//packages/common", "//packages/common/testing", "//packages/core", + "//packages/core/testing", "//packages/router", "@npm//rxjs", ], diff --git a/packages/router/testing/src/router_testing_harness.ts b/packages/router/testing/src/router_testing_harness.ts new file mode 100644 index 00000000000..f6d297026c7 --- /dev/null +++ b/packages/router/testing/src/router_testing_harness.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, DebugElement, Injectable, Type, ViewChild} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Router, RouterOutlet, ɵafterNextNavigation as afterNextNavigation} from '@angular/router'; + +@Injectable({providedIn: 'root'}) +export class RootFixtureService { + private fixture?: ComponentFixture; + private harness?: RouterTestingHarness; + + createHarness(): RouterTestingHarness { + if (this.harness) { + throw new Error('Only one harness should be created per test.'); + } + this.harness = new RouterTestingHarness(this.getRootFixture()); + return this.harness; + } + + private getRootFixture(): ComponentFixture { + if (this.fixture !== undefined) { + return this.fixture; + } + this.fixture = TestBed.createComponent(RootCmp); + this.fixture.detectChanges(); + return this.fixture; + } +} + +@Component({ + standalone: true, + template: '', + imports: [RouterOutlet], +}) +export class RootCmp { + @ViewChild(RouterOutlet) outlet?: RouterOutlet; +} + +/** + * A testing harness for the `Router` to reduce the boilerplate needed to test routes and routed + * components. + * + * @publicApi + */ +export class RouterTestingHarness { + /** + * Creates a `RouterTestingHarness` instance. + * + * The `RouterTestingHarness` also creates its own root component with a `RouterOutlet` for the + * purposes of rendering route components. + * + * Throws an error if an instance has already been created. + * Use of this harness also requires `destroyAfterEach: true` in the `ModuleTeardownOptions` + * + * @param initialUrl The target of navigation to trigger before returning the harness. + */ + static async create(initialUrl?: string): Promise { + const harness = TestBed.inject(RootFixtureService).createHarness(); + if (initialUrl !== undefined) { + await harness.navigateByUrl(initialUrl); + } + return harness; + } + + /** @internal */ + constructor(private readonly fixture: ComponentFixture) {} + + /** Instructs the root fixture to run change detection. */ + detectChanges(): void { + this.fixture.detectChanges(); + } + /** The `DebugElement` of the `RouterOutlet` component. `null` if the outlet is not activated. */ + get routeDebugElement(): DebugElement|null { + const outlet = this.fixture.componentInstance.outlet; + if (!outlet || !outlet.isActivated) { + return null; + } + return this.fixture.debugElement.query(v => v.componentInstance === outlet.component); + } + /** The native element of the `RouterOutlet` component. `null` if the outlet is not activated. */ + get routeNativeElement(): HTMLElement|null { + return this.routeDebugElement?.nativeElement ?? null; + } + + /** + * Triggers a `Router` navigation and waits for it to complete. + * + * The root component with a `RouterOutlet` created for the harness is used to render `Route` + * components. The root component is reused within the same test in subsequent calls to + * `navigateForTest`. + * + * When testing `Routes` with a guards that reject the navigation, the `RouterOutlet` might not be + * activated and the `activatedComponent` may be `null`. + * + * {@example router/testing/test/router_testing_harness_examples.spec.ts region='Guard'} + * + * @param url The target of the navigation. Passed to `Router.navigateByUrl`. + * @returns The activated component instance of the `RouterOutlet` after navigation completes + * (`null` if the outlet does not get activated). + */ + async navigateByUrl(url: string): Promise; + /** + * Triggers a router navigation and waits for it to complete. + * + * The root component with a `RouterOutlet` created for the harness is used to render `Route` + * components. + * + * {@example router/testing/test/router_testing_harness_examples.spec.ts region='RoutedComponent'} + * + * The root component is reused within the same test in subsequent calls to `navigateByUrl`. + * + * This function also makes it easier to test components that depend on `ActivatedRoute` data. + * + * {@example router/testing/test/router_testing_harness_examples.spec.ts region='ActivatedRoute'} + * + * @param url The target of the navigation. Passed to `Router.navigateByUrl`. + * @param requiredRoutedComponentType After navigation completes, the required type for the + * activated component of the `RouterOutlet`. If the outlet is not activated or a different + * component is activated, this function will throw an error. + * @returns The activated component instance of the `RouterOutlet` after navigation completes. + */ + async navigateByUrl(url: string, requiredRoutedComponentType: Type): Promise; + async navigateByUrl(url: string, requiredRoutedComponentType?: Type): Promise { + const router = TestBed.inject(Router); + let resolveFn!: () => void; + const redirectTrackingPromise = new Promise(resolve => { + resolveFn = resolve; + }); + afterNextNavigation(TestBed.inject(Router), resolveFn); + await router.navigateByUrl(url); + await redirectTrackingPromise; + this.fixture.detectChanges(); + const outlet = this.fixture.componentInstance.outlet; + // The outlet might not be activated if the user is testing a navigation for a guard that + // rejects + if (outlet && outlet.isActivated && outlet.activatedRoute.component) { + const activatedComponent = outlet.component; + if (requiredRoutedComponentType !== undefined && + !(activatedComponent instanceof requiredRoutedComponentType)) { + throw new Error(`Unexpected routed component type. Expected ${ + requiredRoutedComponentType.name} but got ${activatedComponent.constructor.name}`); + } + return activatedComponent as T; + } else { + return null; + } + } +} diff --git a/packages/router/testing/src/testing.ts b/packages/router/testing/src/testing.ts index cd4b37d7c0b..d968919dea2 100644 --- a/packages/router/testing/src/testing.ts +++ b/packages/router/testing/src/testing.ts @@ -12,3 +12,4 @@ * Entry point for all public APIs of the router/testing package. */ export * from './router_testing_module'; +export {RouterTestingHarness} from './router_testing_harness'; diff --git a/packages/router/testing/test/BUILD.bazel b/packages/router/testing/test/BUILD.bazel new file mode 100644 index 00000000000..94c7a1e45d5 --- /dev/null +++ b/packages/router/testing/test/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "karma_web_test_suite", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + # Visible to //:saucelabs_unit_tests_poc target + visibility = ["//:__pkg__"], + deps = [ + "//packages/common", + "//packages/common/testing", + "//packages/core", + "//packages/core/testing", + "//packages/platform-browser", + "//packages/platform-browser-dynamic", + "//packages/platform-browser/testing", + "//packages/private/testing", + "//packages/router", + "//packages/router/testing", + "@npm//rxjs", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node"], + deps = [ + ":test_lib", + ], +) + +karma_web_test_suite( + name = "test_web", + deps = [ + ":test_lib", + ], +) diff --git a/packages/router/testing/test/router_testing_harness.spec.ts b/packages/router/testing/test/router_testing_harness.spec.ts new file mode 100644 index 00000000000..9ee336fa38f --- /dev/null +++ b/packages/router/testing/test/router_testing_harness.spec.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AsyncPipe} from '@angular/common'; +import {Component, inject} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ActivatedRoute, provideRouter, Router} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {of} from 'rxjs'; +import {delay} from 'rxjs/operators'; + +describe('navigateForTest', () => { + it('gives null for the activatedComponent when no routes are configured', async () => { + TestBed.configureTestingModule({providers: [provideRouter([])]}); + const harness = await RouterTestingHarness.create('/'); + expect(harness.routeDebugElement).toBeNull(); + }); + it('navigates to routed component', async () => { + @Component({standalone: true, template: 'hello {{name}}'}) + class TestCmp { + name = 'world'; + } + + TestBed.configureTestingModule({providers: [provideRouter([{path: '', component: TestCmp}])]}); + const harness = await RouterTestingHarness.create(); + const activatedComponent = await harness.navigateByUrl('/', TestCmp); + + expect(activatedComponent).toBeInstanceOf(TestCmp); + expect(harness.routeNativeElement?.innerHTML).toContain('hello world'); + }); + + it('executes guards on the path', async () => { + let guardCalled = false; + TestBed.configureTestingModule({ + providers: [provideRouter([{ + path: '', + canActivate: [() => { + guardCalled = true; + return true; + }], + children: [] + }])] + }); + await RouterTestingHarness.create('/'); + expect(guardCalled).toBeTrue(); + }); + + it('throws error if routing throws', async () => { + TestBed.configureTestingModule({ + providers: [provideRouter([{ + path: '', + canActivate: [() => { + throw new Error('oh no'); + }], + children: [] + }])] + }); + await expectAsync(RouterTestingHarness.create('/')).toBeRejected(); + }); + + it('can observe param changes on routed component with second navigation', async () => { + @Component({standalone: true, template: '{{(route.params | async)?.id}}', imports: [AsyncPipe]}) + class TestCmp { + constructor(readonly route: ActivatedRoute) {} + } + + TestBed.configureTestingModule({ + providers: [ + provideRouter([{path: ':id', component: TestCmp}]), + ] + }); + const harness = await RouterTestingHarness.create(); + const activatedComponent = await harness.navigateByUrl('/123', TestCmp); + expect(activatedComponent.route).toBeInstanceOf(ActivatedRoute); + expect(harness.routeNativeElement?.innerHTML).toContain('123'); + await harness.navigateByUrl('/456'); + expect(harness.routeNativeElement?.innerHTML).toContain('456'); + }); + + it('throws an error if the routed component instance does not match the one required', + async () => { + @Component({standalone: true, template: ''}) + class TestCmp { + } + @Component({standalone: true, template: ''}) + class OtherCmp { + } + + TestBed.configureTestingModule({ + providers: [ + provideRouter([{path: '**', component: TestCmp}]), + ] + }); + const harness = await RouterTestingHarness.create(); + await expectAsync(harness.navigateByUrl('/123', OtherCmp)).toBeRejected(); + }); + + it('waits for redirects using router.navigate', async () => { + @Component({standalone: true, template: 'test'}) + class TestCmp { + } + @Component({standalone: true, template: 'redirect'}) + class OtherCmp { + } + + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'test', + canActivate: [() => inject(Router).navigateByUrl('/redirect')], + component: TestCmp + }, + {path: 'redirect', canActivate: [() => of(true).pipe(delay(100))], component: OtherCmp}, + ]), + ] + }); + await RouterTestingHarness.create('test'); + expect(TestBed.inject(Router).url).toEqual('/redirect'); + }); +});