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硬编码了固定返回值,没有可控的触发机制,无法模拟用户点击「确定/取消」按钮的场景来触发后续业务逻辑。
落地解决步骤
- 重构弹窗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); - 无需查找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(); })); - 若需验证弹窗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
相关产品推荐
相关产品推荐

