首页
/ 使用 `@angular/common/http/testing` 测试 Angular HttpClient 请求:从模拟后端到拦截器验证的完整实践指南

使用 `@angular/common/http/testing` 测试 Angular HttpClient 请求:从模拟后端到拦截器验证的完整实践指南

2026-09-06 18:09:15作者:郦嵘贵Just

导读

在 Angular 应用中,HttpClient 是与远程服务器通信的核心 API,而任何外部依赖在单元测试中都应当被模拟。本指南基于 Angular 官方文档 Test requests,系统讲解如何使用 @angular/common/http/testing 库搭建「先发起请求、后断言并 flush」的测试范式:如何通过 provideHttpClientTesting() 配置测试后端、用 HttpTestingController 期望与应答请求、处理并发请求、覆盖后端错误与网络错误场景,并验证函数式与类式两类拦截器。读完本文,你将掌握一套可直接落地的、不依赖真实网络的 HTTP 测试方案。


一、测试 HTTP 的核心思想:模拟后端而非真实网络

如同对待任何外部依赖一样,测试 HttpClient 时必须模拟 HTTP 后端,使测试能够与"远程服务器"交互。@angular/common/http/testing 提供了一整套工具,用于捕获应用发出的请求、对请求作出断言,并通过**flush(冲刷)**每个预期请求来模拟响应、复现后端行为。

该测试库遵循一种固定的工作模式,贯穿全部测试用例:

  1. 应用先执行代码并发起请求
  2. 测试随后断言某些请求"应当已发出"或"不应发出";
  3. 测试针对这些请求的 URL、method、header 等属性进行校验;
  4. 通过 flush() 为每个预期请求提供响应
  5. 最后调用 verify() 确认应用没有发出任何意外的请求

从源码结构看,这套能力由 packages/common/http/testing 包实现,其核心类关系为:

  • HttpClientTestingBackend:既是 HttpBackend(实际执行请求的后端),又是 HttpTestingController(测试控制器),见 backend.ts
  • TestRequest:封装了已收到、等待应答的模拟请求,见 request.ts
  • HttpTestingController:面向测试的抽象控制器 API,见 api.ts

二、搭建测试环境:provideHttpClientTesting()TestBed

2.1 最小配置

要开始测试 HttpClient,只需在测试的 TestBed 配置中加入 provideHttpClientTesting()。Angular 测试环境本就会提供 HttpClient,而 provideHttpClientTesting() 会将其配置为使用测试后端而非真实网络,同时提供可注入的 HttpTestingController,用于与测试后端交互、设置关于"已发出哪些请求"的期望并 flush 这些请求的响应:

TestBed.configureTestingModule({
  providers: [
    // ... 其他测试 provider
    provideHttpClientTesting(),
  ],
});

const httpTesting = TestBed.inject(HttpTestingController);

配置完成后,测试中发出的请求都会命中测试后端,随后便可使用 httpTesting 对请求进行断言。

从实现上看,provideHttpClientTesting() 返回一组 provider(见 provider.ts),其关键动作是把 HttpBackendHttpTestingController 都指向同一个 HttpClientTestingBackend 单例,并关闭"请求参与稳定性判定"标记(ɵREQUESTS_CONTRIBUTE_TO_STABILITY),从而把真实的网络后端完全替换为测试队列。

2.2 需要配置拦截器等特性时:provideHttpClient() 必须在前

如果测试需要配置 HttpClient 的特性(如拦截器),则应先调用 provideHttpClient(...)调用 provideHttpClientTesting()

重要:务必把 provideHttpClient() 放在 provideHttpClientTesting() 之前,因为 provideHttpClientTesting() 会覆盖 provideHttpClient() 的部分配置;顺序颠倒可能破坏测试。

