首页
/ Angular HTTP 测试工具包公开 API 权威解析:HttpTestingController 与 TestRequest 实战指南

Angular HTTP 测试工具包公开 API 权威解析:HttpTestingController 与 TestRequest 实战指南

2026-09-07 11:42:53作者:余洋婵Anita

@angular/common/http/testing 是 Angular 框架内置的 HTTP 测试子包(源码位于 packages/common/http/testing,官方使用指南见 adev/src/content/guide/http/testing.md)。它基于真实的 HttpBackend 拦截机制,在测试环境中用内存队列替换真实网络,让你能"先发出请求、再断言请求、最后 flush 应答"。本文以该包公开 API 的 golden 报告 goldens/public-api/common/http/testing/index.api.md 为骨架,结合其源码实现(api.tsbackend.tsmodule.tsprovider.tsrequest.ts)展开讲解,读完你即可掌握:如何为 HttpClient 装配测试后端、如何用 HttpTestingController 做精确匹配与多请求断言、以及如何用 TestRequest.flush/error/event 完整模拟成功与失败响应。

公开 API 全景:golden 报告揭示了什么

goldens/public-api/ 目录存放的是由 API Extractor 生成、用于约束 Angular 包导出边界的"黄金文件"。该包的 API 报告(文件头注明 "Do not edit this file. It is a report generated by API Extractor")所定义的真实公共导出只有 4 个符号 + 1 个类型,全部可追溯到 packages/common/http/testing/public_api.ts 中的显式导出:

导出 类别 公开性标注
HttpClientTestingModule NgModule 类 @public @deprecated(废弃)
provideHttpClientTesting() 函数,返回 Provider[] @public
HttpTestingController 抽象类(控制器抽象) @public
RequestMatch 接口(请求匹配描述) @public
TestRequest 类(单个请求的 mock) @public

报告中还出现了来自 @angular/corerxjsi0ObserverProvider 等内部类型引用,它们只是实现细节在 API 签名上的投影,使用方无需直接关心。golden 报告本身仅是"签名清单",真正的行为语义定义在子包源码中,下文逐一展开。

入口配置:从 HttpClientTestingModule 到 provideHttpClientTesting

golden 报告将 HttpClientTestingModule 标记为 @deprecated(同时其类注释也明确写着 "Add provideHttpClientTesting() to your providers instead")。看 packages/common/http/testing/src/module.ts 的实现,这个模块的职责其实就是把函数式 provider 包进 NgModule:

@NgModule({
  imports: [HttpClientModule],
  providers: [provideHttpClientTesting()],
})
export class HttpClientTestingModule {}

其 API 报告中的 ɵmod 声明 [typeof HttpClientModule] 正是上图 imports 的编译投影。因此:

  • 在基于 NgModule 的老测试中可用 imports: [HttpClientTestingModule]
  • 在新代码(尤其搭配 provideHttpClient(...) 的 standalone 风格)中应直接使用 providers: [provideHttpClientTesting()]

函数式入口的签名是 provideHttpClientTesting(): Provider[],其真实实现见 packages/common/http/testing/src/provider.ts

export function provideHttpClientTesting(): Provider[] {
  return [
    HttpClientTestingBackend,
    {provide: HttpBackend, useExisting: HttpClientTestingBackend},
    {provide: HttpTestingController, useExisting: HttpClientTestingBackend},
    {provide: ɵREQUESTS_CONTRIBUTE_TO_STABILITY, useValue: false},
  ];
}

这里蕴含三个关键设计(均可从源码确认):

  1. HttpClientTestingBackend 是一个可注入类,同时扮演两个角色——作为 HttpBackendHttpClient 真正发请求的末端)和作为 HttpTestingController(测试断言入口)。这意味着同一个单例对象既是"网络层"又是"控制器",所以"收到的请求"才能与"被期望/被 flush 的请求"天然对应。
  2. 通过 useExisting 别名把抽象类 HttpTestingController 指向该后端,TestBed.inject(HttpTestingController) 拿到的就是它。
  3. 额外把内部 token ɵREQUESTS_CONTRIBUTE_TO_STABILITY 设为 false,使测试请求不参与应用稳定性(如 hydration/SSR 等待机制)的判定,避免测试挂起。

