Angular测试问题:订阅HTTP响应Observable触发重复请求
背景
我有一个负责向后端发送表单数据POST请求的Angular服务。发送请求的方法会根据表单数据的类实例选择对应端点,发送HTTP POST请求并通过pipe捕获错误。服务自身需要根据后端响应执行逻辑,因此会自行订阅这个Observable,同时返回Observable引用,让调用组件也能处理响应:
/* ... */ export class MyService { ROUTES = { 'A': '/api/a/edit', 'B': '/api/b/edit', } /* ... */ private getType(o: ClassA | ClassB): keyof typeof this.ROUTES | undefined { return o instanceof ClassA ? "A" : o instanceof ClassB ? "B" : undefined } public postObject(o: ClassA | ClassB){ const type = this.getType(o) if (!type) { console.error('Object type not supported for POST request.', o); return of(false) } const observable = this.http.post(this.ROUTES[type], o) .pipe(catchError(this.feedbackService.notifyError())) observable.subscribe(_ => { /* 服务内部需要执行的逻辑 */ }) return observable } }
测试该服务时,我传入不同类的数据,验证请求是否发送到正确端点:
describe('MyService', () => { let service: MyService; let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientModule, HttpClientTestingModule ], }); service = TestBed.inject(MyService); httpTestingController = TestBed.inject(HttpTestingController); }); it('should post object to the right URL', () => { const URLS = { A: generateURL('/api/a/edit'), B: generateURL('/api/b/edit'), } let postObject: ClassA | ClassB = new ClassA() service.postObject(postObject) httpTestingController.expectNone(URLS.B) httpTestingController.expectOne(URLS.A).flush(true) postObject = new ClassB() service.postObject(postObject) httpTestingController.expectNone(URLS.A) httpTestingController.expectOne(URLS.B).flush(true) httpTestingController.verify() }) }); function generateURL(path: string) { const request = new HttpRequest('POST', path, null) return request.url }
问题描述
上述测试可以正常运行,但会提示SPEC HAS NO EXPECTATIONS。为解决这个问题,我尝试订阅返回的Observable并添加断言:
service.postObject(postObject).subscribe(status => { expect(status).toBe(true) httpTestingController.verify() });
此时httpTestingController抛出错误:
Expected one matching request for criteria "Match URL: /api/a/edit", found 2 requests
通过日志发现,尽管postObject方法内仅调用了一次post,却产生了两次/api/a/edit请求。为何订阅会触发Observable发起新请求?
原因分析
Angular的HttpClient返回的是冷Observable。冷Observable的核心特性是:每次被订阅时,都会从头执行一遍整个数据流。
在你的服务代码中:
- 调用
this.http.post创建了一个冷Observable - 服务内部先订阅了一次这个Observable,触发第一次HTTP请求
- 随后将同一个Observable返回给测试代码,测试代码再次订阅,触发第二次HTTP请求
这就是重复请求产生的根本原因。
解决方案
有两种常见的解决思路:
方案1:将冷Observable转为热Observable
使用shareReplay(1)操作符将冷Observable转为多播的热Observable,这样所有订阅者都会共享同一个数据流,HTTP请求只会发送一次:
public postObject(o: ClassA | ClassB){ const type = this.getType(o) if (!type) { console.error('Object type not supported for POST request.', o); return of(false) } const observable = this.http.post(this.ROUTES[type], o) .pipe( catchError(this.feedbackService.notifyError()), shareReplay(1) // 转为热Observable,共享数据流 ) observable.subscribe(_ => { /* 服务内部逻辑 */ }) return observable }
方案2:用tap替代内部订阅
将服务内部需要执行的逻辑放到tap操作符中,不再自行订阅Observable,而是让调用者(包括组件和测试代码)负责订阅。这样无论多少订阅者,只要有一次订阅就会触发HTTP请求,同时执行tap中的逻辑:
public postObject(o: ClassA | ClassB){ const type = this.getType(o) if (!type) { console.error('Object type not supported for POST request.', o); return of(false) } return this.http.post(this.ROUTES[type], o) .pipe( catchError(this.feedbackService.notifyError()), tap(_ => { /* 原来的内部逻辑移到这里 */ }) ) }
使用方案2后,测试代码订阅返回的Observable时,会触发一次HTTP请求,同时执行服务内部的tap逻辑,既解决了重复请求问题,也能正常添加断言消除SPEC HAS NO EXPECTATIONS提示。
版本信息
相关依赖包版本:
- @angular-devkit/build-angular@17.3.1
- @angular-devkit/schematics@17.3.1
- @angular/animations@17.3.1
- @angular/cdk@17.3.10
- @angular/cli@17.3.1
- @angular/common@17.3.1
- @angular/compiler-cli@17.3.1
- @angular/compiler@17.3.1
- @angular/core@17.3.1
- @angular/forms@17.3.1
- @angular/language-service@17.3.1
- @angular/platform-browser-dynamic@17.3.1
- @angular/platform-browser@17.3.1
- @angular/router@17.3.1
- @rds/angular-components@4.9.1
- @rds/core@4.9.1
- @types/jasmine@5.1.4
- @types/jest@29.5.12
- @types/node@20.12.5
内容的提问来源于stack exchange,提问作者Ynfiniti

