Angular HTTP 测试工具包公开 API 权威解析:HttpTestingController 与 TestRequest 实战指南
@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.ts、backend.ts、module.ts、provider.ts、request.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/core 与 rxjs 的 i0、Observer、Provider 等内部类型引用,它们只是实现细节在 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},
];
}
这里蕴含三个关键设计(均可从源码确认):
HttpClientTestingBackend是一个可注入类,同时扮演两个角色——作为HttpBackend(HttpClient真正发请求的末端)和作为HttpTestingController(测试断言入口)。这意味着同一个单例对象既是"网络层"又是"控制器",所以"收到的请求"才能与"被期望/被 flush 的请求"天然对应。- 通过
useExisting别名把抽象类HttpTestingController指向该后端,TestBed.inject(HttpTestingController)拿到的就是它。 - 额外把内部 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
expectOne、expectNone、match 三者接受完全相同的匹配参数,均支持以下三种重载形态:
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),
);
}
由此可以提炼出四条硬规则:
- 字符串/
url字段比较的对象是urlWithParams——即"完整 URL,含全部查询参数"。官方指南也特别提示 "The expectation APIs match against the full URL of requests, including any query parameters"。若请求带 query string,写匹配串时必须一并带上。 method匹配前会做toUpperCase()归一化,因此写'get'也能匹配到GET。RequestMatch中method与url均为可选(RequestMatch { method?: string; url?: string }),缺省字段视为"不限定"。- 三组方法对匹配结果的处理不同,务必区分:
| 方法 | 返回值 | 0 个匹配 | 多于 1 个匹配 | 对队列的副作用 |
|---|---|---|---|---|
expectOne(match, description?) |
TestRequest |
抛错 | 抛错 | 将命中的请求从 open 队列移除 |
expectNone(match, description?) |
void |
通过 | —— | 命中 0 个才通过,有命中即抛错并移除 |
match(match) |
TestRequest[] |
空数组 | 返回全部 | 移除所有命中(后续不再被匹配) |
expectOne / expectNone 的失败信息
golden 中的 description 参数是给断言配的"人类可读说明"。从 backend.ts 的 expectOne 实现可以看到它如何进入错误信息:
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),极大方便定位"我发出去的请求到底去哪了"。若未显式传 description,descriptionFromMatcher() 会根据匹配器自动生成描述,例如字符串匹配生成 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 只是抽象;真正干活的 HttpClientTestingBackend(backend.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;
};
});
}
可以总结出以下生命周期事实:
HttpClient发起请求时最终调用handle(),测试后端不发出任何网络包,而是把new TestRequest(req, observer)压入open队列,随后立即向订阅者推送一个HttpEventType.Sent事件;- 请求因此一直处于"挂起(pending)"状态,直到测试用
expectOne/match把它取出并用flush/error/event应答; - 若请求 Observable 在应答前被退订(例如
takeUntil或组件销毁),teardown 会把testReq._cancelled置为true,于是cancelled变为true; match(以及基于它的expectOne/expectNone)命中后会从open中splice移除,因此"同一个请求不会被断言两次"。
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.method、req.request.headers、req.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 Content(HttpStatusCode.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→ 强制类型转换,不支持的类型抛错)。 - 取消后的请求不可 flush:
cancelled为true时调用会抛'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('网络错误') 即可让订阅方收到一个 HttpErrorResponse(status 默认 0、statusText 默认空串,可用 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());
如何验证这些结论
- 公开 API 边界本身由 goldens/public-api/common/http/testing/index.api.md 固化,任何非授权的新增导出都会破坏 golden 校验;
- 子包源码集中在 packages/common/http/testing/src,其中
api.ts定义抽象、backend.ts实现队列与匹配、module.ts/provider.ts负责装配、request.ts实现应答逻辑; - 该子包自身的行为还有单元测试守护,见 packages/common/http/testing/test/request_spec.ts;
- 官方使用手法(含代码顺序、
afterEachverify、错误模拟)记录于仓库内的 HTTP 测试指南 adev/src/content/guide/http/testing.md。
小结
@angular/common/http/testing 的公开 API 虽然只有 5 个符号,却是一条设计精巧的闭环:provideHttpClientTesting()(替代已废弃的 HttpClientTestingModule)把 HttpClientTestingBackend 同时注入为 HttpBackend 与 HttpTestingController;请求被压入 open 队列后,HttpTestingController.expectOne/expectNone/match 以字符串、RequestMatch 或谓词三种方式做精确断言(URL 含参数全量匹配、方法名自动大写归一);TestRequest 的 flush/error/event 三件套覆盖了成功响应(含 2xx/非 2xx 分流与 body 类型转换)、网络错误与进度事件三类场景;最后的 verify() 保证没有请求逃逸出你的断言。这套 API 与 Angular 主流的 "provide 函数式 + standalone" 测试写法天然契合,也是编写高质量、零真实网络依赖单测的基础设施。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00