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'); + }); +});