TestBed 中最小化装配如下(摘自 测试指南):

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

const httpTesting = TestBed.inject(HttpTestingController);

配置提醒:如果同时需要配置 HttpClient 特性(如拦截器),必须把 provideHttpClient(...) 放在 provideHttpClientTesting() 之前,因为后者会覆盖前者的部分设置;顺序颠倒可能破坏测试。

HttpTestingController:三组匹配断言 + verify

HttpTestingController 是 golden 报告的核心抽象,定义于 packages/common/http/testing/src/api.ts。其完整抽象成员如下(报告原文语义:提供"对请求的 mock 与 flush 能力"):

三态匹配:expectOne / expectNone / match

expectOneexpectNonematch 三者接受完全相同的匹配参数,均支持以下三种重载形态:

  • match: string —— 按完整 URL 匹配;
  • match: RequestMatch —— 按 {method?, url?} 对象匹配;
  • match: (req: HttpRequest<any>) => boolean —— 谓词函数,做自定义逻辑匹配。

实际匹配语义实现于 backend.ts_match()

if (typeof match === 'string') {
  return this.open.filter((testReq) => testReq.request.urlWithParams === match);
} else if (typeof match === 'function') {
  return this.open.filter((testReq) => match(testReq.request));
} else {
  return this.open.filter(
    (testReq) =>
      (!match.method || testReq.request.method === match.method.toUpperCase()) &&
      (!match.url || testReq.request.urlWithParams === match.url),
  );
}

由此可以提炼出四条硬规则:

  1. 字符串/url 字段比较的对象是 urlWithParams——即"完整 URL,含全部查询参数"。官方指南也特别提示 "The expectation APIs match against the full URL of requests, including any query parameters"。若请求带 query string,写匹配串时必须一并带上。
  2. method 匹配前会做 toUpperCase() 归一化,因此写 'get' 也能匹配到 GET
  3. RequestMatchmethodurl 均为可选(RequestMatch { method?: string; url?: string }),缺省字段视为"不限定"。
  4. 三组方法对匹配结果的处理不同,务必区分:
方法 返回值 0 个匹配 多于 1 个匹配 对队列的副作用
expectOne(match, description?) TestRequest 抛错 抛错 将命中的请求从 open 队列移除
expectNone(match, description?) void 通过 —— 命中 0 个才通过,有命中即抛错并移除
match(match) TestRequest[] 空数组 返回全部 移除所有命中(后续不再被匹配)

expectOne / expectNone 的失败信息

golden 中的 description 参数是给断言配的"人类可读说明"。从 backend.tsexpectOne 实现可以看到它如何进入错误信息:

if (matches.length > 1) {
  throw new Error(
    `Expected one matching request for criteria "${description}", found ${matches.length} requests.`,
  );
}
if (matches.length === 0) {
  let message = `Expected one matching request for criteria "${description}", found none.`;
  if (this.open.length > 0) {
    const requests = this.open.map(describeRequest).join(', ');
    message += ` Requests received are: ${requests}.`;
  }
  throw new Error(message);
}

当"一个都没找到"时,错误信息会顺带列出当前所有未决请求(格式 METHOD url),极大方便定位"我发出去的请求到底去哪了"。若未显式传 descriptiondescriptionFromMatcher() 会根据匹配器自动生成描述,例如字符串匹配生成 Match URL: ...、对象匹配生成 Match method: GET, URL: /api/config、函数匹配生成 Match by function: fnName

verify:收尾兜底

verify(opts?: {ignoreCancelled?: boolean}): void 断言"不再有任何未处理的悬空请求"。其默认行为(从实现可见):若 open 队列非空即抛错,并列出每个未处理请求的 METHOD url。唯一的选项是:

  • ignoreCancelled: true —— 忽略已被取消的请求(请求 Observable 被退订即视为取消,见下文 handle() 的 teardown 逻辑);默认不忽略,即取消请求也必须在 verify 前处理掉或显式匹配。

