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

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' }
    );
  });
});

问题排查

  1. ProductService未Mock:组件初始化时ngOnInit调用getProducts,发送实际HTTP请求失败触发error分支,导致测试出现意外的snackBar.open调用,干扰断言逻辑。
  2. 组件导入冗余模块:原组件imports中包含AppModule,独立组件无需导入NgModule,会导致依赖注入混乱。
  3. 未处理异步时序:getProducts是异步方法,组件初始化的异步逻辑未完成就执行测试断言,引发时序问题。
  4. 测试冗余组件导入:模拟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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 20:44:51