← [ BACK_TO_NOTES ]

React 组件测试中的 Test Harness 与 Provider

解释为什么单独渲染 React 组件时需要复现其 Provider、Router、QueryClient 等运行环境,以及如何用 Test Harness 保留真实内部协作并只替换外部边界。

#synthesis #growing #test #react #typescript

[!info] related notes

React 组件测试中的 Test Harness 与 Provider

核心结论

React 组件在真实应用里并不是孤立运行的,它可能依赖应用顶层提供的运行环境:

App
├─ QueryClientProvider
├─ RouterProvider
├─ ThemeProvider
├─ AuthProvider
└─ Feature component

当测试直接:

render(<FeatureComponent />);

时,应用顶层这些 Provider 已经不在组件树中。

因此测试需要构造一个 Test Harness / Wrapper:只复现被测组件真正依赖的环境,而不是启动完整应用。

Provider 本质上提供什么

React Provider 通常通过 Context 向后代组件提供运行时对象或状态。

典型例子:

QueryClientProvider
→ QueryClient

RouterProvider
→ location / navigation / route params

ThemeProvider
→ theme

AuthProvider
→ current user / auth state

被测组件可能没有直接 import 这些 Context,但它调用的 Hook 会依赖它们。

所以判断“测试需不需要 Provider”不能只看 JSX props,还要看整个内部调用链。

BodySense 为什么必须有 QueryClientProvider

BodyStateWorkbench 的测试虽然直接传入:

<BodyStateWorkbench snapshot={snapshot} />

看起来数据已经全部通过 props 提供,但组件内部真实调用链是:

BodyStateWorkbench
  ↓
useBodyStateCommand()
  ↓
useMutation(...)
  ↓
useWorkspaceInvalidation()
  ↓
useQueryClient()

其中:

const queryClient = useQueryClient();

默认会从最近的 QueryClientProvider Context 取得 QueryClient。

因此:

const queryClient = new QueryClient();

render(
  <QueryClientProvider client={queryClient}>
    <BodyStateWorkbench snapshot={snapshot} />
  </QueryClientProvider>,
);

不是为了把 snapshot 放进 React Query cache,而是为了满足组件内部 Hooks 的运行时依赖。

client 到底是什么

const queryClient = new QueryClient();

QueryClient 是 TanStack Query 的核心协调对象,持有和管理:

  • QueryCache
  • MutationCache
  • 默认 query/mutation options
  • invalidate / refetch / setQueryData 等操作
  • 当前 Query/Mutation 生命周期

Provider:

<QueryClientProvider client={queryClient}>

做的事情可以简化理解为:

把这个 QueryClient 放进 React Context
                 ↓
下面所有 useQuery/useMutation/useQueryClient
都使用这个 client

为什么测试中通常每个测试创建新的 QueryClient

QueryClient 是有状态对象,会保存:

  • cache
  • query 状态
  • mutation 状态
  • error / loading 信息

如果很多测试共享同一个 QueryClient:

Test A 写入 cache
      ↓
Test B 启动时 cache 还在
      ↓
测试互相影响

因此常见做法:

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });
}

然后每个 test / render helper 创建独立实例。

这叫 test isolation。

BodySense 当前的 retry: false

测试里当前使用:

const queryClient = new QueryClient({
  defaultOptions: {
    mutations: { retry: false },
  },
});

其目的主要是让 mutation 失败时测试立即得到结果,而不是因为重试造成:

  • 测试变慢
  • API mock 被多次调用
  • error test 更难推断

需要注意:TanStack Query 当前 mutation 的 retry 默认就是 0,所以在这个具体场景里显式 retry: false 更多是测试意图和防御式配置;对于 query,客户端默认会重试,因此测试里禁用 query retry 更常见。

为什么 invalidation 也需要真实 QueryClient

BodySense:

return useMutation({
  mutationFn: executeBodyStateCommand,
  onSuccess: invalidateWorkspace,
});

而:

queryClient.invalidateQueries({ queryKey: workspaceKeys.all });

依赖真实 QueryClient。

当前 BodyStateWorkbench.test.tsx 没有真正注册 workspace query,因此 invalidate 很可能没有可刷新的 query;但 Hook 仍然需要能取得一个合法 QueryClient 才能正常执行。

这里 Test Harness 的价值是:

保留真实内部协作,即使这条测试只断言其中一部分结果。

Test Harness 不是 Mock

这两个概念要分开:

Test Harness
→ 提供真实代码运行所需环境

Mock / Fake / Stub
→ 替换某个依赖

BodySense 当前做法:

