You need to enable JavaScript to run this app. Angular单元测试中如何用Jasmine点击确认弹窗按钮
优惠活动
大模型
产品
解决方案
定价
更多

Angular单元测试中如何用Jasmine点击确认弹窗按钮

Angular + Jasmine 测试无法获取确认弹窗按钮、验证登出逻辑问题

问题现象

  • 业务目标:验证signOut方法中,点击确认弹窗的「确定」按钮后,消息token删除、设备文档更新、用户登出等内部业务逻辑的执行正确性
  • 测试现状:调用组件的signOut方法后,通过fixture.debugElement查询所有.mat-button类的元素,始终返回0个匹配项,无法模拟点击弹窗按钮触发后续流程
  • 备注:弹窗实际运行时会正常渲染按钮元素,但测试环境下始终无法获取到按钮DOM节点

现有代码片段

业务组件代码 main.component.ts

async signOut() {
    const {
      ["modules.organizations.main.sign_out.confirmation.title"]: title,
      ["modules.organizations.main.sign_out.confirmation.message"]: message,
      ["modules.organizations.main.sign_out.confirmation.cancel"]: cancel,
      ["modules.organizations.main.sign_out.confirmation.ok"]: ok,
    } = await this.translate.get([
      "modules.organizations.main.sign_out.confirmation.title",
      "modules.organizations.main.sign_out.confirmation.message",
      "modules.organizations.main.sign_out.confirmation.cancel",
      "modules.organizations.main.sign_out.confirmation.ok"
    ]).pipe(first()).toPromise()

    this.alert.confirmation(title, message, cancel, ok).afterClosed()
    .subscribe(async value => {
      if(!value.ok) return
      const loading = this.alert.loading()
      
      try {
        await this.messaging.deleteToken()
        await this.firestore.devices(this.user.uid).doc(this.messaging.uuid()).update({ deleted: true, deletedAt: this.firestore.now })
        await this.auth.signOut()
        loading.close()
      } catch (error) {
        loading.close()
        await this.auth.signOut()
      }
    })
}

测试文件代码 main.component.spec.ts

