Angular测试问题:jasmine-marbles测试NGRX Effect返回空结果
问题背景
我们有一个基于Angular + NGRX Store的项目,所有Effect均遵循「触发动作→API调用→成功动作」的逻辑,API调用基于Open API规范自动生成的客户端实现,所有API方法返回Observable。目前使用jasmine-marbles测试Effect,大部分用例正常,仅文件下载相关测试失败。
测试代码
it('loads a something file', () => { const action = SomethingActions.Get({ param: 's1' }); actions$ = hot('--a-', { a: action }); const api = cold('--b', { b: new Blob([TEST_DATA]) }); const expected = cold('----c', { c: SomethingActions.GetSuccess({ param: 's1', records: EXPECTED_RESULT }), }); when(fileServiceMock.getFile('something-s1.txt')).thenReturn(api); // effects.loadSomething$.subscribe((result) => { // console.log(result); // }); expect(effects.loadSomething$).toBeObservable(expected); verify(fileServiceMock.getFile('something-s1.txt')).once(); });
错误信息
取消注释订阅代码后,会在测试报错后打印出预期结果,错误提示:
Expect $.length = 0 to equal 1.
Effect 代码
public loadSomething$ = createEffect(() => this.actions$.pipe( ofType(SomethingActions.Get), mergeMap(({ param }) => { return fileService.getFile(`something-${param}.txt`)).pipe( mergeMap(async (blob) => await blob.text()), catchError(() => of(DEFAULT_DATA)), map((csv) => SomethingActions.GetSuccess({ spindle, records: parseData(csv) })) ); }), ));
补充调试信息
在Effect中多处添加tap(x => console.log(x))调试,显示Effect在测试期间确实返回了预期结果,但测试仍不通过。
问题原因及解决方案
核心原因:异步Promise与Marbles虚拟时间调度不兼容
你的Effect中使用了mergeMap(async (blob) => await blob.text()),这里的async/await会将Observable流转换为真实异步Promise。而jasmine-marbles的toBeObservable依赖同步虚拟时间调度器,虚拟时间不会等待真实异步操作(比如Blob.text()返回的Promise)完成,导致Marbles测试判定流未发出任何值,从而抛出$.length = 0的错误。
手动订阅能看到结果,是因为手动订阅会等待真实异步任务执行完成,但Marbles测试的TestScheduler只会同步执行虚拟时间内的操作,不会处理真实异步逻辑。
解决方案
方案1:Mock Blob的异步行为
不要返回真实Blob对象,而是构造一个mock对象,将text()方法改为同步或可控的异步实现:
// 替换测试中的api定义 const mockBlob = { text: () => Promise.resolve(TEST_DATA) } as unknown as Blob; const api = cold('--b', { b: mockBlob });
方案2:将Promise转换为Observable(推荐)
修改Effect代码,用RxJS的from将Blob.text()返回的Promise转换为Observable,让整个流受RxJS调度器控制:
// 修改Effect中的mergeMap逻辑 mergeMap((blob) => from(blob.text())), // 用from把Promise转为Observable
这样整个流的时序完全由TestScheduler的虚拟时间管理,Marbles测试就能正确识别发出的值和时序。
方案3:用fakeAsync/tick替代Marbles测试(备选)
若不想修改代码或mock,可使用Angular的fakeAsync和tick()等待异步完成:
it('loads a something file', fakeAsync(() => { const action = SomethingActions.Get({ param: 's1' }); const expectedAction = SomethingActions.GetSuccess({ param: 's1', records: EXPECTED_RESULT }); let emittedAction: any; when(fileServiceMock.getFile('something-s1.txt')).thenReturn(cold('--b', { b: new Blob([TEST_DATA]) })); effects.loadSomething$.subscribe(action => emittedAction = action); actions$.next(action); tick(1000); // 等待异步操作完成 expect(emittedAction).toEqual(expectedAction); verify(fileServiceMock.getFile('something-s1.txt')).once(); }));
此方法会失去Marbles测试的时序可视化优势,仅作为特殊场景下的备选。
内容的提问来源于stack exchange,提问作者Woozar

