Relay 预加载查询组件测试指南:usePreloadedQuery 与 useQueryLoader 的 Mock 环境配置
Relay 预加载查询组件测试指南:usePreloadedQuery 与 useQueryLoader 的 Mock 环境配置
本文是 Relay 官方测试指南的深入实践版,面向使用 useQueryLoader / usePreloadedQuery 预加载查询模式的 React 组件开发者。与普通 useLazyLoadQuery 组件不同,这类组件在测试时必须先在 Mock 环境中登记“待处理操作”和“响应生成器”,否则查询会一直挂起(suspend)导致测试失效。读完本文你将掌握 queueOperationResolver 与 queuePendingOperation 的正确用法、MockPayloadGenerator 的定制技巧,以及一套可复用的排查流程。
为什么预加载查询组件需要特殊的测试设置
使用 useQueryLoader 和 usePreloadedQuery 的组件,其数据获取流程与 useLazyLoadQuery、QueryRenderer 有本质区别:
useLazyLoadQuery在组件挂载时由 Relay 自动发起请求,测试中直接通过resolveMostRecentOperation即可应答;- 而
useQueryLoader组件的数据获取发生在渲染之外(例如按钮点击、事件回调或useEffect中调用loadQuery),usePreloadedQuery只是消费一个已经创建好的PreloadedQuery引用。
因此,测试这类组件需要在渲染组件之前完成两步准备工作:
- 通过
environment.mock.queueOperationResolver配置查询解析器,用它生成响应数据; - 通过
environment.mock.queuePendingOperation登记一次“待处理的查询调用”(query + variables)。
这两步缺一不可,它们的顺序在渲染之前即可,具体说明见后文源码分析。
症状:如何判断测试配置出了问题
如果你的测试出现以下现象,几乎可以确定是预加载查询的 Mock 配置缺失或错误:
- 测试的表现和预期完全不符,组件没有渲染出应有内容;
- 查询看起来是“阻塞”而不是“执行”——例如
Suspense的 fallback 一直停留在 "waiting" 状态,永远不切换到 "data loaded"; - 在组件中于
usePreloadedQuery前后各加一个console.log,只有 “before” 被打印,usePreloadedQuery之后的代码从未执行(说明查询抛出了 suspend)。
根本原因在于:usePreloadedQuery 消费的 PreloadedQuery 引用所对应的操作,如果 Mock 环境里找不到匹配的“待处理操作”或没有可用的响应解析器,数据永远不会到达,组件只能持续挂起。
快速上手:完整的最小可运行测试模板
下面的代码来自官方文档的 TL;DR 示例,整理为一个可直接运行的 Jest 测试骨架(官方文档中 testing-library-react 为内部封装,开源场景可用 @testing-library/react 替换,act 在开源版本中会一并导出):
const {RelayEnvironmentProvider} = require('react-relay');
const {
MockPayloadGenerator,
createMockEnvironment,
} = require('relay-test-utils');
const {render} = require('testing-library-react');
// 编写文档时 act 尚未被内部版 testing-library-react 重新导出,
// 但外部开源版本已重新导出
const {act} = require('ReactTestUtils');
test("...", () => {
// arrange(渲染之前完成)
const environment = createMockEnvironment();
environment.mock.queueOperationResolver(operation => {
return MockPayloadGenerator.generate(operation, {
CurrencyAmount() {
// 覆盖该 GraphQL 类型返回的数据
return {
formatted_amount: "1234$",
};
},
});
});
const query = YourComponentGraphQLQueryGoesHere; // 可以是同一个 query,也可以是与之 identical 的 query
const variables = {
// 本次调用的真实 variables
};
environment.mock.queuePendingOperation(YourComponentGraphQLQuery, variables);
// act(渲染并触发加载)
const {getByTestId, ...otherStuffYouMightNeed} = render(
<RelayEnvironmentProvider environment={environment}>
<YourComponent data-testid="1234" {...componentPropsIfAny} />
</RelayEnvironmentProvider>
);
// 触发加载:点击按钮、派发事件等;如果 loadQuery 写在 useEffect 中:
act(() => jest.runAllImmediates());
// assert
// 你的断言写在这里
});
注意 jest.runAllImmediates 是内部测试基础设施的用法,开源环境通常用 act(async () => ...) 或在 act 中同步推进,请按你所用的 Jest 版本适配。
测试用例矩阵:到底哪些组合能通过?
RelayMockEnvironment-test.js 中明确验证了各种调用组合的结果,这是判断“为什么我的测试挂起”的最直接依据:
| queueOperationResolver | queuePendingOperation | preloadQuery | 结果 |
|---|---|---|---|
| ✅ | ✅ | ✅(且调用顺序在两者之后) | 同步渲染数据 |
| ❌ | ✅ | ✅ | 挂起(suspend) |
| ✅ | ❌ | ✅ | 挂起(suspend) |
| ✅ | ✅ | 未调用 | 挂起(suspend) |
| preloadQuery 先调用,之后再补齐两项 | — | — | 挂起(suspend) |
测试用例 renders synchronously if queueOperationResolver, queuePendingOperation and preloadQuery have been called(RelayMockEnvironment-test.js)确认:只有当三项都满足且 preloadQuery 在 queueOperationResolver 和 queuePendingOperation 之后调用时,组件才会同步拿到数据。这一矩阵值得在排查问题时首先对照。
第一步:配置查询解析器生成响应
通过 environment.mock.queueOperationResolver(operation => ...) 完成,难点在于返回的 mock 数据必须符合特定格式——即 MockResolvers 类型。它是传给 MockPayloadGenerator.generate(operation, mockResolvers) 的第二个参数:一个以 GraphQL 类型名为 key、以返回 mock 数据函数为 value 的对象(详见 Testing Relay Components 指南 中 "Mock Payload Generator and the @relay_test_operation Directive" 一节)。
继续上面的示例:
return MockPayloadGenerator.generate(operation, {
CurrencyAmount() { // <-- GraphQL 类型名
return {
formatted_amount: "response_value", // <-- 查询中选择的 CurrencyAmount 字段
};
}
});
如何确定要 mock 的 GraphQL 类型与字段
最麻烦的一步是获取 GraphQL 类型名和它被选中的字段。官方文档提供了两种方法:
- 在 resolver 回调中打印
console.log(JSON.stringify(operation, null, 2)),在输出中寻找与你要 mock 的对象对应的concreteType,然后查看它的兄弟节点selections数组——该数组描述了从这个对象上选择的字段; - 在 GraphiQL 中查看查询对应的类型,把查询中列出的字段填进 mock 返回值。
从源码结构看,OperationDescriptor(即 operation)包含 request.node.operation.selections,而 MockPayloadGenerator 正是遍历这些 selections 逐层生成数据的(见 RelayMockPayloadGenerator.js 的 generate 与 _traverseSelections)。
Mock Resolver Context:按变量或路径返回不同数据
你完全可以为不同查询变量返回不同数据(详见 Testing Relay Components 指南 中 "Mock Resolver Context" 一节)。查询变量会出现在 context.args 中——但需要注意,只有最内层函数调用能拿到它(对文档示例中的查询而言,只有 offer_ids 可用):
CurrencyAmount(context) {
console.log(JSON.stringify(context, null, 2)); // <-- 打印 context 观察结构
return { formatted_amount: mockResponse };
}
// <-- 输出形如 { ...snip..., "name": "subtotal_price_for_offers", args: { offer_ids: [...] } }
MockResolverContext 的完整定义位于 index.d.ts:
export type MockResolverContext = Readonly<{
parentType: string | null | undefined; // 父级 GraphQL 类型
name: string | null | undefined; // 字段名
alias: string | null | undefined; // 字段别名
path: readonly string[] | null | undefined; // 选择路径
args: Readonly<Record<string, unknown>> | null | undefined; // 字段参数
}>;
实际生成时该 context 由 RelayMockPayloadGenerator.js 的 _mockScalar 构造,其中 args 来自字段的参数(_getFieldArgs),支持 Literal、Variable、ObjectValue、ListValue 四种取值形态。
默认 resolver 与 <mock-value-for-field-...> 占位符
如果你不提供某个类型的 resolver,MockPayloadGenerator 会使用内置默认值。从 RelayMockPayloadGenerator.js 可以看到默认行为:
ID→<{parentType-}mock-id-{n}>Boolean→falseInt→42Float→4.2- 其他标量 →
<mock-value-for-field-"字段名">
当返回数据看起来像 <mock-value-for-field-"formatted_amount"> 时,说明你 mock 错了 GraphQL 类型(没有为该字段所属的类型提供 resolver,回退到了占位符)。正确做法是检查 operation 输出中的 concreteType,为正确的类型编写 resolver。
Mock Resolver 的函数签名是 (context, generateId) => unknown,第二个参数 generateId 会生成递增整数序列,便于在测试中制造唯一的 ID(见 RelayMockPayloadGenerator.js 与 Testing Relay Components 指南 的 "ID Generation" 小节)。
第二步:登记待处理的查询调用
这一步相对直接:调用 environment.mock.queuePendingOperation(query, variables) 即可。两个参数各有严格要求:
query必须与组件发出的查询匹配。最简单也最抗查询变更的做法是:组件模块导出查询对象,测试中直接引用同一个query;使用一个 identical(内容相同但非同一个对象)的查询也可以。variables必须与本次测试调用实际使用的变量匹配。注意嵌套对象与数组的对比语义——源码中通过areEqual比较(RelayModernMockEnvironment.js):- 数组按值比较(不比较引用),但元素顺序必须一致;
- 嵌套对象做深比较,key 顺序无关(文档标注这一点尚未完全确认,如果你用过"深层"结构的 GraphQL 查询,欢迎反馈更新)。
源码视角:这两个队列是如何被消费的
理解底层实现有助于精准排查。从 RelayModernMockEnvironment.js 可以看到:
const queuePendingOperation = (query, variables) => {
const operationDescriptor = createOperationDescriptor(getRequest(query), variables);
pendingOperations = pendingOperations.concat([operationDescriptor]);
};
const queueOperationResolver = (resolver) => {
resolversQueue = resolversQueue.concat([resolver]);
};
当组件实际执行网络请求时,Mock 的 execute 会先在 pendingOperations 中查找匹配的操作(RelayModernMockEnvironment.js):
const currentOperation = pendingOperations.find(
op =>
op.request.node.params === request &&
areEqual(op.request.variables, variables),
);
只有同时满足“参数对象相同”和“变量相等”时,才会命中 currentOperation;随后取 resolversQueue 队首的 resolver 生成响应,并把该项从队列中移除(RelayModernMockEnvironment.js)。这也解释了为什么:
- query 不一致(换了另一个 query)→ 找不到
currentOperation,resolver 不会被调用; - variables 不匹配 → 同样找不到,resolver 不会被调用;
- resolver 队列为空 → 操作被挂起,响应永远不会到来。
execute 还会先检查 QueryResponseCache(cacheID = id ?? text),如果命中缓存会直接返回缓存 payload,这也是变量比较必须严格一致的原因之一。
Troubleshooting:系统化排查清单
综合官方文档与源码实现,遇到问题按以下顺序排查:
-
到处
console.log! 推荐三个观测点:- 组件内:
useQueryLoader、usePreloadedQuery、loadQuery前后各打一条日志; - 测试内:在
queueOperationResolver回调里打日志,确认它是否被调用、operation的内容是什么; - 库内:在
RelayModernMockEnvironment.execute中const currentOperation = ...之后打日志(对应 RelayModernMockEnvironment.js 附近),确认currentOperation是否为null。
- 组件内:
-
如果
loadQuery从未被调用:检查是否漏了触发事件。取决于组件实现,触发源可能是用户操作(按钮点击、按键)、JavaScript 事件(通过事件发射机制),或useEffect中的"延迟执行"。useEffect场景最容易遗漏——必须在渲染组件之后调用act(() => jest.runAllImmediates())让 effect 生效。 -
如果 "before" 被打印而 "after" 没有:说明查询在 suspend。此时对照上文"测试用例矩阵"逐一核对,最常见的原因:
- 使用了不同的 query —— resolver 不会被调用,
currentOperation为null; - variables 不匹配 —— resolver 不会被调用,
currentOperation为null(重点检查variables;如有数组,确保元素顺序一致,或尽量改用 Set); preloadQuery调用顺序不对(必须先queueOperationResolver+queuePendingOperation,再preloadQuery)。
- 使用了不同的 query —— resolver 不会被调用,
-
如果返回的数据不是预期值:很可能是 mock 错了 GraphQL 类型。判断依据:返回值形如
<mock-value-for-field-"formatted_amount">即说明该字段落入了默认占位符分支。
:::note
务必确认组件与测试使用同一个 environment——确保测试的 React 树中没有嵌套的 <RelayEnvironmentProvider environment={RelayFBEnvironment}> 之类覆盖了 mock 环境。
:::
进阶:Mock 环境 API 全景
Testing Relay Components 指南 中给出了 RelayMockEnvironment 的完整 API 概览,其中与预加载查询场景直接相关的是:
- 查找操作:
getAllOperations()、findOperation(findFn)、getMostRecentOperation(); - 解析/拒绝操作:
resolve、reject、nextValue、complete、resolveMostRecentOperation、rejectMostRecentOperation; - 队列机制:
queueOperationResolver(resolver)、queuePendingOperation(query, variables); - 工具方法:
isLoading、cachePayload、clearCache。
类型签名可在 index.d.ts 中查看。当测试中既有预加载查询又有 mutation/subscription 时,findOperation 与 getMostRecentOperation 能帮你精准定位到目标操作。
结语
预加载查询(useQueryLoader + usePreloadedQuery)组件在测试中之所以"特殊",本质是数据获取时机从"渲染内"移到了"渲染外"。只要在渲染之前完成 queueOperationResolver + queuePendingOperation 两项登记(并确保 preloadQuery 在其后调用),Mock 环境的 execute 就能在 pendingOperations 中命中操作并借助 resolver 立即生成响应,组件从而可以同步渲染出数据。
本文示例以 testing-library-react 为主,但同样的机制同样适用于 react-test-renderer——RelayMockEnvironment-test.js 中的测试正是用 TestRenderer.create 配合 RelayEnvironmentProvider + Suspense 完成的。掌握这套配置后,无论是按钮触发的按需加载、useEffect 中的自动预取,还是带复杂 variables 的查询,你都能写出稳定、可读的单元测试。