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

如何测试Angular组件中RxJS Tap内的属性变更?

Angular组件中RxJS Pipe/Tap操作的属性变更测试方案

问题背景

组件通过ngOnInit中的RxJS流加载分类数据,经过tap操作处理后更新两个BehaviorSubject:categories$和numberOfSelectedCategories$。测试时函数调用验证通过,但属性值未正确更新(numberOfSelectedCategories$始终为0)。

组件核心代码:

ngOnInit(): void {
  const getOne$ = this.appDataService.proxy.getAllWithoutPaginate().pipe(
    tap((result: Array<CategoryInterface>) => {
      const categories = [];

      result.forEach((category: CategoryInterface) => {
        this.parseCategoryTree(categories, category);
      });

      this.categories$.next(categories);
      this.numberOfSelectedCategories$.next(this.calendarService.categories$.value.length);
    })
  );

  this.subscriptions.add(this.appDataService.start(getOne$, 'load categories').subscribe());
}

原有测试代码的核心问题:

// ... 省略导入
describe('CalendarHeaderComponent', () => {
  let component: CalendarHeaderComponent;
  let fixture: ComponentFixture<CalendarHeaderComponent>;
  const datas = CATEGORY_RESPONSE;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, DdataCoreModule.forRoot(environment), RouterTestingModule, AppModule],
      declarations: [CalendarHeaderComponent]
    })
    .compileComponents()
    .then(() => {
      fixture = TestBed.createComponent(CalendarHeaderComponent);
      component = fixture.componentInstance;

      // 错误:组件初始化后才设置Spy,ngOnInit已执行,用的是真实服务
      const proxySpy = jasmine.createSpyObj('ProxyService', ['getAllWithoutPaginate']);
      const appDataServiceSpy = jasmine.createSpyObj('AppDataService', ['proxy', 'start']);

      appDataServiceSpy.start.and.returnValue(of(datas));
      proxySpy.getAllWithoutPaginate.and.returnValue(of(datas));
      appDataServiceSpy.proxy = proxySpy;
      component.appDataService = appDataServiceSpy;

      fixture.detectChanges();
    });
  }));

  it('properties changing after load', fakeAsync(() => {
    // 错误:未处理异步流,且calendarService未Mock
    expect(component.numberOfSelectedCategories$.value).withContext('numberOfSelectedCategories$').toEqual(10);
    expect(component.categories$.value);
  }));
});

问题分析

  1. Spy设置时机错误:fixture.detectChanges()会触发ngOnInit,但此时Spy是在组件创建后才赋值,导致ngOnInit执行时使用真实服务而非Mock实例。
  2. 依赖Mock不完整:numberOfSelectedCategories$的值依赖calendarService.categories$.value.length,原有测试未Mock该Observable,导致其值为空数组,长度为0。
  3. 未处理RxJS异步流:使用fakeAsync但未调用flush()/tick(),RxJS流未完成,tap中的逻辑未触发。

修复步骤

1. 提前在TestBed中注入Mock服务

将Spy服务配置到TestBed的providers中,确保组件初始化时直接使用Mock实例:

beforeEach(waitForAsync(() => {
  // 提前创建并配置Spy
  const proxySpy = jasmine.createSpyObj('ProxyService', ['getAllWithoutPaginate']);
  const appDataServiceSpy = jasmine.createSpyObj('AppDataService', ['start']);
  appDataServiceSpy.proxy = proxySpy;
  proxySpy.getAllWithoutPaginate.and.returnValue(of(datas));
  appDataServiceSpy.start.and.callFake((obs$) => obs$); // 模拟start转发Observable的行为

  // Mock calendarService的categories$,返回处理后的10个分类
  const mockCategories = datas.flatMap(cat => [cat, ...(cat.children || [])]);
  const calendarServiceSpy = jasmine.createSpyObj('CalendarService', [], {
    categories$: new BehaviorSubject(mockCategories)
  });

  TestBed.configureTestingModule({
    imports: [HttpClientTestingModule, RouterTestingModule],
    declarations: [CalendarHeaderComponent],
    providers: [
      { provide: AppDataService, useValue: appDataServiceSpy },
      { provide: CalendarService, useValue: calendarServiceSpy }
    ]
  }).compileComponents().then(() => {
    fixture = TestBed.createComponent(CalendarHeaderComponent);
    component = fixture.componentInstance;
    fixture.detectChanges(); // 此时ngOnInit使用Mock服务执行
  });
}));

2. 在测试用例中触发异步流完成

在fakeAsync测试中调用flush(),确保所有RxJS异步操作执行完毕:

it('properties changing after load', fakeAsync(() => {
  flush(); // 触发所有异步流完成

  // 验证categories$处理后的长度
  expect(component.categories$.value.length).withContext('categories$ length').toEqual(10);
  // 验证选中分类数量
  expect(component.numberOfSelectedCategories$.value).withContext('numberOfSelectedCategories$').toEqual(10);
}));

3. 可选:验证分类树处理逻辑

如果需要确保parseCategoryTree的正确性,可以增加结构验证:

it('parses category tree correctly', fakeAsync(() => {
  flush();
  const processedCategories = component.categories$.value;
  
  // 验证父分类和子分类数量匹配测试数据
  expect(processedCategories.filter(c => !c.parentId).length).toEqual(6);
  expect(processedCategories.filter(c => c.parentId).length).toEqual(4);
}));

完整修复后的测试代码示例

import { fakeAsync, flush, ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { BehaviorSubject, of } from 'rxjs';
import { CalendarHeaderComponent } from './calendar-header.component';
import { AppDataService } from 'path-to-app-data-service';
import { CalendarService } from 'path-to-calendar-service';
import { CATEGORY_RESPONSE } from 'path-to-test-data';

describe('CalendarHeaderComponent', () => {
  let component: CalendarHeaderComponent;
  let fixture: ComponentFixture<CalendarHeaderComponent>;
  const datas = CATEGORY_RESPONSE;

  beforeEach(waitForAsync(() => {
    const proxySpy = jasmine.createSpyObj('ProxyService', ['getAllWithoutPaginate']);
    const appDataServiceSpy = jasmine.createSpyObj('AppDataService', ['start']);
    appDataServiceSpy.proxy = proxySpy;
    proxySpy.getAllWithoutPaginate.and.returnValue(of(datas));
    appDataServiceSpy.start.and.callFake((obs$) => obs$);

    const mockCategories = datas.flatMap(cat => [cat, ...(cat.children || [])]);
    const calendarServiceSpy = jasmine.createSpyObj('CalendarService', [], {
      categories$: new BehaviorSubject(mockCategories)
    });

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, RouterTestingModule],
      declarations: [CalendarHeaderComponent],
      providers: [
        { provide: AppDataService, useValue: appDataServiceSpy },
        { provide: CalendarService, useValue: calendarServiceSpy }
      ]
    }).compileComponents().then(() => {
      fixture = TestBed.createComponent(CalendarHeaderComponent);
      component = fixture.componentInstance;
      fixture.detectChanges();
    });
  }));

  it('calls service methods on init', fakeAsync(() => {
    flush();
    expect(component.appDataService.proxy.getAllWithoutPaginate).toHaveBeenCalledTimes(1);
    expect(component.appDataService.start).toHaveBeenCalledTimes(1);
  }));

  it('updates properties after data load', fakeAsync(() => {
    flush();

    expect(component.categories$.value.length).toEqual(10);
    expect(component.numberOfSelectedCategories$.value).toEqual(10);
  }));
});

内容的提问来源于stack exchange,提问作者Ferenc Bablena

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.19 18:15:49