news 2026/9/7 5:06:29

TanStack Query for Angular 测试实战:PendingTasks 集成、TestBed 策略与源码级原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TanStack Query for Angular 测试实战:PendingTasks 集成、TestBed 策略与源码级原理

TanStack Query for Angular 测试实战:PendingTasks 集成、TestBed 策略与源码级原理

【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query

在 Angular 应用中测试基于injectQuery/injectMutation的服务或组件,核心难点是如何可靠地"等待异步数据就绪"。本文围绕 TanStack Query Angular 包的官方测试指南展开:先讲清inject*函数如何通过 Angular 19 引入的PendingTasks机制让ApplicationRef.whenStable()/fixture.whenStable()天然感知进行中的查询与变更,再给出 TestBed 配置、首个查询测试、组件测试、重试控制、HttpClientTestingModule网络桩、无限查询分页、变更与乐观更新等完整可复制的测试代码,并结合@tanstack/angular-query-experimental包的源码(pending-tasks-compat.tscreate-base-query.tsproviders.ts)与官方测试套件(pending-tasks.test.ts)说明每段模式背后的实现依据。读完本文,你可以独立搭建一套在 Zone.js 与 Zoneless 两种变更检测模式下都能稳定运行的 Angular Query 单元测试。

一、原理先行:PendingTasks 如何让 whenStable() 等待查询完成

文档开篇指出:大多数使用 TanStack Query 的 Angular 测试,都会涉及调用injectQuery/injectMutation的服务或组件。TanStack Query 的inject*函数与 Angular 的PendingTasks机制集成,确保框架"知道"有哪些查询和变更正在进行中。这意味着测试(以及 SSR)可以等待查询和变更全部落地:

  • 单元测试中可以用ApplicationRef.whenStable()fixture.whenStable()来等待查询完成;
  • 该做法在 Zone.js 和 Zoneless 两种配置下都有效;
  • 版本前提:该集成要求 Angular 19 或更高版本。更早的 Angular 版本不支持PendingTasks

源码证据:PENDING_TASKS 注入令牌与降级方案

这个集成在 Angular 包中由 pending-tasks-compat.ts 实现。它定义了一个PENDING_TASKS注入令牌,工厂函数通过Reflect.get(ng, 'PendingTasks')动态探测 Angular 是否提供了PendingTasksAPI:

export const PENDING_TASKS = new InjectionToken<PendingTasksCompat>( 'PENDING_TASKS', { factory: (): PendingTasksCompat => { // 通过 Reflect 访问,令打包器在令牌缺失时(Angular < 19)保持安静 const token = Reflect.get(ng, 'PendingTasks') as unknown as ... const svc = token ? (inject(token, { optional: true }) ...) : null // 没有 PendingTasks 时回退到一个稳定的 no-op shim return { add: svc ? () => svc.add() : () => noop } }, }, )

从源码结构看,这正是文档中"Angular 19 才支持"警告的落地方式:低版本 Angular 下不会报错,只是 PendingTasks 记录被降级为 no-op(此时whenStable()不再阻塞查询,测试需退化为手动等待);打包器也因此不会把缺失的 API 当作编译错误。

查询侧:fetching 时注册任务,idle 时释放

create-base-query.ts 是injectQueryinjectInfiniteQuery的共享基座(见 inject-query.ts 中createBaseQuery(injectQueryFn, QueryObserver)的调用)。其核心 effect 订阅 observer 后,根据fetchStatus维护一个 pending task 引用:

notifyManager.batchCalls((state) => { ngZone.run(() => { if (state.fetchStatus === 'fetching' && !pendingTaskRef) { pendingTaskRef = pendingTasks.add() // 查询开始:登记一个未完成任务 } if (state.fetchStatus === 'idle' && pendingTaskRef) { pendingTaskRef() // 查询结束:释放任务 pendingTaskRef = null } ... resultFromSubscriberSignal.set(state) }) }),

onCleanup中同样会释放pendingTaskRef并取消订阅,所以组件被销毁后不会遗留"僵尸任务"导致whenStable()永远挂起——官方测试套件中专门有用例验证了这一点(见第六节)。变更侧同理:inject-mutation.ts 中也通过injector.get(PENDING_TASKS)登记进行中的 mutation。

理解了这条链路,文档中所有"awaitwhenStable()"的写法就都有了实现依据:查询处于fetching状态期间会注册 pending task,状态回到idle时释放,whenStable()因此能精确地等到数据落地。

二、TestBed 配置:为每个 spec 准备干净的 QueryClient

文档建议:为每个 spec 创建一个全新的QueryClient,并通过provideTanStackQueryprovideQueryClient提供。这样能保持缓存隔离,并允许按测试修改默认选项:

const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, // ✅ 让失败用例更快 }, }, }) TestBed.configureTestingModule({ providers: [provideTanStackQuery(queryClient)], })

文档还特别警告:如果单元测试直接复用了应用真实的 TanStack Query 配置,务必确认withDevtools没有意外进入测试 providers——它会拖慢测试,最好让测试与生产配置分离。

从源码看这个警告是合理的:providers.ts 中provideTanStackQuery(queryClient, ...features)会把features(如withDevtools()返回的DevtoolsFeature)展开为额外的 provider 数组;而provideQueryClient内部还会在工厂中执行client.mount(),并通过DestroyRef.onDestroy调用client.unmount()保证注入器销毁时清理——所以为每个测试注入一个独立实例,生命周期管理是自动闭合的。

此外,若测试共享了辅助函数,文档要求在afterEach中调用queryClient.clear()(或重建实例),确保一个测试的数据不会渗入下一个测试。官方测试套件 pending-tasks.test.ts 正是这样做的:

beforeEach(() => { vi.useFakeTimers() queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }) TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection(), provideTanStackQuery(queryClient)], }) }) afterEach(() => { onlineManager.setOnline(true) queryClient.clear() vi.useRealTimers() })

注意两个细节:defaultOptions同时关闭了queriesmutations的重试;afterEach里除了clear()还恢复了onlineManager的在线状态并换回真实计时器——如果某个用例通过onlineManager.setOnline(false)模拟离线,不做恢复会污染后续用例。

三、第一个查询测试:runInInjectionContext + tick + whenStable

文档给出的标准模式是:查询测试通常在TestBed.runInInjectionContext中执行,然后等待稳定:

const appRef = TestBed.inject(ApplicationRef) const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ queryKey: ['greeting'], queryFn: () => 'Hello', })), ) TestBed.tick() // 触发 effect // 查询空闲时,应用即稳定 await appRef.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('Hello')

