@react-three/test-renderer 测试渲染器 API 全解析:为 react-three-fiber 场景编写可断言的单元测试
【免费下载链接】react-three-fiber🇨🇭 A React renderer for Three.js项目地址: https://gitcode.com/GitHub_Trending/re/react-three-fiber
@react-three/test-renderer是 react-three-fiber 生态中专用于 Node 环境的实验性 React 测试渲染器,它基于@react-three/fiber自身的 reconciler 将 THREE.js 场景图包装成可遍历、可查询、可触发事件的测试实例,让你无需 WebGL 与浏览器即可对<mesh />、useFrame等 3D 场景逻辑做断言。读完本文,你将掌握create()/act()/advanceFrames()/fireEvent()等核心 API 的完整用法,以及ReactThreeTestInstance的属性与方法,能够像测试普通 React 组件一样为 3D 场景编写专业、稳定的单元测试。
一、为什么需要专门的 3D 测试渲染器
在 react-three-fiber 中编写了复杂的 WebGL 场景后,你自然会想对它做自动化测试。但常规思路会遇到两个障碍:
- THREE 元素不在 DOM 中:
react-dom无法渲染<mesh />这类元素,因为@react-three/fiber拥有自己独立的 reconciler,会把元素挂载到独立的 React root 上,react-dom看不到场景内部的树结构; - WebGL 环境依赖:真实的
THREE.WebGLRenderer需要浏览器与 GPU 上下文,在 CI 或纯 Node 环境下难以运行。
@react-three/test-renderer(下文简称 RTTR)正是为解决这一问题而生:它在内部复用了@react-three/fiber的 reconciler,将完整的 scene graph 暴露出来,并包装成带断言工具集的测试实例。本质上,它可以让你在不创建 WebGL 渲染器、不启动渲染循环的情况下拿到场景图快照,见 README。
该包测试框架无关,可与 jest、jasmine 等主流断言框架搭配使用(仓库自带的测试即基于 jest,见 jest.config.js 与 RTTR.core.test.tsx)。
二、安装与版本要求
根据 README 与 package.json,推荐安装方式如下:
yarn add @react-three/fiber three yarn add -D @react-three/test-renderer版本要求(来自 package.json 的peerDependencies):
| 依赖 | 版本要求 |
|---|---|
react | ^19.0.0 |
@react-three/fiber | >=9.0.0 |
three | >=0.156 |
仓库当前版本为9.1.0,通过preconstruct构建为 CJS / ESM 双格式(main指向dist/react-three-test-renderer.cjs.js,module指向dist/react-three-test-renderer.esm.js)。
三、create():创建测试渲染器
3.1 基本签名
const renderer = await ReactThreeTestRenderer.create(element, options)create接收一个 THREE 元素(例如<mesh />),返回一个 Promise,需要await获取渲染器实例。默认情况下它不会创建真正的THREE.WebGLRenderer,也没有渲染循环,但会渲染出完整的场景图。返回值包含scene、getInstance、toTree、toGraph、fireEvent、advanceFrames、update、unmount等成员,下面逐一展开。
从源码看(src/index.tsx),create内部会先通过createCanvas(options)生成一个 mock canvas,再调用 fiber 的createRoot(canvas)并配置:
await _root.configure({ frameloop: 'never', // 关键:关闭自动渲染循环 size: { width: options?.width ?? 1280, height: options?.height ?? 800, top: 0, left: 0, }, ...options, events: undefined, // 关闭真实事件系统,由 fireEvent 接管 })这里有两个值得注意的默认值:画布尺寸默认1280 x 800(对应useThree中size.width/size.height),且frameloop被固定为'never',保证测试环境完全受控、不产生非确定性的帧循环。
3.2 CreateOptions
// RenderProps 来自 @react-three/fiber interface CreateOptions extends RenderProps<HTMLCanvasElement> { width?: number // 画布宽度,默认 1280 height?: number // 画布高度,默认 800 }CreateOptions继承自 fiber 的RenderProps<HTMLCanvasElement>,因此@react-three/fiber的<Canvas />相关配置项(如camera、gl、shadows、dpr等)原则上也适用;width/height用于控制 mock canvas 的尺寸。在 RTTR.hooks.test.tsx 中可以看到实际用法:
await ReactThreeTestRenderer.create(<Component />, { width: 1280, height: 800 })测试随后断言useThree返回的size为{ height: 800, width: 1280, top: 0, left: 0 },验证了尺寸配置确实传递到了 store。
3.3scene
renderer.scene返回根级 “react three test instance” 对象(即ReactThreeTestInstance,类型为Scene),是后续一切断言的入口。你可以通过它向下查找更深的测试实例。例如:
const { scene } = await ReactThreeTestRenderer.create( <mesh> <boxGeometry args={[2, 2]} /> <meshBasicMaterial /> </mesh>, ) expect(scene.type).toEqual('Scene') // 根实例类型是 Scene expect(scene.children[0].type).toEqual('Mesh') // 第一个子节点是 Mesh源码中scene由wrapFiber(_scene)包装生成(src/index.tsx),其中_scene取自 fiber store 的state.scene上挂载的__r3f实例。
3.4getInstance()
renderer.getInstance()返回根 THREE 元素对应的实例(即真实的THREE.Object3D等对象),若不可用则返回null。注意:如果根元素是函数组件,则无法工作——函数组件没有自己的实例(它们只是渲染逻辑的容器)。源码会先判断 canvas 是否已卸载(mockRoots.has(canvas)),随后沿 fiber 节点向下遍历直到找到带有stateNode的节点,再通过reconciler.getPublicRootInstance(root)取出公共根实例,见 src/index.tsx。
3.5toTree()
renderer.toTree()返回一个代表渲染树的对象,与react-test-renderer的toTree()行为类似,会包含所有以 React 组件形式编写的元素。每个节点形如{ type, props, children }(见 src/types/public.ts 中的TreeNode),type 采用首字母小写的约定(如'mesh'),转换逻辑见 src/helpers/tree.ts。
3.6toGraph()
renderer.toGraph()返回一个代表THREE.js 场景图(scene graph)的对象,每个节点为{ type, name, children }(见 src/types/public.ts 中的SceneGraphItem)。与toTree()不同,它不会包含所有元素,例如使用attach挂载的 geometry、material、color 等不会出现在图中。转换逻辑见 src/helpers/graph.ts:它会递归 fiber 子节点,并对primitive类型的节点额外并入真实 THREE.js 对象的子节点。
3.7fireEvent()
renderer.fireEvent(testInstance, eventName, mockEventData)原生方法,用于向渲染树中特定部分触发事件:传入树中的某个元素与事件名,第三个参数mockEventData会被合并进传给事件处理器的MockSyntheticEvent。
事件命名遵循 camelCase 约定(如pointerUp),也可以直接传事件处理器名(如onPointerUp)。源码实现(src/fireEvent.ts)中toEventHandlerName('pointerUp')会转换成'onPointerUp',再依次尝试props['onPointerUp']与props['pointerUp'];若都找不到处理器,会console.warn提示但不会抛出异常。
const handlePointerDown = jest.fn() const { scene, fireEvent } = await ReactThreeTestRenderer.create( <mesh onPointerDown={handlePointerDown}> <boxGeometry args={[2, 2]} /> <meshBasicMaterial /> </mesh>, ) await fireEvent(scene.children[0], 'onPointerDown', { offsetX: 640, offsetY: 400 }) await fireEvent(scene.children[0], 'pointerDown') // 等价写法 expect(handlePointerDown).toHaveBeenCalledTimes(2)上面的例子直接取自 RTTR.events.test.tsx,可以看到两种命名方式都有效,且自定义的offsetX/offsetY数据会出现在事件对象中。
MockSyntheticEvent
type MockSyntheticEvent = { camera: Camera // 渲染场景的默认相机 stopPropagation: () => void target: ReactThreeTestInstance currentTarget: ReactThreeTestInstance sourceEvent: MockEventData ...mockEventData // 你传入的自定义数据会被展开合并进来 }从 src/fireEvent.ts 的实现可以看出,camera直接取自 fiber store 的state.camera,stopPropagation是一个空操作占位(保证调用不抛错),而...data展开保证了自定义字段(如offsetX)可被事件处理器读取。
3.8advanceFrames()
renderer.advanceFrames(frames, delta)原生方法,用于推进帧,从而执行订阅了 GL 渲染循环的回调(例如useFrame)。它需要两个参数:推进的帧数,以及传给订阅者的delta值,从而营造一个更可控的测试环境。delta可以是单个数值,也可以是数值数组(数组模式下逐帧取对应增量,见 src/index.tsx 中advanceFrames的实现)。
经典用例来自 RTTR.hooks.test.tsx:
const Component = () => { const meshRef = React.useRef<THREE.Mesh>(null!) useFrame((_, delta) => { meshRef.current.rotation.x += delta }) return ( <mesh ref={meshRef}> <boxGeometry args={[2, 2]} /> <meshBasicMaterial /> </mesh> ) } const renderer = await ReactThreeTestRenderer.create(<Component />) expect(renderer.scene.children[0].instance.rotation.x).toEqual(0) await ReactThreeTestRenderer.act(async () => { await renderer.advanceFrames(2, 1) // 推进 2 帧,每帧 delta = 1 }) expect(renderer.scene.children[0].instance.rotation.x).toEqual(2)advanceFrames(2, 1)精确地执行了 2 次useFrame回调,每次 delta 为 1,使旋转量从 0 变为 2——这正是“受控帧循环”的价值:不依赖真实时钟,测试结果完全可复现。
3.9update()
renderer.update(element)使用新的根元素重新渲染整棵树,模拟一次 React 更新并随之更新子树。如果新元素与旧元素类型和 key 相同,则树会被更新(复用)而非重建;若已卸载的根被再次 update,会输出警告'RTTR: attempted to update an unmounted root!'。内部通过_root.render(newElement)并在act中执行(见 src/index.tsx)。
在 RTTR.core.test.tsx 中可见状态更新后直接断言renderer.scene.children[0].instance.position.x的场景:
const renderer = await ReactThreeTestRenderer.create(<Component />) expect(renderer.scene.children[0].instance.position.x).toEqual(7) // 组件挂载后 setState 生效3.10unmount()
renderer.unmount()卸载整棵树,并触发相应的生命周期事件(如componentWillUnmount)。从源码看,卸载后getInstance()会因 canvas 不再存在于mockRoots而返回null。
四、act():为断言做准备
ReactThreeTestRenderer.act(callback)与react-test-renderer的act()类似,用于在断言前“安定”组件状态。不同之处在于:使用 RTTR 时,你不需要手动把ReactThreeTestRenderer.create和renderer.update包进act(它们内部已经处理),只需要在需要推动异步更新(如advanceFrames、fireEvent、异步 setState)时使用。
act直接来自 React 自身的导出(import { act } from 'react',见 src/index.tsx),因此它遵循 React 的 act 语义。
Act 示例(基于 jest)
import ReactThreeTestRenderer from '@react-three/test-renderer' const Mesh = () => { const meshRef = React.useRef() useFrame((_, delta) => { meshRef.current.rotation.x += delta }) return ( <mesh ref={meshRef}> <boxGeometry args={[2, 2]} /> <meshBasicMaterial /> </mesh> ) } const renderer = await ReactThreeTestRenderer.create(<Mesh />) expect(renderer.scene.children[0].instance.rotation.x).toEqual(0) await ReactThreeTestRenderer.act(async () => { await renderer.advanceFrames(2, 1) }) expect(renderer.scene.children[0].instance.rotation.x).toEqual(2)五、ReactThreeTestInstance:测试实例 API
ReactThreeTestInstance是包装create()返回元素的内部类,提供一系列属性与方法增强测试体验,整体与react-test-renderer的 API 高度镜像。完整说明见 rttr-instance.md。
5.1 属性
| 属性 | 类型 | 说明 |
|---|---|---|
instance | TObject | 该测试实例对应的实例对象,即 THREE 初始化的类实例(THREE.Mesh、THREE.Scene等) |
type | string | 测试实例的 THREE 类型,如'Scene'、'Mesh' |
props | object | 当前传给元素的 props,包含隐藏的如attach="geometry"这类在 reconciler 中自动应用的 props |
parent | ReactThreeTestInstance \| null | 父测试实例,无父时返回null |
children | ReactThreeTestInstance[] | 按children属性返回子测试实例,不包含Geometry、Material 等 attach 挂载项 |
allChildren | ReactThreeTestInstance[] | 返回全部子测试实例,粒度与toTree()一致,捕获树中所有 React 组件 |
源码实现(src/createTestInstance.ts)中,children与allChildren的关键差异在于getChildren的exhaustive选项:默认过滤掉带props.attach的子节点,而allChildren则全部保留;此外对于primitive类型节点,还会为其 THREE.js 对象子节点创建“虚拟实例”一并并入子列表。
5.2 查询方法
testInstance.find(test) // 找到唯一满足 test(testInstance) 的实例,不是恰好一个则抛错 testInstance.findAll(test) // 找到所有满足条件的实例,一个都没有时返回空数组 [] testInstance.findByType(type) // 按类型找唯一实例,不是恰好一个则抛错 testInstance.findAllByType(type) // 按类型找全部实例,无匹配返回 [] testInstance.findByProps(props) // 按 props 找唯一实例,不是恰好一个则抛错 testInstance.findAllByProps(props) // 按 props 找全部实例,无匹配返回 []findByProps/findAllByProps还支持 RegExp 匹配器:
testInstance.findByProps({ name: /^mesh_01$/ }) testInstance.findAllByProps({ name: /^mesh_\d+$/ })RegExp 匹配逻辑在 src/helpers/testInstance.ts 的matchProps中实现:当过滤值instanceof RegExp且目标 props 值为字符串时,会执行filter.test(value);否则走严格相等比较。这意味着你既能做精确匹配,也能做模式匹配。
关于“恰好一个”的抛错语义:expectOne(src/helpers/testInstance.ts)在 0 个匹配时抛出RTTR: No instances found ...,多于 1 个时抛出RTTR: Expected 1 but found N instances ...。这些行为在 RTTR.methods.test.tsx 中均有覆盖:
expect(() => scene.find((node) => node.props.color === 0x0000ff)).toThrow() // 有 2 个匹配,抛错 expect(() => scene.findByType('BufferGeometry')).toThrow() // 0 个匹配,抛错 expect(scene.findAllByType('Mesh')).toHaveLength(2) // 精确批量查找 expect(scene.findAllByProps({ color: 0x0000ff })).toHaveLength(2) // 批量 props 查找5.3findAll的遍历规则
findAll的底层实现在 src/helpers/testInstance.ts:默认从根节点开始(includeRoot为true),递归遍历allChildren并始终包含子树的根。而实例方法findAll调用时显式传入了{ includeRoot: false }(见 src/createTestInstance.ts),即实例上的查询不会把自身算作候选,只会搜索其子树——这也解释了为什么scene.find((node) => node.instance.name === 'mesh_01')可以命中场景下的子节点。
六、底层原理:RTTR 是如何“假装”渲染的
RTTR 能在纯 Node 环境渲染 THREE 场景,核心在于三块 mock 机制:
Mock Canvas(src/createTestCanvas.ts):优先使用
document.createElement('canvas')(jsdom 环境),否则构造一个带getContext的假 canvas 对象,其getContext返回WebGL2RenderingContextmock(实现见 src/WebGL2RenderingContext.ts),并在globalThis上补充WebGLRenderingContext/WebGL2RenderingContext全局类,保证 three.js 拿到上下文后不会真的调用 GPU。复用 fiber 的 reconciler(src/index.tsx):通过
import { createRoot, reconciler, _roots as mockRoots } from '@react-three/fiber'创建独立 root,并把 store 从mockRoots.get(canvas)中取出用于后续的fireEvent与advanceFrames。RTTR 在入口处执行了extend(THREE as any),将全部 THREE 类注册进 fiber 的元素目录,使<mesh />等 JSX 标签可被解析。实例包装的 WeakMap 缓存(src/createTestInstance.ts):
wrapFiber使用WeakMap<Instance, ReactThreeTestInstance>保证同一个 fiber 始终返回同一个测试实例包装对象,避免每次访问parent/children时产生身份不一致的实例。
七、waitFor:处理异步条件的辅助函数
除了create与act,RTTR 还导出了一个名为waitFor的辅助函数(见 src/helpers/waitFor.ts):
ReactThreeTestRenderer.waitFor(callback, options?) // options: { interval?: number; timeout?: number } 默认 interval=50ms, timeout=5000ms它会反复执行callback,直到返回真值(或返回null/undefined)为止,超时则抛出Timed out after ${timeout}ms.错误。整个轮询包裹在act中,适合等待异步加载或异步状态更新完成的场景。
八、综合实战:一个完整的测试用例
结合前面所有 API,编写一个覆盖“渲染 → 查询 → 触发事件 → 推进帧 → 更新 → 卸载”全流程的 jest 测试:
import React from 'react' import * as THREE from 'three' import { useFrame } from '@react-three/fiber' import ReactThreeTestRenderer from '@react-three/test-renderer' const RotatingMesh = () => { const meshRef = React.useRef<THREE.Mesh>(null!) useFrame((_, delta) => { meshRef.current.rotation.x += delta }) return ( <mesh name="rotor" onPointerDown={(e) => console.log(e.offsetX, e.offsetY)}> <boxGeometry args={[2, 2]} /> <meshBasicMaterial color={0x0000ff} /> </mesh> ) } describe('RotatingMesh', () => { it('renders, rotates and fires events', async () => { const renderer = await ReactThreeTestRenderer.create(<RotatingMesh />) // 1. 场景结构断言 expect(renderer.scene.type).toEqual('Scene') expect(renderer.scene.findAllByType('Mesh')).toHaveLength(1) // 2. 实例查询:按 props(含 RegExp) const mesh = renderer.scene.findByProps({ name: /^rotor$/ }) expect(mesh.instance).toBeInstanceOf(THREE.Mesh) // 3. 场景图快照 expect(renderer.toGraph()[0].type).toEqual('Mesh') // 4. 受控推进帧,验证 useFrame await ReactThreeTestRenderer.act(async () => { await renderer.advanceFrames(3, 0.5) }) expect(mesh.instance.rotation.x).toBeCloseTo(1.5) // 5. 触发事件 const spy = jest.spyOn(console, 'log') await renderer.fireEvent(mesh, 'pointerDown', { offsetX: 640, offsetY: 400 }) expect(spy).toHaveBeenCalledWith(640, 400) // 6. 更新与卸载 await renderer.update(<RotatingMesh />) expect(renderer.scene.findAllByType('Mesh')).toHaveLength(1) await renderer.unmount() expect(renderer.getInstance()).toBeNull() }) })提示:若希望观察
update时生命周期(如componentDidMount/ setState)带来的变化,可参考 RTTR.core.test.tsx 中Component挂载后把pos从 3 更新为 7 的断言写法。
九、测试覆盖地图:从仓库测试学习最佳实践
仓库自带的测试是学习 RTTR 用法的第一手资料,位于 packages/test-renderer/src/tests:
| 测试文件 | 覆盖主题 |
|---|---|
| RTTR.core.test.tsx | JSX 渲染、hooks、useTransition、空场景、复合组件、toGraph/toTree、Fragments、primitive与子树、快照断言 |
| RTTR.events.test.tsx | fireEvent的两种命名方式、mockEventData 传递、未找到处理器时的降级行为 |
| RTTR.hooks.test.tsx | useThree的 store 状态、useLoader加载模型、advanceFrames驱动useFrame |
| RTTR.methods.test.tsx | parent/children、find/findAll、findByType/findAllByType、findByProps/findAllByProps(含 RegExp) |
快照文件见 RTTR.core.test.tsx.snap,可直观看到toTree()/toGraph()的 JSON 输出形态。
十、实用要点速查
create/update/unmount内部已处理act,只需对advanceFrames、fireEvent、异步回调包一层act;toTree()反映React 组件树(含函数组件与 attach 项),toGraph()反映THREE 场景图(不含 attach 项);两者都是快照测试的好帮手;children与allChildren的区别在于是否包含 attach 挂载项(Geometry / Material 等);find/findByType/findByProps要求恰好一个匹配,否则抛错——这能及早暴露“查询条件过于宽泛”的问题;findAll系列则返回数组,无匹配为空数组;props中可能包含attach这类 reconciler 内部 props,断言时请留意;fireEvent的事件名可写pointerUp或onPointerUp,找不到处理器时只告警不抛错;- 默认画布尺寸 1280×800,可通过
CreateOptions.width/height覆盖,并会反映到useThree的size中。
相关文档与源码索引
- API 文档:React Three Test Renderer API、React Three Test Instance API
- 包说明与安装:packages/test-renderer/README.md
- 核心实现:src/index.tsx(
create/advanceFrames)、src/createTestInstance.ts(实例包装与查询)、src/fireEvent.ts(事件触发)、src/createTestCanvas.ts(mock canvas) - 树与图转换:src/helpers/tree.ts、src/helpers/graph.ts
- 类型定义:src/types/public.ts(
CreateOptions、Renderer、MockSyntheticEvent、Tree、SceneGraph) - 测试用例:RTTR.core.test.tsx、RTTR.events.test.tsx、RTTR.hooks.test.tsx、RTTR.methods.test.tsx
【免费下载链接】react-three-fiber🇨🇭 A React renderer for Three.js项目地址: https://gitcode.com/GitHub_Trending/re/react-three-fiber
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考