由于"每个测试结束后都不该有漏网请求",官方推荐把它下沉到 afterEach

afterEach(() => {
  TestBed.inject(HttpTestingController).verify();
});

请求如何进入测试队列:HttpClientTestingBackend 的运行机制

golden 报告中的 HttpTestingController 只是抽象;真正干活的 HttpClientTestingBackendbackend.ts)虽然被 provider 注册、实现了两个接口,但并未作为独立符号列入公开 API 导出——它属于"公开模块名、内部实现类"。

它的工作方式是一条极简的"挂起请求队列":

private open: TestRequest[] = [];

handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
  return new Observable((observer: Observer<any>) => {
    const testReq = new TestRequest(req, observer);
    this.open.push(testReq);
    observer.next({type: HttpEventType.Sent} as HttpEvent<any>);
    return () => {
      testReq._cancelled = true;
    };
  });
}

可以总结出以下生命周期事实:

  1. HttpClient 发起请求时最终调用 handle(),测试后端不发出任何网络包,而是把 new TestRequest(req, observer) 压入 open 队列,随后立即向订阅者推送一个 HttpEventType.Sent 事件;
  2. 请求因此一直处于"挂起(pending)"状态,直到测试用 expectOne/match 把它取出并用 flush/error/event 应答;
  3. 若请求 Observable 在应答前被退订(例如 takeUntil 或组件销毁),teardown 会把 testReq._cancelled 置为 true,于是 cancelled 变为 true
  4. match(以及基于它的 expectOne/expectNone)命中后会从 opensplice 移除,因此"同一个请求不会被断言两次"。

TestRequest:对一个请求的完整应答控制

TestRequest 是 golden 报告中最“厚”的一个类,定义于 packages/common/http/testing/src/request.ts。它的语义是:"一个已收到、等待被应答的 mock 请求"。公开成员如下。

成员与构造

  • request: HttpRequest<any> —— 原始请求对象。构造器把它作为公开只读属性暴露(golden 中的构造签名 constructor(request: HttpRequest<any>, observer: Observer<HttpEvent<any>>)),借助 req.request.methodreq.request.headersreq.request.body 等可对"应用到底发出了什么"做断言(例如拦截器测试里校验请求头)。
  • get cancelled(): boolean —— 请求发出后是否已被取消(见上文 teardown 逻辑)。
  • 构造器的 observer 参数是后端内部注入的应答通道,测试方不应直接操作。

flush:发送"最终响应"

flush 的完整签名(含 body 的全部合法类型联合)为:

flush(
  body: ArrayBuffer | Blob | boolean | string | number | Object |
        (boolean | string | number | Object | null)[] | null,
  opts?: {
    headers?: HttpHeaders | {[name: string]: string | string[]};
    status?: number;
    statusText?: string;
  },
): void

其行为细节(全部可由 request.ts 源码确认):

  • 默认状态码推导:不传 opts.status 时——若 body === null,则按 204 No ContentHttpStatusCode.NoContent)发送,默认 statusText'No Content';否则按 200 OK 发送,默认 statusText'OK'
  • 自定义状态码:一旦传了 status 就必须同时传 statusText,否则直接抛错 'statusText is required when setting a custom status.'
  • 按状态码分流2xx 区间走 observer.next(new HttpResponse({...}))complete();其余状态码(如 4xx/5xx)则走 observer.error(new HttpErrorResponse({...}))。这意味着flush('Failed!', {status: 500, statusText: 'Internal Server Error'}) 即可模拟“服务器返回错误”,而无需借助 error()——这一点官方指南在 "Backend errors" 一节有同款示例。
  • responseType 自动转换 body:会依据请求的 responseType 调用 _maybeConvertBody() 对 body 做转换(json → 校验并透传可 JSON 序列化数据、text → 序列化为字符串、arraybuffer/blob → 强制类型转换,不支持的类型抛错)。
  • 取消后的请求不可 flushcancelledtrue 时调用会抛 'Cannot flush a cancelled request.'