三段式各有其源码层面的原因:

  1. TestBed.runInInjectionContextinjectQuery首行会执行assertInInjectionContext(injectQuery)(见 inject-query.ts),必须在注入上下文中调用;
  2. TestBed.tick():从源码结构看,create-base-query.ts中订阅 observer 的逻辑放在effect里,effect 的初次执行需要等到下一轮变更检测,tick()正是用来驱动这一轮,否则查询根本不会启动;
  3. await appRef.whenStable():如第一节所述,fetching期间注册的 pending task 保证此 Promise 在查询落地后才 resolve。

使用 fake timers(Vitest)时的注意事项

PendingTasks 会让whenStable()在查询 settle 之后才 resolve;若使用 Vitest 的 fake timers,需要先拨表并冲掉一个微任务,再等待稳定:

await vi.advanceTimersByTimeAsync(0) await Promise.resolve() await appRef.whenStable()

官方测试套件中的完整节奏是"先取 stablePromise → 冲微任务 → 拨表 → await stablePromise":

const stablePromise = app.whenStable() // 冲掉微任务,让 TanStack Query 的调度通知得以处理 await Promise.resolve() await vi.advanceTimersByTimeAsync(10) await stablePromise

这个顺序并非随意:Promise.resolve()先让 query-core 里经notifyManager排程的回调有机会执行(状态翻转为idle并释放 pending task),再拨表让定时逻辑推进,最后stablePromise自然 resolve。同步 resolve 的queryFn与同步抛错的queryFn都能被该模式正确覆盖(分别断言successerror状态)。

四、组件测试:TestBed.createComponent + fixture.whenStable

对于组件,文档建议用TestBed.createComponent引导,然后等待fixture.whenStable()

const fixture = TestBed.createComponent(ExampleComponent) await fixture.whenStable() expect(fixture.componentInstance.query.data()).toEqual({ value: 42 })

组件场景下 fixture 同时承担了 effect 触发与稳定性等待两件事,因此比服务测试更简洁。若组件带有 signal 输入,仓库测试工具 test-utils.ts 提供了setFixtureSignalInputs辅助函数:它借助@angular/core/primitives/signals中的signalSetFn直接写入@input()signal 的值(绕过@angular/angular#54013的限制),并按需调用detectChanges(),可作为自定义组件测试工具的实现参考。

五、重试控制:别让 backoff 拖垮测试

文档指出:重试会拖慢失败用例,因为默认 backoff 会重试三次。应通过defaultOptions或单个 query 设置retry: false(或指定次数)保持测试快速;若某个查询确实要验证重试逻辑,应断言最终状态而非中间次数。

官方测试套件提供了"确实要验证重试"时的写法:显式给小retryDelay,用 fake timers 拨过全部重试窗口,最后断言尝试次数与终态:

const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ queryKey: key, retry: 2, retryDelay: 10, queryFn: async () => { attemptCount++ if (attemptCount <= 2) throw new Error(`Attempt ${attemptCount} failed`) return 'success-data' }, })), ) const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(50) await stablePromise expect(query.status()).toBe('success') expect(attemptCount).toBe(3) // 首次 + 2 次重试

