Angular 18中Mat Dialog单元测试失败:间谍未生效问题
解决Angular 18单元测试中MatDialog和MatSnackBar间谍未生效问题
问题描述
在Angular 18独立组件AppComponent中,onUpdate方法通过MatDialog打开UpdateProductComponent,编写单元测试模拟MatDialog时,出现间谍未生效的报错:
Expected spy MatSnackBar.open to have been called with:
[ 'Something went wrong', 'Cerrar', Object({ duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom' }) ]
but it was never called.
组件代码
import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ProductService } from '@services/product.service'; import { Product } from '@models/product.model'; import { MatIconModule } from '@angular/material/icon'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { UpdateProductComponent } from './modals/update.product/update.product.component'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSnackBar } from '@angular/material/snack-bar'; import { PaginatedProducts } from '@models/paginate.product.model'; @Component({ selector: 'app-root', standalone: true, templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], imports: [ MatProgressSpinnerModule, CommonModule, MatIconModule, MatFormFieldModule, MatDialogModule, ], }) export class AppComponent implements OnInit { isLoading = true; errorMessage = ''; products: Product[] = []; isDown = false; startX = 0; scrollLeft = 0; queryPaginationProduct: any = {}; constructor( private productService: ProductService, public dialog: MatDialog, public snackBar: MatSnackBar, ) {} ngOnInit() { this.queryPaginationProduct = { limit: 10, page: 1, total: 10 }; this.getProducts(); } getProducts() { this.isLoading = true; this.productService .getProducts( this.queryPaginationProduct.page, this.queryPaginationProduct.limit, ) .subscribe({ next: (response: PaginatedProducts) => { this.products = response.products; this.isLoading = false; this.queryPaginationProduct = { limit: response.limit, page: response.page, total: response.total, }; this.snackBar.open(response.message, 'Cerrar', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom', }); }, error: (error) => { this.errorMessage = 'Failed to load data'; this.isLoading = false; console.error(error); }, }); } onPageChange(event: any): void { this.queryPaginationProduct.page = event.pageIndex + 1; this.queryPaginationProduct.limit = event.pageSize; this.getProducts(); } trackByFn(index: any, item: Product) { return item.id; } onUpdate(item: Product) { const dialogRef = this.dialog.open(UpdateProductComponent, { width: '800px', disableClose: true, hasBackdrop: true, data: item, }); dialogRef.afterClosed().subscribe((result) => { if (result.status === 'ok') { this.getProducts(); } this.snackBar.open(result.message, 'Cerrar', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom', }); }); } }
原测试代码
import { TestBed, ComponentFixture } from '@angular/core/testing'; import { Subject, of } from 'rxjs'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppComponent } from './app.component'; import { UpdateProductComponent } from './modals/update.product/update.product.component'; import { Product } from '@models/product.model'; import { NoopAnimationsModule, provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient, withFetch } from '@angular/common/http'; describe('AppComponent (fully stubbed MatDialog)', () => { let component: AppComponent; let fixture: ComponentFixture<AppComponent>; let matDialogStub: { openDialogs: MatDialogRef<any>[]; afterOpened: Subject<MatDialogRef<any>>; afterAllClosed: Subject<void>; open: jasmine.Spy; closeAll: () => void; }; let snackBar: jasmine.SpyObj<MatSnackBar>; beforeEach(async () => { const afterOpenedSubject = new Subject<MatDialogRef<any>>(); const afterAllClosedSubject = new Subject<void>(); matDialogStub = { openDialogs: [], afterOpened: afterOpenedSubject, afterAllClosed: afterAllClosedSubject, open: jasmine.createSpy('open').and.callFake((componentOrTemplateRef, config) => { const mockDialogRef = { afterClosed: () => of({ status: 'ok', message: 'Product updated successfully' }), close: () => { const index = matDialogStub.openDialogs.indexOf(mockDialogRef as any); if (index > -1) { matDialogStub.openDialogs.splice(index, 1); } if (matDialogStub.openDialogs.length === 0) { afterAllClosedSubject.next(); } }, } as MatDialogRef<UpdateProductComponent>; matDialogStub.openDialogs.push(mockDialogRef); afterOpenedSubject.next(mockDialogRef); return mockDialogRef; }), closeAll: () => { matDialogStub.openDialogs.length = 0; afterAllClosedSubject.next(); }, }; const snackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']); await TestBed.configureTestingModule({ imports: [AppComponent, UpdateProductComponent, NoopAnimationsModule], providers: [ { provide: MatDialog, useValue: matDialogStub }, { provide: MatSnackBar, useValue: snackBarSpy }, provideAnimations(), provideHttpClient(withFetch()), ], }) .overrideComponent(AppComponent, { remove: { imports: [] }, }) .compileComponents(); fixture = TestBed.createComponent(AppComponent); component = fixture.componentInstance; snackBar = TestBed.inject(MatSnackBar) as jasmine.SpyObj<MatSnackBar>; }); it('should open UpdateProductComponent and handle afterClosed = {status: "ok"}', () => { spyOn(component, 'getProducts'); const mockProduct: Product = { id: 123, name: 'Test Product', category: 'Test Category', price: 100, quantity: 5 }; component.onUpdate(mockProduct); expect(matDialogStub.open).toHaveBeenCalledWith(UpdateProductComponent, { width: '800px', disableClose: true, hasBackdrop: true, data: mockProduct, }); expect(component.getProducts).toHaveBeenCalled(); expect(snackBar.open).toHaveBeenCalledWith( 'Product updated successfully', 'Cerrar', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom' } ); }); });
问题排查
- ProductService未Mock:组件初始化时
ngOnInit调用getProducts,发送实际HTTP请求失败触发error分支,导致测试出现意外的snackBar.open调用,干扰断言逻辑。 - 组件导入冗余模块:原组件
imports中包含AppModule,独立组件无需导入NgModule,会导致依赖注入混乱。 - 未处理异步时序:
getProducts是异步方法,组件初始化的异步逻辑未完成就执行测试断言,引发时序问题。 - 测试冗余组件导入:模拟
MatDialog时无需导入UpdateProductComponent,反而会引发额外依赖问题。
修复方案
1. 修复组件导入
移除AppModule和重复的MatProgressSpinnerModule,仅保留独立组件必需的模块。
2. Mock核心服务
创建ProductService间谍对象,模拟getProducts返回成功Observable,避免实际HTTP请求;同时完善MatDialog和MatSnackBar的mock逻辑。
3. 处理异步时序
使用fakeAsync和tick处理组件初始化的异步操作,确保所有订阅回调执行完成后再进行断言。
4. 简化测试依赖
移除测试中不必要的UpdateProductComponent导入,减少依赖冲突。
修复后的完整测试代码
import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; import { Subject, of } from 'rxjs'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppComponent } from './app.component'; import { Product } from '@models/product.model'; import { NoopAnimationsModule, provideAnimations } from '@angular/platform-browser/animations'; import { ProductService } from '@services/product.service'; import { PaginatedProducts } from '@models/paginate.product.model'; describe('AppComponent (fully stubbed MatDialog)', () => { let component: AppComponent; let fixture: ComponentFixture<AppComponent>; let matDialogStub: { openDialogs: MatDialogRef<any>[]; afterOpened: Subject<MatDialogRef<any>>; afterAllClosed: Subject<void>; open: jasmine.Spy; closeAll: () => void; }; let snackBar: jasmine.SpyObj<MatSnackBar>; let productServiceSpy: jasmine.SpyObj<ProductService>; beforeEach(async () => { const afterOpenedSubject = new Subject<MatDialogRef<any>>(); const afterAllClosedSubject = new Subject<void>(); // Mock MatDialog matDialogStub = { openDialogs: [], afterOpened: afterOpenedSubject, afterAllClosed: afterAllClosedSubject, open: jasmine.createSpy('open').and.callFake((componentOrTemplateRef, config) => { const mockDialogRef = { afterClosed: () => of({ status: 'ok', message: 'Product updated successfully' }), close: () => { const index = matDialogStub.openDialogs.indexOf(mockDialogRef as any); if (index > -1) { matDialogStub.openDialogs.splice(index, 1); } if (matDialogStub.openDialogs.length === 0) { afterAllClosedSubject.next(); } }, } as MatDialogRef<any>; matDialogStub.openDialogs.push(mockDialogRef); afterOpenedSubject.next(mockDialogRef); return mockDialogRef; }), closeAll: () => { matDialogStub.openDialogs.length = 0; afterAllClosedSubject.next(); }, }; // Mock MatSnackBar const snackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']); // Mock ProductService productServiceSpy = jasmine.createSpyObj('ProductService', ['getProducts']); const mockPaginatedResponse: PaginatedProducts = { products: [], limit: 10, page: 1, total: 0, message: 'Products loaded successfully' }; productServiceSpy.getProducts.and.returnValue(of(mockPaginatedResponse)); await TestBed.configureTestingModule({ imports: [AppComponent, NoopAnimationsModule], providers: [ { provide: MatDialog, useValue: matDialogStub }, { provide: MatSnackBar, useValue: snackBarSpy }, { provide: ProductService, useValue: productServiceSpy }, provideAnimations(), ], }).compileComponents(); fixture = TestBed.createComponent(AppComponent); component = fixture.componentInstance; snackBar = TestBed.inject(MatSnackBar) as jasmine.SpyObj<MatSnackBar>; }); it('should initialize correctly', fakeAsync(() => { fixture.detectChanges(); // 触发ngOnInit tick(); // 等待getProducts异步逻辑完成 expect(productServiceSpy.getProducts).toHaveBeenCalledWith(1, 10); expect(snackBar.open).toHaveBeenCalledWith('Products loaded successfully', 'Cerrar', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom' }); })); it('should open UpdateProductComponent and handle afterClosed = {status: "ok"}', () => { spyOn(component, 'getProducts'); const mockProduct: Product = { id: 123, name: 'Test Product', category: 'Test Category', price: 100, quantity: 5 }; component.onUpdate(mockProduct); // 验证Dialog打开参数 expect(matDialogStub.open).toHaveBeenCalledWith(jasmine.any(Function), { width: '800px', disableClose: true, hasBackdrop: true, data: mockProduct, }); // 验证getProducts调用 expect(component.getProducts).toHaveBeenCalled(); // 验证SnackBar触发 expect(snackBar.open).toHaveBeenCalledWith( 'Product updated successfully', 'Cerrar', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom' } ); }); });
关键修复点说明
- Mock ProductService:避免实际HTTP请求,消除初始化时的意外
snackBar调用,同时验证初始化逻辑的正确性。 - 异步时序处理:用
fakeAsync/tick确保组件初始化的异步逻辑执行完成后再断言,解决时序问题。 - 简化Dialog断言:使用
jasmine.any(Function)替代直接传入组件类,避免测试依赖未导入的组件。 - 精简依赖导入:仅保留测试必需的模块和服务,减少冲突概率。
内容的提问来源于stack exchange,提问作者newuser147
相关产品推荐
相关产品推荐