error:模拟"网络层错误"

// @deprecated —— Http 请求永远不会发出 ErrorEvent
error(error: ErrorEvent, opts?: TestRequestErrorOptions): void;
// 推荐形态
error(error: ProgressEvent, opts?: TestRequestErrorOptions): void;

golden 中 @deprecated 标注的仅是 ErrorEvent 重载;实现注释写明 "Http requests never emit an ErrorEvent. Please specify a ProgressEvent"。用 new ProgressEvent('网络错误') 即可让订阅方收到一个 HttpErrorResponsestatus 默认 0statusText 默认空串,可用 opts 覆盖)。官方指南在 "Network errors" 一节给出的标准写法:

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

event:推送任意中间事件

event(event: HttpEvent<any>): void 用于在响应流上投递任意 HttpEvent,典型场景是模拟 HttpDownloadProgressEvent / HttpUploadProgressEvent 等进度事件(reportProgress: true 时才会触发进度上报)。它对已取消请求同样会抛错。

完整测试流程实战

综合 golden API 与官方指南,一个标准测试单元通常遵循"应用先发请求 → 测试断言请求 → flush 应答 → 断言结果 → verify 收尾"的节奏。最典型的示例(摘自 测试指南):

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

const httpTesting = TestBed.inject(HttpTestingController);

const service = TestBed.inject(ConfigService);
const config$ = service.getConfig<Config>();
const configPromise = firstValueFrom(config$); // 订阅触发请求

const req = httpTesting.expectOne('/api/config', 'Request to load the configuration');
expect(req.request.method).toBe('GET'); // 对请求做细粒度断言

req.flush(DEFAULT_CONFIG);              // 应答,请求完成
expect(await configPromise).toEqual(DEFAULT_CONFIG);

httpTesting.verify();                   // 断言没有额外请求

若要连请求方法一起匹配,可以用对象形态:httpTesting.expectOne({method: 'GET', url: '/api/config'}, '...')

针对同型并发请求,改用 match() 批量取出并逐个应答:

const allGetRequests = httpTesting.match({method: 'GET'});
for (const req of allGetRequests) {
  // 逐个 flush/error
}

谓词匹配与"零匹配"断言常用于闸口类校验:

// 期望恰好存在一个带 body 的请求
const withBody = httpTesting.expectOne((req) => req.body !== null);
// 断言期间没有发出任何非 GET 的变更请求
httpTesting.expectNone((req) => req.method !== 'GET');

拦截器测试则体现"provide 顺序"与"读取 TestRequest.request"的配合(完整示例见 adev/src/content/guide/http/testing.md):

TestBed.configureTestingModule({
  providers: [
    AuthService,
    provideHttpClient(withInterceptors([authInterceptor])), // 先配 HttpClient
    provideHttpClientTesting(),                              // 再套测试后端
  ],
});

const req = httpTesting.expectOne('/api/config');
expect(req.request.headers.get('X-Authentication-Token')).toEqual(service.getAuthToken());

如何验证这些结论

小结

@angular/common/http/testing 的公开 API 虽然只有 5 个符号,却是一条设计精巧的闭环:provideHttpClientTesting()(替代已废弃的 HttpClientTestingModule)把 HttpClientTestingBackend 同时注入为 HttpBackendHttpTestingController;请求被压入 open 队列后,HttpTestingController.expectOne/expectNone/match 以字符串、RequestMatch 或谓词三种方式做精确断言(URL 含参数全量匹配、方法名自动大写归一);TestRequestflush/error/event 三件套覆盖了成功响应(含 2xx/非 2xx 分流与 body 类型转换)、网络错误与进度事件三类场景;最后的 verify() 保证没有请求逃逸出你的断言。这套 API 与 Angular 主流的 "provide 函数式 + standalone" 测试写法天然契合,也是编写高质量、零真实网络依赖单测的基础设施。

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