六、HttpClient 与网络桩:HttpTestingController 与 PendingTasks 配合

文档说明:Angular 的HttpClientTestingModule与 PendingTasks 配合良好。将其与 Query provider 一起注册,再通过HttpTestingController冲刷响应即可:

TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [provideTanStackQuery(queryClient)], }) const httpCtrl = TestBed.inject(HttpTestingController) const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ queryKey: ['todos'], queryFn: () => lastValueFrom(TestBed.inject(HttpClient).get('/api/todos')), })), ) const fixturePromise = TestBed.inject(ApplicationRef).whenStable() httpCtrl.expectOne('/api/todos').flush([{ id: 1 }]) await fixturePromise expect(query.data()).toEqual([{ id: 1 }]) httpCtrl.verify()

注意这里的时序技巧:先取whenStable()Promise,再flush响应——若先 flush 再取,查询可能已经完成,whenStable()会立即 resolve 而错过"等待数据"的语义。httpCtrl.verify()收尾则保证没有未处理的挂起请求。

仓库测试套件 pending-tasks.test.ts 的 "HttpClient Integration" 分组还覆盖了两类更复杂的场景:

  • 并发请求:两个injectQuery分别请求/api/1/api/2,用setTimeout+ fake timers 在 10ms 后依次expectOne().flush(),最后断言两者均successhttpTestingController.verify()通过;
  • 请求失败/取消:对/api/cancel调用req.error(new ProgressEvent('error'), { status: 0, statusText: 'Unknown Error' })模拟网络错误,断言查询进入error状态。

此外,"Edge Cases" 分组验证了queryClient.cancelQueries({ queryKey })在飞行中取消后,查询会恢复到pending/idle预取状态,且whenStable()依然正常 resolve——测试中取消飞行中的查询不会破坏 PendingTasks 的记账。

七、无限查询与分页

文档说明:无限查询使用同一套模式——调用fetchNextPage(),若在使用 fake 时间则拨表,然后等待稳定并断言data().pages

const infinite = TestBed.runInInjectionContext(() => injectInfiniteQuery(() => ({ queryKey: ['pages'], queryFn: ({ pageParam = 1 }) => fetchPage(pageParam), getNextPageParam: (last, all) => all.length + 1, })), ) await appRef.whenStable() expect(infinite.data().pages).toHaveLength(1) await infinite.fetchNextPage() await vi.advanceTimersByTimeAsync(0) await appRef.whenStable() expect(infinite.data().pages).toHaveLength(2)

关键点:fetchNextPage()每次都会把 fetchStatus 重新带入fetching,因此每一页加载后都需要重新等待一次whenStable();若queryFn是异步的,还要先用vi.advanceTimersByTimeAsync(0)冲掉定时器,再等稳定。

八、变更(Mutations)与乐观更新

文档给出的变更测试模式:

const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ mutationFn: async (input: string) => input.toUpperCase(), })), ) mutation.mutate('test') // 触发 effect TestBed.tick() await appRef.whenStable() expect(mutation.isSuccess()).toBe(true) expect(mutation.data()).toBe('TEST')

mutate之后同样需要TestBed.tick()触发 effect 使订阅建立,再等待稳定。

若还要验证乐观更新与回滚,仓库测试套件提供了一个完整可参考的用例结构:onMutate中先保存旧值并写入新值,onError中回滚;断言分三层——mutate后立刻验证数据已被乐观改写(此时尚未稳定)、whenStable()后验证isSuccess()与最终缓存值:

queryClient.setQueryData(testQueryKey, 'initial-data') const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ mutationFn: (newData: string) => sleep(50).then(() => newData), onMutate: async (newData) => { const previousData = queryClient.getQueryData(testQueryKey) queryClient.setQueryData(testQueryKey, newData) return { previousData } }, onError: (_err, _newData, context) => { if (context?.previousData) { queryClient.setQueryData(testQueryKey, context.previousData) } }, })), ) mutation.mutate('optimistic-data') await Promise.resolve() expect(queryClient.getQueryData(testQueryKey)).toBe('optimistic-data') // 乐观值即时可见 // ...await appRef.whenStable() 后断言 isSuccess() 与终态

九、从官方测试套件提取的进阶模式

除文档列出的场景外,pending-tasks.test.ts 还验证了若干生产测试中容易踩坑的行为,值得作为补充:

组件销毁不会卡死 whenStable

@Component定义一个持有查询与变更的TestComponentTestBed.createComponent后立即fixture.destroy(),随后断言app.whenStable()在拨表后 resolve。这印证了 create-base-query.ts 中onCleanup释放 pending task、inject-mutation.ts的清理逻辑:组件消失后,它未完成的异步操作不会继续阻塞框架稳定性。

