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.ts、create-base-query.ts、providers.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 是injectQuery与injectInfiniteQuery的共享基座(见 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,并通过provideTanStackQuery或provideQueryClient提供。这样能保持缓存隔离,并允许按测试修改默认选项:
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同时关闭了queries与mutations的重试;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')三段式各有其源码层面的原因:
TestBed.runInInjectionContext:injectQuery首行会执行assertInInjectionContext(injectQuery)(见 inject-query.ts),必须在注入上下文中调用;TestBed.tick():从源码结构看,create-base-query.ts中订阅 observer 的逻辑放在effect里,effect 的初次执行需要等到下一轮变更检测,tick()正是用来驱动这一轮,否则查询根本不会启动;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都能被该模式正确覆盖(分别断言success与error状态)。
四、组件测试: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(),最后断言两者均success且httpTestingController.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定义一个持有查询与变更的TestComponent,TestBed.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()三次也不产生任务泄漏,最终status为success。
这些用例与文档清单共同说明:"每次状态变更后 await 一次whenStable()" 在并发、取消、离线、销毁等边界下依然成立,可以放心作为测试的默认同步手段。
十、快速检查清单
汇总文档的 Quick checklist,并补充仓库测试实践验证过的两条:
- 每个测试使用全新的
QueryClient(测试后clear()或重建实例) - 关闭或控制重试,避免 backoff 拖成超时
- 使用 fake timers 时,
whenStable()前先拨表 + 冲微任务 - 使用
HttpClientTestingModule或你偏好的 mock 来断言网络调用,并用verify()收尾 - 每次
refetch、fetchNextPage或 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),仅供参考