@react-three/test-renderer 测试渲染器 API 全解析:为 react-three-fiber 场景编写可断言的单元测试
2026/9/10 10:10:15 网站建设 项目流程

@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 场景后,你自然会想对它做自动化测试。但常规思路会遇到两个障碍:

  1. THREE 元素不在 DOM 中react-dom无法渲染<mesh />这类元素,因为@react-three/fiber拥有自己独立的 reconciler,会把元素挂载到独立的 React root 上,react-dom看不到场景内部的树结构;
  2. 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.jsmodule指向dist/react-three-test-renderer.esm.js)。

三、create():创建测试渲染器

3.1 基本签名

const renderer = await ReactThreeTestRenderer.create(element, options)

create接收一个 THREE 元素(例如<mesh />),返回一个 Promise,需要await获取渲染器实例。默认情况下它不会创建真正的THREE.WebGLRenderer,也没有渲染循环,但会渲染出完整的场景图。返回值包含scenegetInstancetoTreetoGraphfireEventadvanceFramesupdateunmount等成员,下面逐一展开。

从源码看(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(对应useThreesize.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 />相关配置项(如cameraglshadowsdpr等)原则上也适用;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

源码中scenewrapFiber(_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-renderertoTree()行为类似,会包含所有以 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.camerastopPropagation是一个空操作占位(保证调用不抛错),而...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-rendereract()类似,用于在断言前“安定”组件状态。不同之处在于:使用 RTTR 时,你不需要手动把ReactThreeTestRenderer.createrenderer.update包进act(它们内部已经处理),只需要在需要推动异步更新(如advanceFramesfireEvent、异步 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 属性

属性类型说明
instanceTObject该测试实例对应的实例对象,即 THREE 初始化的类实例(THREE.MeshTHREE.Scene等)
typestring测试实例的 THREE 类型,如'Scene''Mesh'
propsobject当前传给元素的 props,包含隐藏的attach="geometry"这类在 reconciler 中自动应用的 props
parentReactThreeTestInstance \| null父测试实例,无父时返回null
childrenReactThreeTestInstance[]children属性返回子测试实例,不包含Geometry、Material 等 attach 挂载项
allChildrenReactThreeTestInstance[]返回全部子测试实例,粒度与toTree()一致,捕获树中所有 React 组件

源码实现(src/createTestInstance.ts)中,childrenallChildren的关键差异在于getChildrenexhaustive选项:默认过滤掉带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:默认从根节点开始(includeRoottrue),递归遍历allChildren并始终包含子树的根。而实例方法findAll调用时显式传入了{ includeRoot: false }(见 src/createTestInstance.ts),即实例上的查询不会把自身算作候选,只会搜索其子树——这也解释了为什么scene.find((node) => node.instance.name === 'mesh_01')可以命中场景下的子节点。

六、底层原理:RTTR 是如何“假装”渲染的

RTTR 能在纯 Node 环境渲染 THREE 场景,核心在于三块 mock 机制:

  1. Mock Canvas(src/createTestCanvas.ts):优先使用document.createElement('canvas')(jsdom 环境),否则构造一个带getContext的假 canvas 对象,其getContext返回WebGL2RenderingContextmock(实现见 src/WebGL2RenderingContext.ts),并在globalThis上补充WebGLRenderingContext/WebGL2RenderingContext全局类,保证 three.js 拿到上下文后不会真的调用 GPU。

  2. 复用 fiber 的 reconciler(src/index.tsx):通过import { createRoot, reconciler, _roots as mockRoots } from '@react-three/fiber'创建独立 root,并把 store 从mockRoots.get(canvas)中取出用于后续的fireEventadvanceFrames。RTTR 在入口处执行了extend(THREE as any),将全部 THREE 类注册进 fiber 的元素目录,使<mesh />等 JSX 标签可被解析。

  3. 实例包装的 WeakMap 缓存(src/createTestInstance.ts):wrapFiber使用WeakMap<Instance, ReactThreeTestInstance>保证同一个 fiber 始终返回同一个测试实例包装对象,避免每次访问parent/children时产生身份不一致的实例。

七、waitFor:处理异步条件的辅助函数

除了createact,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.tsxJSX 渲染、hooks、useTransition、空场景、复合组件、toGraph/toTree、Fragments、primitive与子树、快照断言
RTTR.events.test.tsxfireEvent的两种命名方式、mockEventData 传递、未找到处理器时的降级行为
RTTR.hooks.test.tsxuseThree的 store 状态、useLoader加载模型、advanceFrames驱动useFrame
RTTR.methods.test.tsxparent/childrenfind/findAllfindByType/findAllByTypefindByProps/findAllByProps(含 RegExp)

快照文件见 RTTR.core.test.tsx.snap,可直观看到toTree()/toGraph()的 JSON 输出形态。

十、实用要点速查

  • create/update/unmount内部已处理act只需对advanceFramesfireEvent、异步回调包一层act
  • toTree()反映React 组件树(含函数组件与 attach 项),toGraph()反映THREE 场景图(不含 attach 项);两者都是快照测试的好帮手;
  • childrenallChildren的区别在于是否包含 attach 挂载项(Geometry / Material 等);
  • find/findByType/findByProps要求恰好一个匹配,否则抛错——这能及早暴露“查询条件过于宽泛”的问题;findAll系列则返回数组,无匹配为空数组;
  • props中可能包含attach这类 reconciler 内部 props,断言时请留意;
  • fireEvent的事件名可写pointerUponPointerUp,找不到处理器时只告警不抛错;
  • 默认画布尺寸 1280×800,可通过CreateOptions.width/height覆盖,并会反映到useThreesize中。

相关文档与源码索引

  • 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(CreateOptionsRendererMockSyntheticEventTreeSceneGraph
  • 测试用例: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询