QueryClientProvider → 真实 Harness
useMutation         → 真实
useQueryClient      → 真实
workspaceApi        → spy + mockResolvedValue
Sonner toast        → module mock

这是一种很健康的组件集成测试边界。

为什么不直接 mock useBodyStateCommand

当然可以:

vi.mock("../hooks/useBodyStateCommand", ...)

这样就不需要 QueryClientProvider 了。

但代价是测试不再覆盖:

BodyStateWorkbench
→ useBodyStateCommand
→ executeBodyStateCommand
→ useMutation lifecycle
→ invalidateWorkspace

如果这些真实内部协作本来就是你希望保护的,就不应该为了少写一个 Provider 把它们全部 mock 掉。

一般原则:

优先在稳定的系统边界替换依赖,而不是在组件内部随意截断调用链。

常见 Provider Wrapper

QueryClient

function QueryWrapper({ children }: PropsWithChildren) {
  const [queryClient] = useState(() => createTestQueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Router

render(
  <MemoryRouter initialEntries={["/users/42"]}>
    <UserPage />
  </MemoryRouter>,
);

这里不是 mock useLocation(),而是提供一个测试用 Router 环境。

多 Provider 组合

function TestProviders({ children }: PropsWithChildren) {
  return (
    <QueryClientProvider client={createTestQueryClient()}>
      <MemoryRouter>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </MemoryRouter>
    </QueryClientProvider>
  );
}

真正项目中要注意 QueryClient 不应在每次 wrapper rerender 时重复创建,可通过 factory + render helper 或 useState 保持实例稳定。

renderWithProviders 模式

当多个测试反复写 Provider boilerplate 时,可以抽:

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = createTestQueryClient();

  return {
    queryClient,
    ...render(
      <QueryClientProvider client={queryClient}>
        {ui}
      </QueryClientProvider>,
    ),
  };
}

使用:

renderWithProviders(
  <BodyStateWorkbench snapshot={snapshot} />,
);

这个 helper 应该隐藏:

  • Provider boilerplate
  • 通用测试环境初始化

但不要隐藏业务上关键的输入,例如 fact kind、snapshot 状态和用户动作。

什么时候应该用真实 Provider

倾向保留真实 Provider:

  • Hook 本身是测试路径的一部分
  • Provider 初始化成本低
  • Context 行为可能影响结果
  • 你希望测试组件和真实状态层的协作

倾向 Fake/Mock:

  • 外部系统非常昂贵或不稳定
  • Provider 本身需要真实网络/浏览器能力
  • 当前测试明确只关心一个更窄的纯展示单元
  • 真实依赖会让失败难以定位

核心不是“Provider 越真实越好”,而是测试边界要与目标行为匹配。

Toast 与 Provider 为什么不是一回事

BodySense 同时出现:

<QueryClientProvider ... />

和:

vi.mock("sonner", ...)

容易让初学者以为“都是为了让测试运行”。其实不同:

QueryClientProvider

是组件真实运行所需依赖:

没有 Provider
→ useQueryClient/useMutation 无法按正常上下文工作

toast mock

是主动隔离非核心副作用:

不 mock toast
→ 组件通常仍可能执行
→ 但测试会引入无关通知系统副作用

因此:

Provider = 补齐真实运行环境
Mock toast = 移除当前测试不关心的外部副作用

Test Harness 与 AAA

Test Harness 通常属于 Arrange:

it("confirms the fact", async () => {
  // Arrange
  const user = userEvent.setup();
  renderWithProviders(<BodyStateWorkbench ... />);

  // Act
  await user.click(...);

  // Assert
  expect(...);
});

这样读测试时可以清楚知道 Provider 是场景环境,而不是被测试行为本身。

常见误区

把整个 App 当 Harness

为了测一个按钮就启动完整 App、真实 Router、全部 Query、所有全局 store,会让组件测试退化成慢而难定位的小型 E2E。

共享有状态 client

共享 QueryClient、store、fake server 时必须明确 reset;否则测试顺序可能改变结果。

为了不用 Provider 而 mock 所有 Hook

这往往降低真实覆盖面,并把测试绑定到内部实现。

Harness helper 隐藏太多业务信息

renderCase("scenario-7")

如果读测试根本看不出 scenario-7 的事实是什么,就失去了测试作为文档的价值。

最短记忆方式

Provider 是组件的运行环境,Test Harness 是测试里最小化复现这个环境的方法。

对于 BodySense 当前场景:

snapshot 是业务输入;QueryClientProvider 是 Hook 运行环境;workspaceApi 是外部边界;toast 是可隔离副作用。

创建于 2026/8/31 更新于 2026/8/31
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本