TestBed.configureTestingModule({
  providers: [provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
});

该顺序约束的本质是:provideHttpClient() 注册真正组成 HttpClientHttpBackend、拦截器链等依赖,而 provideHttpClientTesting() 需在它们之后注册才能成功替换其中的 HttpBackend(见上文 provider.ts{provide: HttpBackend, useExisting: HttpClientTestingBackend} 的覆盖逻辑)。

2.3 关于 HttpClientTestingModule

在较早的 Angular 版本中,测试通过 HttpClientTestingModule(NgModule 形式)引入上述 provider。当前仓库中该模块仍然保留,但其 JSDoc 明确标注已被弃用,并建议改为在 providers 中直接使用 provideHttpClientTesting(),见 module.ts。新代码应遵循 provide 函数风格。


三、期望与应答请求:expectOne + flush 完整流程

3.1 一个完整的用例剖析

下面是文档给出的核心示例——测试一个期望发生 GET 请求并返回模拟响应的完整用例,代码中的注释已标明每一环节的作用:

TestBed.configureTestingModule({
  providers: [ConfigService, provideHttpClientTesting()],
});

const httpTesting = TestBed.inject(HttpTestingController);

// 加载 `ConfigService` 并请求当前配置。
const service = TestBed.inject(ConfigService);
const config$ = service.getConfig<Config>();

// `firstValueFrom` 订阅 `Observable`,从而发起 HTTP 请求,
// 并创建响应结果的 `Promise`。
const configPromise = firstValueFrom(config$);

// 此时请求处于 pending 状态,可以通过 `HttpTestingController` 断言其已发出:
const req = httpTesting.expectOne('/api/config', 'Request to load the configuration');

// 如有需要,可断言请求的各种属性。
expect(req.request.method).toBe('GET');

// Flush 该请求使其完成,并把结果投递给订阅方。
req.flush(DEFAULT_CONFIG);

// 随后断言 `ConfigService` 成功收到了响应:
expect(await configPromise).toEqual(DEFAULT_CONFIG);

// 最后断言没有其他请求发出。
httpTesting.verify();

该流程严格对应文档开头所述的测试范式:expectOne 锁定预期请求 → 断言请求属性 → flush 投递响应 → 断言业务结果 → verify 收尾。

3.2 flush() 背后的行为细节

flush() 的语义远比"返回一个成功响应"更丰富。查看 TestRequest.flush 的实现可以发现:

  • 默认状态码:当 opts.status 未指定时,若 body 为 null,则返回 204 No Content;否则返回 200 OK
  • 状态码决定投递方式2xx 状态通过 observer.next(new HttpResponse(...))observer.complete() 正常完成;非 2xx 状态则会构造 HttpErrorResponse 并通过 observer.error(...) 走错误分支——这一点正是下一节"后端错误测试"的实现基础;
  • 自定义状态必须提供 statusText:当使用自定义状态码时,statusText 为必填,否则会抛出 'statusText is required when setting a custom status.'
  • 响应体按 responseType 转换:flush 的 body 会根据请求声明的 responseTypejsontextblobarraybuffer)进行类型转换(_maybeConvertBody),并允许省略 body(null)。

3.3 用结构化参数匹配请求方法

除断言 req.request.method 外,expectOne 还支持展开形式,一次性同时匹配 HTTP 方法与 URL:

const req = httpTesting.expectOne(
  {
    method: 'GET',
    url: '/api/config',
  },
  'Request to load the configuration',
);

对照 backend.ts_match 实现可看到其匹配规则:传入对象时,methodurl 均为可选字段,method 会被转成大写后与请求比较,而 URL 实际上匹配的是 request.urlWithParams(即包含查询参数的完整 URL)。这也印证了文档的提示:

注意:期望 API 匹配的是请求的完整 URL,包括所有查询参数。

3.4 expectOne 的失败语义

注意:若测试中发出了不止一个符合给定条件的请求,expectOne 会直接失败。这一行为由 HttpClientTestingBackend.expectOne 强制保证(backend.ts):命中数大于 1 时抛出 Expected one matching request for criteria "...", found N requests.;命中数为 0 时抛出 Expected one matching request for criteria "...", found none.,并在存在 open 请求时额外列出已收到的请求(形如 GET /some-url),极大方便排查。仓库中的 request_spec.ts 对上述错误消息做了逐字验证。

3.5 把 verify() 收尾移入 afterEach

"确认没有遗留请求"这一步足够通用,适合抽到 afterEach() 中,让每个用例自动检查"是否发出了多余请求":

afterEach(() => {
  // 确认所有测试都没有发出额外的 HTTP 请求。
  TestBed.inject(HttpTestingController).verify();
});

四、一次性处理多个请求:match() API

当测试需要响应重复的并发请求时,应使用 match() 而非 expectOne()match() 接受与 expectOne 相同的参数,但返回匹配请求的数组;请求一经返回即从后续匹配中移除,你需要自行负责逐个 flush 并 verify:

const allGetRequests = httpTesting.match({method: 'GET'});
for (const req of allGetRequests) {
  // 处理对每个请求的响应。
}

从实现上看(backend.ts),match() 先通过 _match 在内部"open 请求列表"中筛选,再把命中的请求逐个从该列表中剔除——这正是其与"返回后不再参与后续匹配"语义对应的机制。


五、高级匹配:谓词函数与 expectNone

5.1 使用谓词自定义匹配逻辑

所有匹配函数都接受一个谓词函数来实现自定义匹配:

// 寻找一个带有请求体的请求。
const requestsWithBody = httpTesting.expectOne((req) => req.body !== null);

谓词收到的是底层 HttpRequest,因此可以自由访问 methodurlWithParamsheadersbody 等全部属性。

5.2 expectNone:断言某类请求"不应存在"

expectNone 断言没有任何请求符合给定条件,命中即失败:

// 断言没有发出任何变更类(非 GET)请求。
httpTesting.expectNone((req) => req.method !== 'GET');

