You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular测试中router.navigate调用堆叠问题排查咨询

问题分析与解决方案: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();
  });
});

问题原因

  1. 全局Spy对象未重置:你创建的mockRouter、customerApplicationFacade等Spy对象是全局变量,所有测试用例会共享它们的调用记录。每个测试执行后,调用记录不会自动清空,导致后续测试的断言会包含之前所有测试的调用次数,最终叠加成5次(对应5个测试用例)。

  2. 组件初始化时的意外调用:在beforeEach中,你是先创建组件(此时ngOnInit已执行,loadAndNavigate被调用),之后才stub该方法。这会导致第一个测试用例(should create)中已经触发了一次loadAndNavigate,进而调用了router.navigate,后续测试手动调用该方法时,调用记录持续累加。

  3. 异步订阅未同步处理:虽然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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.25 04:35:56