离线暂停期间 pending task 持续阻塞

当查询重试进入延迟期时调用onlineManager.setOnline(false),fetch 会被暂停(fetchStatus === 'paused')。测试断言此时whenStable()不会resolve;恢复在线后重试继续并最终成功,stablePromise才 resolve。即:暂停中的重试同样被视为"未完成的框架工作",与查询执行中的行为一致。

并发场景

套件中的 "Concurrent Operations" 分组验证了 3 个不同 key 的查询并发(其中一个是同步queryFn)、3 个变更并发、以及查询与变更混合并发,全部通过一次advanceTimersByTimeAsync+stablePromise完成断言。快速连续refetch()三次也不产生任务泄漏,最终statussuccess

这些用例与文档清单共同说明:"每次状态变更后 await 一次whenStable()" 在并发、取消、离线、销毁等边界下依然成立,可以放心作为测试的默认同步手段。

十、快速检查清单

汇总文档的 Quick checklist,并补充仓库测试实践验证过的两条:

  • 每个测试使用全新的QueryClient(测试后clear()或重建实例)
  • 关闭或控制重试,避免 backoff 拖成超时
  • 使用 fake timers 时,whenStable()前先拨表 + 冲微任务
  • 使用HttpClientTestingModule或你偏好的 mock 来断言网络调用,并用verify()收尾
  • 每次refetchfetchNextPage或 mutation 之后都要await whenStable()
  • 服务测试优先TestBed.runInInjectionContext,组件测试优先fixture.whenStable()
  • (补充)mutate/injectQuery建立订阅依赖 effect 执行,必要时先TestBed.tick()再等待稳定
  • (补充)模拟离线或全局状态的用例,在afterEach中恢复(如onlineManager.setOnline(true)

进一步深入

  • 原理与降级实现:pending-tasks-compat.ts、create-base-query.ts
  • provider 生命周期(mount/unmount):providers.ts
  • 完整的 PendingTasks 集成测试(本文多数进阶用例的出处):pending-tasks.test.ts
  • 组件测试工具函数(signal 输入写入):test-utils.ts
  • 应用端配置参考:installation.md

【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 5:04:44

D3 v7 快速上手:CDN、npm 与 React/Svelte 集成的完整接入指南

D3 v7 快速上手&#xff1a;CDN、npm 与 React/Svelte 集成的完整接入指南 【免费下载链接】d3 Bring data to life with SVG, Canvas and HTML. :bar_chart::chart_with_upwards_trend::tada: 项目地址: https://gitcode.com/GitHub_Trending/d3/d3 D3&#xff08;Data…

作者头像 李华
网站建设 2026/9/7 5:04:26

作业指导书管理系统提升效率,如何选择最合适的方案?

1. 引言作业指导书&#xff08;SOP、WI、操作规范&#xff09;是生产制造、设备运维、质量管理等场景中连接标准与执行的关键载体。很多企业早期用 Word、Excel 或共享盘管理作业指导书&#xff0c;文件散落在不同电脑和网盘里&#xff0c;版本混乱、审批依赖邮件、现场员工难以…

作者头像 李华
网站建设 2026/9/7 4:59:02

基于SpringBoot+vue的Web的影视资源管理系统(毕业设计项目源码+文档)

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/9/7 4:58:47

基于Handsontable构建Excel风格在线编辑表格的实践与优化

简介&#xff1a;Handsontable是一套基于JavaScript的Excel风格表格交互库&#xff0c;专为HTML前端页面提供类似Excel的数据网格编辑能力&#xff0c;面向需要在网页中实现复杂表格录入、编辑与交互的前端开发者或数据后台开发人员。它兼容IE10、Firefox、Chrome、Safari和Ope…

作者头像 李华
网站建设 2026/9/7 4:58:26

FPGA软核实战:DE2板上运行OC8051并点亮LED

简介&#xff1a;面向FPGA与嵌入式学习者&#xff0c;这套基于Altera DE2开发板的OC8051点灯实验资源&#xff0c;完整演示了如何在DE2硬件平台上集成可综合的8051软核处理器&#xff0c;并通过自带LED测试程序验证运行效果。整个压缩包共1042个文件、约19.07MB&#xff0c;以完…

作者头像 李华
网站建设 2026/9/7 4:57:27

构建Lojban工具链:livla管道式架构与文本解析实践

简介&#xff1a;这套面向Lojban语言学习者和开发者的多功能工具组合&#xff0c;整合了解析器、搜索界面、词典软件与IRC机器人等组件&#xff0c;可通过Docker或podman快速部署到本地环境。压缩包内共2000个文件、约44.38MB&#xff0c;其中1613个mp3音频构成丰富的发音素材库…

作者头像 李华