expectOne 一样,expectNone 同样支持字符串 URL、{method, url} 对象与谓词函数三种匹配形式(接口签名见 api.ts)。


六、错误处理测试:后端错误与网络错误

6.1 后端错误(服务器返回非成功状态码)

当服务器返回非成功状态码时,用带错误响应参数的 flush 来模拟后端报错:

const req = httpTesting.expectOne('/api/config');
req.flush('Failed!', {status: 500, statusText: 'Internal Server Error'});

// 断言应用成功处理了后端错误。

request.ts 可知:非 2xx 状态码下 flush 实际投递的是一个 HttpErrorResponse,其 error 字段即 flush 的 body,因此你既可以传字符串,也可以传入结构化错误对象,从而精确复现真实后端的错误体。

6.2 网络错误(以 ProgressEvent 呈现)

请求也可能因网络错误而失败,这类错误在运行时以 ProgressEvent 的形式暴露,可通过 error() 方法投递:

const req = httpTesting.expectOne('/api/config');
req.error(new ProgressEvent('network error!'));

// 断言应用成功处理了网络错误。

注意:仓库实现中 error(error: ErrorEvent, ...) 签名已被标记为弃用(HTTP 请求实际永远不会发出 ErrorEvent),应当使用 ProgressEvent 重载,见 request.tserror() 构造的 HttpErrorResponse 状态码为 0statusText 为空字符串,这与浏览器中真实的网络故障表现一致。


七、拦截器测试实战

7.1 场景:为请求附加认证令牌

应用常常需要为每个发出的请求附加由某个服务生成的认证令牌,该行为可以通过拦截器来强制实现。下面是函数式拦截器的典型写法(与文档配套的拦截器指引见 interceptors.md):

export function authInterceptor(
  request: HttpRequest<unknown>,
  next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
  const authService = inject(AuthService);

  const clonedRequest = request.clone({
    headers: request.headers.append('X-Authentication-Token', authService.getAuthToken()),
  });
  return next(clonedRequest);
}

该拦截器的 TestBed 配置应依托 withInterceptors 特性:

TestBed.configureTestingModule({
  providers: [
    AuthService,
    // 建议一次只测试一个拦截器。
    provideHttpClient(withInterceptors([authInterceptor])),
    provideHttpClientTesting(),
  ],
});

随后,HttpTestingController 可取出请求实例并检查其是否被正确修改

const service = TestBed.inject(AuthService);
const req = httpTesting.expectOne('/api/config');

expect(req.request.headers.get('X-Authentication-Token')).toEqual(service.getAuthToken());

这里能实现断言的关键在于 TestRequest 暴露了只读的 request: HttpRequest 属性(request.ts),测试方可对拦截器 clone 之后的请求头做完整校验。

7.2 类式拦截器(DI 风格)的测试配置

等价的拦截器也可用**类式(基于 DI)**实现:

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private authService = inject(AuthService);

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const clonedRequest = request.clone({
      headers: request.headers.append('X-Authentication-Token', this.authService.getAuthToken()),
    });
    return next.handle(clonedRequest);
  }
}

测试它的 TestBed 配置则相应改为:

TestBed.configureTestingModule({
  providers: [
    AuthService,
    provideHttpClient(withInterceptorsFromDi()),
    provideHttpClientTesting(),
    // 依赖 HTTP_INTERCEPTORS 令牌把 AuthInterceptor 注册为 HttpInterceptor。
    {provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true},
  ],
});

两类拦截器的差异在于注册方式:函数式拦截器使用 provideHttpClientwithInterceptors 特性直接内联;类式拦截器通过 withInterceptorsFromDi() 引入并依赖 HTTP_INTERCEPTORS 多值令牌注册。两种方式下 provideHttpClientTesting() 都必须置于其后,以保证测试后端生效。


八、将指南落到仓库:更多可参考资源

小结

本文以官方 Test requests 为骨架,深入 @angular/common/http/testing 的实现源码,完整梳理了 HTTP 测试的完整闭环:

  • 配置层provideHttpClientTesting()HttpBackend 替换为 HttpClientTestingBackend,且必须位于 provideHttpClient() 之后;
  • 期望层expectOne / expectNone / match 支持 URL、{method, url} 对象与谓词三种匹配,并针对完整 URL(含查询参数)进行比对;
  • 应答层flush() 依据状态码自动决定成功或错误投递,error() 模拟网络故障;
  • 验证层verify() 确保无意外请求,可作为 afterEach 的固定收尾;
  • 进阶场景:并发请求的批量应答与函数式/类式拦截器的请求改写验证。

掌握这套范式后,你可以让任何依赖 HttpClient 的 Service、组件与拦截器测试都在确定、快速、无网络的前提下运行,并精确复现后端成功、后端错误与网络故障三种真实运行状态。

登录后查看全文
热门项目推荐
相关项目推荐