React 组件测试中的 Test Harness 与 Provider
解释为什么单独渲染 React 组件时需要复现其 Provider、Router、QueryClient 等运行环境,以及如何用 Test Harness 保留真实内部协作并只替换外部边界。
[!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 是可隔离副作用。