Angular测试中router.navigate调用堆叠问题排查咨询
问题背景
我有一个ResumePageComponent,在ngOnInit中调用方法加载数据并根据条件导航至应用内页面。添加若干测试后,出现router.navigate被调用5次的错误。已将导航逻辑从ngOnInit移至独立方法,但测试中Router未重置,请问为何会出现调用堆叠情况?
组件代码
export class ResumePageComponent implements OnInit { public applicationId: string = ""; constructor(private router: Router, private activatedRoute: ActivatedRoute, private applicationFacade: CustomerApplicationFacade, private routingFacade: RoutingFacade) { } ngOnInit(): void { this.loadAndNavigate(); } public loadAndNavigate(): void { this.applicationFacade.commandLoadApplication(this.applicationId).subscribe((success) => { if (success) { const step = this.applicationFacade.getResumeApplicationStep() const page = step > -1 ? applicationDescriptor.pages.find(a => a.index == step) : null; if (page) { if (step < 2) { //if application not started, go to landing page. this.router.navigate(['/apply/' + page.route]); } else { this.routingFacade.determinePageOnResume(page.id); } } else { this.router.navigate(['/portal/dashboard']); } } else { this.router.navigate(['/portal/dashboard']); } }); } }
测试代码
describe('ResumePageComponent', () => { let spectator: Spectator<ResumePageComponent>; let component: ResumePageComponent; const customerApplicationFacade = createSpyObject(CustomerApplicationFacade); let routingFacade: SpyObject<RoutingFacade> = createSpyObject(RoutingFacade); let mockRouter: SpyObject<Router> = createSpyObject(Router); const createComponent = createComponentFactory({ component: ResumePageComponent, imports: [RouterTestingModule], declarations: [ResumePageComponent], providers: [ { provide: CustomerApplicationFacade, useValue: customerApplicationFacade }, { provide: Router, useValue: mockRouter }, { provide: RoutingFacade, useValue: routingFacade }, { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => '1234', }, }, }, } ] }); beforeEach(async () => { customerApplicationFacade.commandLoadApplication.and.returnValue(of(true)); spectator = createComponent(); component = spectator.component; spyOn(component, 'loadAndNavigate').and.stub(); }); fit('should create', () => { expect(component).toBeTruthy(); }); fit('should navigate to dashboard if application is not loaded', () => { customerApplicationFacade.commandLoadApplication.and.returnValue(of(false)); component.loadAndNavigate(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/portal/dashboard']); }); fit('should navigate to dashboard if page index not found', () => { customerApplicationFacade.getResumeApplicationStep.and.returnValue(of(9999)); component.loadAndNavigate(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/portal/dashboard']); }); fit('should navigate to invite-begin if application step is 0', () => { customerApplicationFacade.getResumeApplicationStep.and.returnValue(of(0)); component.loadAndNavigate(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/apply/invite-begin']); expect(routingFacade.determinePageOnResume).not.toHaveBeenCalled(); }); fit('should navigate to begin if application step is 1', () => { customerApplicationFacade.getResumeApplicationStep.and.returnValue(of(1)); component.loadAndNavigate(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/apply/begin']); expect(routingFacade.determinePageOnResume).not.toHaveBeenCalled(); }); fit('should call determinePageOnResume when resume step is greater than 1', () => { customerApplicationFacade.getResumeApplicationStep.and.returnValue(of(2)); component.loadAndNavigate(); expect(routingFacade.determinePageOnResume).toHaveBeenCalled(); }); });
问题原因
全局Spy对象未重置:你创建的
mockRouter、customerApplicationFacade等Spy对象是全局变量,所有测试用例会共享它们的调用记录。每个测试执行后,调用记录不会自动清空,导致后续测试的断言会包含之前所有测试的调用次数,最终叠加成5次(对应5个测试用例)。组件初始化时的意外调用:在
beforeEach中,你是先创建组件(此时ngOnInit已执行,loadAndNavigate被调用),之后才stub该方法。这会导致第一个测试用例(should create)中已经触发了一次loadAndNavigate,进而调用了router.navigate,后续测试手动调用该方法时,调用记录持续累加。异步订阅未同步处理:虽然
commandLoadApplication返回的是同步Observable,但测试中未确保订阅完成后再断言,可能导致调用记录的统计时机出现偏差,叠加问题更明显。
解决方案
1. 每个测试用例前重置Spy调用记录
在beforeEach中添加Spy的重置逻辑,确保每个测试用例的调用记录都是独立的:
beforeEach(async () => { // 重置所有Spy的调用记录 mockRouter.navigate.calls.reset(); customerApplicationFacade.commandLoadApplication.calls.reset(); customerApplicationFacade.getResumeApplicationStep.calls.reset(); routingFacade.determinePageOnResume.calls.reset(); customerApplicationFacade.commandLoadApplication.and.returnValue(of(true)); spectator = createComponent(); component = spectator.component; spyOn(component, 'loadAndNavigate').and.stub(); });
2. 提前Stub loadAndNavigate 方法
避免组件初始化时自动调用该方法,在创建组件前就Stub,或者使用Spectator的overrideMethod替换:
beforeEach(async () => { // 重置Spy mockRouter.navigate.calls.reset(); customerApplicationFacade.commandLoadApplication.calls.reset(); customerApplicationFacade.getResumeApplicationStep.calls.reset(); routingFacade.determinePageOnResume.calls.reset(); customerApplicationFacade.commandLoadApplication.and.returnValue(of(true)); // 创建组件前Stub方法,避免ngOnInit自动调用 spectator = createComponent({ overrideComponents: [ { component: ResumePageComponent, override: { methods: { loadAndNavigate: () => {} } } } ] }); component = spectator.component; });
3. 管理组件订阅,避免内存泄漏
在组件中添加Subscription对象管理订阅,在ngOnDestroy中退订,防止组件销毁后订阅仍触发导航:
export class ResumePageComponent implements OnInit, OnDestroy { public applicationId: string = ""; private subscription = new Subscription(); constructor(private router: Router, private activatedRoute: ActivatedRoute, private applicationFacade: CustomerApplicationFacade, private routingFacade: RoutingFacade) { } ngOnInit(): void { this.loadAndNavigate(); } public loadAndNavigate(): void { const sub = this.applicationFacade.commandLoadApplication(this.applicationId).subscribe((success) => { // 原有导航逻辑... }); this.subscription.add(sub); } ngOnDestroy(): void { this.subscription.unsubscribe(); } }
4. 使用fakeAsync确保异步操作完成
对于Observable订阅,用fakeAsync和tick确保断言前订阅已执行完成:
fit('should navigate to dashboard if application is not loaded', fakeAsync(() => { customerApplicationFacade.commandLoadApplication.and.returnValue(of(false)); component.loadAndNavigate(); tick(); // 触发Observable订阅的执行 expect(mockRouter.navigate).toHaveBeenCalledWith(['/portal/dashboard']); }));
内容的提问来源于stack exchange,提问作者atomantium

