使用 `@angular/common/http/testing` 测试 Angular HttpClient 请求:从模拟后端到拦截器验证的完整实践指南
导读
在 Angular 应用中,HttpClient 是与远程服务器通信的核心 API,而任何外部依赖在单元测试中都应当被模拟。本指南基于 Angular 官方文档 Test requests,系统讲解如何使用 @angular/common/http/testing 库搭建「先发起请求、后断言并 flush」的测试范式:如何通过 provideHttpClientTesting() 配置测试后端、用 HttpTestingController 期望与应答请求、处理并发请求、覆盖后端错误与网络错误场景,并验证函数式与类式两类拦截器。读完本文,你将掌握一套可直接落地的、不依赖真实网络的 HTTP 测试方案。
一、测试 HTTP 的核心思想:模拟后端而非真实网络
如同对待任何外部依赖一样,测试 HttpClient 时必须模拟 HTTP 后端,使测试能够与"远程服务器"交互。@angular/common/http/testing 提供了一整套工具,用于捕获应用发出的请求、对请求作出断言,并通过**flush(冲刷)**每个预期请求来模拟响应、复现后端行为。
该测试库遵循一种固定的工作模式,贯穿全部测试用例:
- 应用先执行代码并发起请求;
- 测试随后断言某些请求"应当已发出"或"不应发出";
- 测试针对这些请求的 URL、method、header 等属性进行校验;
- 通过
flush()为每个预期请求提供响应; - 最后调用
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),其关键动作是把 HttpBackend 与 HttpTestingController 都指向同一个 HttpClientTestingBackend 单例,并关闭"请求参与稳定性判定"标记(ɵREQUESTS_CONTRIBUTE_TO_STABILITY),从而把真实的网络后端完全替换为测试队列。
2.2 需要配置拦截器等特性时:provideHttpClient() 必须在前
如果测试需要配置 HttpClient 的特性(如拦截器),则应先调用 provideHttpClient(...),再调用 provideHttpClientTesting():
重要:务必把
provideHttpClient()放在provideHttpClientTesting()之前,因为provideHttpClientTesting()会覆盖provideHttpClient()的部分配置;顺序颠倒可能破坏测试。
TestBed.configureTestingModule({
providers: [provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
});
该顺序约束的本质是:provideHttpClient() 注册真正组成 HttpClient 的 HttpBackend、拦截器链等依赖,而 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 会根据请求声明的responseType(json、text、blob、arraybuffer)进行类型转换(_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 实现可看到其匹配规则:传入对象时,method 与 url 均为可选字段,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,因此可以自由访问 method、urlWithParams、headers、body 等全部属性。
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.ts。error() 构造的 HttpErrorResponse 状态码为 0、statusText 为空字符串,这与浏览器中真实的网络故障表现一致。
七、拦截器测试实战
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},
],
});
两类拦截器的差异在于注册方式:函数式拦截器使用 provideHttpClient 的 withInterceptors 特性直接内联;类式拦截器通过 withInterceptorsFromDi() 引入并依赖 HTTP_INTERCEPTORS 多值令牌注册。两种方式下 provideHttpClientTesting() 都必须置于其后,以保证测试后端生效。
八、将指南落到仓库:更多可参考资源
- 官方测试文档姊妹篇:HTTP 指南首页 说明了应用侧如何配置
provideHttpClient及各种特性函数; - TestRequest 行为规范测试 与 模块/测试用例目录 直接展示了官方团队对本指南中 API(
flush、expectOne、verify、error)的契约验证; - 仓库内的真实组件测试大量使用了本指南的模式,例如 update.component.spec.ts、content-loader.service.spec.ts,可作为从指南走向真实项目的参考范本。
小结
本文以官方 Test requests 为骨架,深入 @angular/common/http/testing 的实现源码,完整梳理了 HTTP 测试的完整闭环:
- 配置层:
provideHttpClientTesting()将HttpBackend替换为HttpClientTestingBackend,且必须位于provideHttpClient()之后; - 期望层:
expectOne/expectNone/match支持 URL、{method, url}对象与谓词三种匹配,并针对完整 URL(含查询参数)进行比对; - 应答层:
flush()依据状态码自动决定成功或错误投递,error()模拟网络故障; - 验证层:
verify()确保无意外请求,可作为afterEach的固定收尾; - 进阶场景:并发请求的批量应答与函数式/类式拦截器的请求改写验证。
掌握这套范式后,你可以让任何依赖 HttpClient 的 Service、组件与拦截器测试都在确定、快速、无网络的前提下运行,并精确复现后端成功、后端错误与网络故障三种真实运行状态。
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 StartedRust0627
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