fdescribe('MainComponent', () => {
  let component: MainComponent;
  let fixture: ComponentFixture<MainComponent>;
  let bottomSheetMock: jasmine.SpyObj<any>;
  let alertServiceMock: jasmine.SpyObj<any>;
  let dialogRefSpy: jasmine.SpyObj<any>;
  let messagingServiceMock: jasmine.SpyObj<any>;
  let routerMock: jasmine.SpyObj<any>;
  let el: DebugElement;
  
  bottomSheetMock = jasmine.createSpyObj('MatBottomSheet', ['open']);
  bottomSheetMock.open.and.returnValue('true');

  dialogRefSpy = jasmine.createSpy();
  dialogRefSpy.component = {title: 'error', message: 'error'};
  dialogRefSpy.afterClosed = () => of(true);

  const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open', 'close']);
  matDialogSpy.open.and.returnValue(dialogRefSpy);
  matDialogSpy.close.and.returnValue(dialogRefSpy);

  beforeEach(waitForAsync(() => {
    messagingServiceMock = jasmine.createSpyObj('MessagingService', ['requestPermission']);
    messagingServiceMock.requestPermission.and.callThrough();

    routerMock = jasmine.createSpyObj('Router', ['navigate']);
    routerMock.navigate.and.returnValue(true);

    alertServiceMock = jasmine.createSpyObj('AlertService', [
      'message', 'error', 'input', 'password', 'loading', 'confirmation'
    ]);
    alertServiceMock.error.and.returnValue(matDialogSpy.open(AlertComponent, {
      data: { type: 'error', title: 'error', error: 'description' },
      disableClose: true
    }));
    alertServiceMock.input.and.returnValue(matDialogSpy.open(AlertComponent, {
      data: { type: 'input', title: 'title', description: 'description' },
      disableClose: true
    }));
    alertServiceMock.confirmation.and.returnValue(matDialogSpy.open(AlertComponent, {
      data: { type: 'confirmation', title: 'title', message: 'message', cancel: 'cancel', ok: true },
      disableClose: true
    }))
    alertServiceMock.loading.and.returnValue(matDialogSpy);
    alertServiceMock.message.and.callThrough();

    TestBed.configureTestingModule({
      imports: [
        NoopAnimationsModule,
        AngularFireModule.initializeApp(environment.firebase),
        AngularFirestoreModule,
        OverlayModule,
        RouterTestingModule,
        MatDialogModule,
        MatChipsModule,
        TranslateModule.forRoot({
          loader: { provide: TranslateLoader, useClass: TranslateFakeLoader }
        })
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA],
      declarations: [MainComponent],
      providers: [
        { provide: AlertService, useValue: alertServiceMock },
        { provide: MatDialog, useValue: matDialogSpy },
        { provide: MatBottomSheet, useValue: bottomSheetMock },
        { provide: Router, useValue: routerMock },
        { provide: MessagingService, useValue: messagingServiceMock },
        AuthService, TranslateService, UploadService, FormBuilder
      ],
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(MainComponent);
    alertServiceMock = TestBed.inject(AlertService);
    messagingServiceMock = TestBed.inject(MessagingService);
    component = fixture.componentInstance;
    el = fixture.debugElement;
  });

  afterEach(() => {
    fixture.destroy();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should display confirmation alert', fakeAsync(async () => {
    await component.signOut();
    await fixture.whenStable();
    // 查询按钮返回空数组
    let buttonDebugElems: DebugElement[] = fixture.debugElement.queryAll(By.css('.mat-button'));
    console.log('elements =>', buttonDebugElems); 
    
    expect(await alertServiceMock.confirmation).toHaveBeenCalled();
  }));
});

核心问题原因

  • 关键认知偏差:MatDialog生成的弹窗属于独立的Overlay层,DOM节点直接挂载在<body>标签下,根本不在当前被测组件的视图树范围内。即使开启真实弹窗渲染,通过fixture.debugElement.queryAll也永远查不到弹窗内的元素。
  • 现有测试已经将AlertService、MatDialog全部替换为模拟对象,没有真实渲染弹窗的DOM结构,自然查询不到任何按钮元素。
  • 现有模拟逻辑里dialogRefSpy.afterClosed硬编码了固定返回值,没有可控的触发机制,无法模拟用户点击「确定/取消」按钮的场景来触发后续业务逻辑。

落地解决步骤

  1. 重构弹窗Mock逻辑,实现可控的返回值触发
    不要给afterClosed写死固定返回值,替换为可控的Subject,可以手动控制弹窗关闭时的返回结果:
    // 替换原有的dialogRefSpy定义
    import { Subject } from 'rxjs';
    const afterClosedSubject = new Subject<{ok: boolean}>();
    const dialogRefSpy = {
      afterClosed: () => afterClosedSubject.asObservable(),
      close: jasmine.createSpy('dialogClose')
    };
    // 修改alertServiceMock.confirmation的返回值为可控的dialogRefSpy
    alertServiceMock.confirmation.and.returnValue(dialogRefSpy);
    
  2. 无需查找DOM按钮,直接模拟返回值验证业务逻辑
    由于当前测试已经Mock了整个AlertService方法,不会生成真实弹窗DOM,不需要硬查页面元素。只要通过可控的Subject模拟弹窗关闭的返回值,即可触发subscribe内部的业务流程:
    it('should execute full signOut flow when confirm button is clicked', fakeAsync(async () => {
      // 调用组件方法,触发弹窗打开流程
      await component.signOut();
      tick(); // 等待异步翻译流程执行完成
      
      // 模拟用户点击「确定」按钮,弹窗返回ok=true
      afterClosedSubject.next({ok: true});
      afterClosedSubject.complete();
      tick(); // 等待subscribe内部的异步流程执行完成
      
      // 验证所有后续业务方法被正确调用
      expect(messagingServiceMock.deleteToken).toHaveBeenCalled();
      expect(alertServiceMock.loading).toHaveBeenCalled();
      expect(dialogRefSpy.close).toHaveBeenCalled();
    }));
    
    it('should skip signOut flow when cancel button is clicked', fakeAsync(async () => {
      await component.signOut();
      tick();
      
      // 模拟用户点击「取消」按钮,弹窗返回ok=false
      afterClosedSubject.next({ok: false});
      afterClosedSubject.complete();
      tick();
      
      // 验证业务逻辑未触发
      expect(messagingServiceMock.deleteToken).not.toHaveBeenCalled();
    }));
    
  3. 若需验证弹窗DOM渲染的场景
    可保留真实的MatDialog和AlertService渲染流程,此时需要通过document.querySelector查询Overlay层的元素(而非fixture.debugElement),因为弹窗DOM挂载在body节点下:
    // 真实渲染弹窗后,查询确定按钮并模拟点击
    const confirmButton = document.querySelector('.mat-dialog-container .mat-mdc-button');
    confirmButton.dispatchEvent(new MouseEvent('click'));
    tick();
    

内容的提问来源于stack exchange,提问作者Vinicius Lustosa

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.28 01:45:10