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

Angular测试问题:订阅HTTP响应Observable触发重复请求

Angular服务POST请求重复触发问题解析

背景

我有一个负责向后端发送表单数据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的核心特性是:每次被订阅时,都会从头执行一遍整个数据流。

在你的服务代码中:

  1. 调用this.http.post创建了一个冷Observable
  2. 服务内部先订阅了一次这个Observable,触发第一次HTTP请求
  3. 随后将同一个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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.21 22:42:34