Relay 预加载查询组件测试指南:usePreloadedQuery 与 useQueryLoader 的 Mock 环境配置

原创2026-09-21 23:37:10503 阅读
文章标签:前端开发工具

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 引用。

因此,测试这类组件需要在渲染组件之前完成两步准备工作:

  1. 通过 environment.mock.queueOperationResolver 配置查询解析器,用它生成响应数据;
  2. 通过 environment.mock.queuePendingOperation 登记一次“待处理的查询调用”(query + variables)。

这两步缺一不可,它们的顺序在渲染之前即可,具体说明见后文源码分析。

症状:如何判断测试配置出了问题

如果你的测试出现以下现象,几乎可以确定是预加载查询的 Mock 配置缺失或错误:

  1. 测试的表现和预期完全不符,组件没有渲染出应有内容;
  2. 查询看起来是“阻塞”而不是“执行”——例如 Suspense 的 fallback 一直停留在 "waiting" 状态,永远不切换到 "data loaded";
  3. 在组件中于 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 → false
  • Int → 42
  • Float → 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:系统化排查清单

综合官方文档与源码实现,遇到问题按以下顺序排查:

  1. 到处 console.log! 推荐三个观测点:

    • 组件内:useQueryLoader、usePreloadedQuery、loadQuery 前后各打一条日志;
    • 测试内:在 queueOperationResolver 回调里打日志,确认它是否被调用、operation 的内容是什么;
    • 库内:在 RelayModernMockEnvironment.execute 中 const currentOperation = ... 之后打日志(对应 RelayModernMockEnvironment.js 附近),确认 currentOperation 是否为 null。
  2. 如果 loadQuery 从未被调用:检查是否漏了触发事件。取决于组件实现,触发源可能是用户操作(按钮点击、按键)、JavaScript 事件(通过事件发射机制),或 useEffect 中的"延迟执行"。useEffect 场景最容易遗漏——必须在渲染组件之后调用 act(() => jest.runAllImmediates()) 让 effect 生效。

  3. 如果 "before" 被打印而 "after" 没有:说明查询在 suspend。此时对照上文"测试用例矩阵"逐一核对,最常见的原因:

    • 使用了不同的 query —— resolver 不会被调用,currentOperation 为 null;
    • variables 不匹配 —— resolver 不会被调用,currentOperation 为 null(重点检查 variables;如有数组,确保元素顺序一致,或尽量改用 Set);
    • preloadQuery 调用顺序不对(必须先 queueOperationResolver + queuePendingOperation,再 preloadQuery)。
  4. 如果返回的数据不是预期值:很可能是 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 的查询,你都能写出稳定、可读的单元测试。

登录后查看全文
relay