Angular Router 测试 API 详解:@angular/router/testing 中的 RouterTestingHarness 与 RouterTestingModule
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
本文基于 Angular 仓库中@angular/router测试子包(@angular/router/testing)的公共 API 报告(goldens/public-api/router/testing/index.api.md)及其源码实现,完整解读该测试工具包公开的全部 API:RouterTestingHarness的每个成员、已废弃的RouterTestingModule,以及各 API 背后的根组件、导航等待机制与组件类型校验等底层实现,帮助你在 TestBed 环境中快速编写路由与路由组件的集成测试。
一、这个 API 报告对应的包是什么
API 报告文件开头声明了其归属:
// API Report File for "@angular/router_testing"该报告由 API Extractor 自动生成(文件顶部注明 "Do not edit this file"),描述的是@angular/router的 testing 入口(从 packages/router/testing/src/testing.ts 的模块注释可见:"Entry point for all public APIs of the router/testing package")。该入口导出两组核心能力:
export * from './router_testing_module'; // RouterTestingModule(已废弃) export {RouterTestingHarness} from './router_testing_harness';对应源码文件:
- packages/router/testing/src/router_testing_harness.ts —
RouterTestingHarness实现; - packages/router/testing/src/router_testing_module.ts —
RouterTestingModule实现; - packages/router/testing/src/testing.ts — 公共入口;
- packages/router/testing/test/router_testing_harness.spec.ts — 官方测试用例,是各 API 行为的最直接证据。
下面按 API 报告中的公开面逐项展开。
二、RouterTestingHarness:路由测试的"一站式"测试桩
2.1 公开 API 总览
API 报告中的完整签名为:
export class RouterTestingHarness { static create(initialUrl?: string): Promise<RouterTestingHarness>; detectChanges(): void; readonly fixture: ComponentFixture<{ routerOutletData: WritableSignal<unknown>; }>; navigateByUrl(url: string): Promise<null | {}>; navigateByUrl<T>(url: string, requiredRoutedComponentType: Type<T>): Promise<T>; get routeDebugElement(): DebugElement | null; get routeNativeElement(): HTMLElement | null; }各成员的用途与行为边界如下:
| API | 类型 | 作用 | 关键行为(源码佐证) |
|---|---|---|---|
create(initialUrl?) | 静态方法 | 创建 harness;可选传入初始 URL,创建后先完成一次导航 | 若 harness 已存在则抛错;initialUrl !== undefined时自动navigateByUrl(initialUrl) |
navigateByUrl(url) | 实例方法(重载 1) | 触发一次导航并等待其完成 | 返回导航后RouterOutlet激活的组件实例,未激活时返回null |
navigateByUrl<T>(url, Type<T>) | 实例方法(重载 2) | 同上,且断言激活组件类型 | 激活组件类型不匹配、或导航未激活任何组件时抛出Error |
detectChanges() | 实例方法 | 让 harness 的根 fixture 运行变更检测 | 直接委托给fixture.detectChanges() |
fixture | 只读属性 | harness 根组件的ComponentFixture | 其组件类型含routerOutletData: WritableSignal<unknown>信号 |
routeDebugElement | getter | 路由 outlet 的DebugElement | outlet 未激活(如守卫拒绝导航)时返回null |
routeNativeElement | getter | outlet 的HTMLElement | 即routeDebugElement?.nativeElement ?? null |
2.2 内部结构:自建的根组件与根 fixture
从 router_testing_harness.ts 可以看到 harness 依赖两个内部类:
@Component({ template: '<router-outlet [routerOutletData]="routerOutletData()"></router-outlet>', imports: [RouterOutlet], changeDetection: ChangeDetectionStrategy.Eager, }) export class RootCmp { @ViewChild(RouterOutlet) outlet?: RouterOutlet; readonly routerOutletData = signal<unknown>(undefined); }RootCmp是 harness 自动创建的"根组件",模板里只有一个<router-outlet>,用于渲染路由组件——这正是fixture属性类型为ComponentFixture<{routerOutletData: WritableSignal<unknown>}>的来源:该类型描述的就是RootCmp对外暴露的routerOutletData信号。RootFixtureService则负责懒创建并缓存该 fixture,createHarness()中有一句硬约束:
if (this.harness) { throw new Error('Only one harness should be created per test.'); }即每个测试用例只能创建一个 harness,重复创建会直接抛错;JSDoc 同时要求配合TestBed的ModuleTeardownOptions设置destroyAfterEach: true以保证清理。
2.3 navigateByUrl 的底层实现:等待导航完成 + 类型断言
navigateByUrl的实现(router_testing_harness.ts)分三步:
等待导航完成:先注入
Router,用afterNextNavigation(来自路由包内部的ɵafterNextNavigation)挂一个一次性 Promise,再执行router.navigateByUrl(url)并await该 Promise,随后fixture.detectChanges();取出激活组件:读取
RootCmp上的RouterOutlet,若outlet.isActivated && outlet.activatedRoute.component成立,则返回outlet.component;类型断言:若调用方传入了
requiredRoutedComponentType,则做instanceof检查,不匹配时抛出:`Unexpected routed component type. Expected ${requiredRoutedComponentType.name} but got ${activatedComponent.constructor.name}`当导航根本没有激活任何组件(例如守卫拒绝导航)但调用方又期望得到组件实例时,抛出:
`Unexpected routed component type. Expected ${requiredRoutedComponentType.name} but the navigation did not activate any component.`
这里有一个值得注意的设计细节:harness 不仅等待navigateByUrl的 Promise,还额外等待"下一次导航完成"的信号。这在处理重定向场景时至关重要——官方测试用例waits for redirects using router.navigate(router_testing_harness.spec.ts)中,guard 内部通过inject(Router).navigateByUrl('/redirect')发起二次导航,且目标路由的 guard 还带 100ms 延迟,RouterTestingHarness.create('test')依然能正确等到最终 URL 为/redirect。
2.4 routeDebugElement / routeNativeElement 的判空逻辑
routeDebugElement的实现是:先取RootCmp上的RouterOutlet,若 outlet 不存在或!outlet.isActivated直接返回null;否则在 fixture 的debugElement树中查询componentInstance === outlet.component的节点。这与navigateByUrl的 JSDoc 说明一致:"When testing Routes with guards that reject the navigation, the RouterOutlet might not be activated and the activatedComponent may be null"。
2.5 实战示例(摘自仓库官方测试)
以下示例完整取自 packages/router/testing/test/router_testing_harness.spec.ts,可直接作为编写路由测试的模板:
基本导航并断言组件实例与 DOM:
it('navigates to routed component', async () => { @Component({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'); });注意测试中没有导入任何RouterTestingModule,而是直接用provideRouter提供路由——这正是当前推荐姿势(下文第三节解释原因)。
验证守卫被执行:
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(); });参数变化时复用同一 harness 的二次导航:
it('can observe param changes on routed component with second navigation', async () => { @Component({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(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 () => { // 路由指向 TestCmp,却断言 OtherCmp await expectAsync(harness.navigateByUrl('/123', OtherCmp)).toBeRejected(); }); it('throws an error if navigation fails but expected a component instance', async () => { // 守卫返回 false 拒绝导航,却期望得到 TestCmp 实例 await expectAsync(harness.navigateByUrl('/123', TestCmp)).toBeRejected(); });此外,当没有配置任何路由时,harness.routeDebugElement应为null(gives null for the activatedComponent when no routes are configured用例),可用于验证"导航无处可去"的边界场景。
三、RouterTestingModule:已废弃,但 API 报告仍在
API 报告将该类标记为// @public @deprecated:
export class RouterTestingModule { static withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterTestingModule>; static ɵfac: i0.ɵɵFactoryDeclaration<RouterTestingModule, never>; static ɵinj: i0.ɵɵInjectorDeclaration<RouterTestingModule, never>; static ɵmod: i0.ɵɵNgModuleDeclaration<RouterTestingModule, never, never, [typeof RouterModule]>; }其中ɵfac、ɵinj、ɵmod是 Angular 编译器为 NgModule 生成的声明元数据(ɵmod表明它导出RouterModule),属于 Ivy 编译产物而非手写 API,使用者一般无需关心。真正有意义的公开面只有withRoutes(routes, config?)。
为什么废弃?router_testing_module.ts 的 JSDoc 给出了官方结论:
Use
provideRouterorRouterModule/RouterModule.forRootinstead. This module was previously used to provide a helpful collection of test fakes, most notably those forLocationandLocationStrategy. These are generally not required anymore, asMockPlatformLocationis provided inTestBedby default. However, you can use them directly withprovideLocationMocks.
即:该模块当年的核心价值是为测试提供Location/LocationStrategy的 mock 实现;如今TestBed默认提供MockPlatformLocation,这一价值已不成立,而位置 mock 可改用@angular/common/testing的provideLocationMocks单独引入。
它的实现细节(router_testing_module.ts):
@NgModule({ exports: [RouterModule], providers: [ ROUTER_PROVIDERS, provideLocationMocks(), withPreloading(NoPreloading).ɵproviders, // 测试中禁用预加载 {provide: ROUTES, multi: true, useValue: []}, ], }) export class RouterTestingModule { static withRoutes(routes: Routes, config?: ExtraOptions): ModuleWithProviders<RouterTestingModule> { return { ngModule: RouterTestingModule, providers: [ {provide: ROUTES, multi: true, useValue: routes}, {provide: ROUTER_CONFIGURATION, useValue: config ? config : {}}, ], }; } }要点:withRoutes返回ModuleWithProviders,通过多提供者ROUTES注入路由表、通过ROUTER_CONFIGURATION注入可选的ExtraOptions;模块本身额外提供NoPreloading,确保测试环境不会触发预加载逻辑。旧式写法(imports: [RouterTestingModule.withRoutes([...])])在新代码中应替换为providers: [provideRouter([...])]。
四、入口文件中的内部符号再导出及其原因
testing.ts 除导出两个公共 API 外,还有一组带ɵɵ前缀的再导出:
export {RouterOutlet as ɵɵRouterOutlet} from '../../src/directives/router_outlet'; export {RouterLink as ɵɵRouterLink} from '../../src/directives/router_link'; export {RouterLinkActive as ɵɵRouterLinkActive} from '../../src/directives/router_link_active'; export {EmptyOutletComponent as ɵɵEmptyOutletComponent} from '../../src/components/empty_outlet';源码注释解释了动机:这些符号由RouterTestingModule经由RouterModule导出,Angular 编译器在消费者侧对包内相对导入存在限制,需要通过本入口的再导出让部分编译(partial compilation)输出能正确引用它们;同时注释明确强调这些导出需要保持稳定、不要随意重命名,因为消费方库的编译产物可能已经引用了它们。对使用者而言,这属于实现细节,无需直接使用。
五、使用前提与注意事项
- 必须先配置路由:
RouterTestingHarness本身不注册路由,需先在TestBed中通过provideRouter(...)(或RouterModule)提供路由表,否则导航后routeDebugElement为null(官方用例已验证此行为)。 - 每个测试一个 harness:重复调用
create()会抛出 "Only one harness should be created per test."。 - 需要
destroyAfterEach: true:harness 的 JSDoc 明确要求在ModuleTeardownOptions中开启,以保证 fixture 在用例间被销毁。 - 守卫拒绝导航时不要期待组件:若测试的是拒绝导航的守卫,
navigateByUrl返回null、routeDebugElement为null;此时若误用带类型断言的重载,会收到 "navigation did not activate any component" 错误。 - 导航中的错误处理:用例
throws error if routing throws展示了配合withRouterConfig({resolveNavigationPromiseOnError: true})时,路由抛错会被转化为"导航完成但未激活组件"(navigateByUrl('e')resolve 为null),便于对错误分支做确定性断言。 - 旧代码迁移:使用
RouterTestingModule的测试代码属于废弃 API,建议迁移到provideRouter;若确实需要Locationmock,直接引入provideLocationMocks。
六、相关文件索引
| 内容 | 路径 |
|---|---|
| 本文依据的公共 API 报告 | goldens/public-api/router/testing/index.api.md |
| harness 源码 | packages/router/testing/src/router_testing_harness.ts |
| 测试模块源码(已废弃) | packages/router/testing/src/router_testing_module.ts |
| 包入口 | packages/router/testing/src/testing.ts |
| 官方测试用例 | packages/router/testing/test/router_testing_harness.spec.ts |
| 包描述 | packages/router/testing/PACKAGE.md |
@angular/router/testing的公开面很小但职责清晰:RouterTestingHarness用"自建带<router-outlet>的根组件 + 等待导航完成 + 组件类型断言"三件套,消除了手写根组件、手动监听events、反复detectChanges等样板代码;而RouterTestingModule则作为历史包袱被标记废弃,其能力(尤其是位置 mock)已由TestBed默认行为与provideLocationMocks承接。在编写路由相关的集成测试时,优先使用provideRouter+RouterTestingHarness的组合,是当前仓库自身测试代码(packages/router/testing/test/router_testing_harness.spec.ts)所遵循的范式。
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考