VisionCamera 真机 Harness 测试指南:为命令式 API 编写端到端回归测试
2026/9/16 0:03:41 网站建设 项目流程

VisionCamera 真机 Harness 测试指南:为命令式 API 编写端到端回归测试

【免费下载链接】react-native-vision-camera📸 A powerful, high-performance React Native Camera library.项目地址: https://gitcode.com/GitHub_Trending/re/react-native-vision-camera

导读

本文介绍 react-native-vision-camera 仓库中apps/simple-camera/__tests__/目录下的 Harness 真机测试套件:它通过 react-native-harness 将 Jest 兼容的测试运行器嵌入示例应用,在真实手机(本地 adb 设备或 AWS Device Farm 设备)上对VisionCamera的命令式 API 做端到端回归验证。读完本文,你将掌握这套测试的组织方式、九条编写规范、本机与 CI 的运行方法,以及如何用"最小失败测试 + PR"的流程上报相机相关 bug。

为什么需要一套跑在真机上的测试

相机库的 API 与真实硬件强耦合:分辨率、帧格式、闪光灯、HDR、防抖等能力因设备而异,模拟器和类型系统都无法验证capturePhoto拍出的照片是否真的包含像素数据。apps/simple-camera/__tests__/README.md明确了这套测试存在的两个目标,按优先级排列:

  1. 公共 API 回归在 CI 上自动失败:该库在真实硬件上支持的每一项VisionCamera特性都对应一个测试。如果一次重构破坏了capturePhoto,下一次 PR 的 CI 运行就会变红。
  2. Bug 报告变成可执行代码:任何人发现 bug 时,预期不是新建一个独立复现仓库,而是打开一个 PR,在本目录下添加一个最小的失败测试复现问题。维护者在同一分支上修复 bug 直到 CI 变绿,测试随修复一起合并。这样同一个 bug 永远不会再次静默回归。

如果你要上报 bug:打开一个 PR,按下文规则在本目录下添加尽可能小的it(...)块,然后在 issue 中引用该 PR——PR 上的 CI 运行本身就是复现。无需创建单独仓库。

测试布局:一个文件对应一个 API 领域

测试按领域拆分,每个文件测试命令式VisionCameraAPI 的一个切片,文件命名遵循__tests__/**/*.harness.{ts,tsx}模式:

文件覆盖内容
visioncamera.devices.harness.tsVisionCamera.createDeviceFactory、设备枚举、每设备能力、getCameraForIdaddOnCameraDevicesChangedListenergetSupportedExtensionsuserPreferredCamera
visioncamera.session.harness.tscreateCameraSessionconfigurestartstopaddOnStartedListener/addOnStoppedListener/addOnErrorListener、中断监听器、运行中重配置、多摄像头
visioncamera.photo.harness.tscreatePhotoOutputcapturePhoto/capturePhotoToFile、容器格式(JPEG、HEIC、DNG)、闪光灯 / 镜像 / 质量 / 分辨率选项、拍摄生命周期回调、预览图
visioncamera.video.harness.tscreateVideoOutputRecorder生命周期、音频、maxDuration/maxFileSize自动停止、暂停 / 恢复 / 取消、持久化 Recorder、更高分辨率编码
visioncamera.frame.harness.tscreateFrameOutput、通过react-native-vision-camera-worklets安装 worklet、YUV / RGB / 原生像素格式、scheduleOnRNcreateSynchronizablesetOnFrameDroppedCallbackenablePreviewSizedOutputBuffers
visioncamera.multi-output.harness.ts组合 photo、video、frame 输出的多输出会话、替换某个输出而其他输出保持挂接、跨会话重启的持久录制
visioncamera.constraints.harness.tsVisionCamera.resolveConstraints+onSessionConfigSelected、FPS / HDR / 防抖 / binned / pixelFormat / resolutionBias 约束
visioncamera.controller.harness.tsCameraController——变焦、手电筒、曝光补偿、对焦测光、低光增强、主体区域监听器
visioncamera.hooks.harness.tsxuseCameraDevice(...)对位置和物理设备过滤变化的 React 钩子响应性,以及useCamera(...).onUIRotationChanged
visioncamera.utils.harness.ts纯公共工具,如覆盖所有输出 / 界面朝向组合的getUIRotation(...)
visioncamera.coordinates.harness.tsxFrame.convertFramePointToCameraPoint/convertCameraPointToFramePointPreviewView.convertViewPointToCameraPoint/convertCameraPointToViewPointPreviewView.createMeteringPointconvertScannedObjectCoordinatesToViewCoordinates、端到端 Frame → Camera → View 往返
visioncamera.nativepreviewview.harness.tsxNativePreviewView生命周期、布局敏感预览回归覆盖、resizeMode、AndroidimplementationMode、手势控制器、多预览挂载、PreviewViewref 方法、AndroidtakeSnapshot()尺寸
visioncamera.camera-view.harness.tsx高层<Camera>预览生命周期、photo 输出集成、控制器 props、原生手势、CameraRef方法、isActive、挂载 / 卸载 / 替换行为

选择与你测试内容最匹配的文件。如果要复现的 bug 跨越多个输出,放入失败最核心的那个文件。如果都不合适,新建visioncamera.<domain>.harness.ts——Jest 会自动拾取所有匹配__tests__/**/*.harness.{ts,tsx}的文件。

测试编写规范:九条硬性契约

这套测试的契约刻意严格,目的是让测试读起来和用户写的VisionCamera业务代码完全一致——贡献者和 LLM 无需学习框架专用 helper 就能直接插入复现代码。

1. 直接使用VisionCameraAPI,禁止抽 helper

每个测试都要从VisionCamera开始内联地端到端构建会话。不要createSession()configureAndStart()之类的 helper——测试里的 API 应该和用户在 App 中写的一模一样。以下代码来自 visioncamera.photo.harness.ts,是一个完整的内存 JPEG 拍摄测试:

it('captures a JPEG Photo in-memory', async () => { const session = await VisionCamera.createCameraSession(false) const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.FHD_4_3, containerFormat: 'jpeg', quality: 0.9, qualityPrioritization: 'balanced', }) await session.configure([ { input: backDevice, outputs: [{ output: photoOutput, mirrorMode: 'auto' }], constraints: [], }, ]) await session.start() const photo = await photoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) expect(photo.width).toBeGreaterThan(0) expect(photo.containerFormat).toBe('jpeg') photo.dispose() await session.stop() })

beforeAll可以缓存平凡的 API 结果(例如CameraDeviceFactory和默认的后置 / 前置CameraDevice),但不能包装任何相机会话搭建。每个it块拥有自己独立的sessionphotoOutput等,以尽可能原子地运行。每个原子测试必须正确销毁非平凡对象,避免在测试之间泄漏硬件状态——最重要的是始终stop()(甚至dispose())一个CameraSession

2. 硬需求 vs 软需求

不同相机的硬件能力不同:硬需求失败是真实 bug;软特性缺失属于设备限制,不应让测试失败。

  • 硬需求——用expect(...)检查,使测试失败。例如:存在后置摄像头;photo 输出产出的照片width > 0session.configure为每个 connection 返回一个 controller;或某个 API 契约如约成立。
  • 软需求——由匹配的能力标志门控,不支持时用context.skip('<what>: <reason>')。Harness 会将其报告为带原因的跳过测试,出现在运行摘要和 JUnit 输出中。不要console.log(...)+return来掩盖运行时相机能力缺口。
it('resolves photoHDR: true when the device supports photo HDR', async (context) => { if (!backDevice.supportsPhotoHDR) { return context.skip('photoHDR: not supported on this device') } // hard-assert HDR behavior from here on })

当可空值需要随后收窄时,使用上述 guard 形式而非context.skip(condition, reason),并保留return,让 TypeScript 理解后续代码只在能力存在时执行。如果矩阵中只有一个可选用例不受支持,把它拆成独立的it(...),让始终支持的用例照常运行、可选用例报告为 skipped。

只能从it(...)内部或有意跳过整个测试的代码中调用context.skip(...),不要藏进共享 helper 里做可选子断言——它会中止整个it(...)。跨平台测试若有仅 Android 或仅 iOS 的断言,应拆成带各自平台跳过的独立it(...)

能力标志分布在三处:

  • CameraDevicehasFlashhasTorchsupportsFocusMeteringsupportsExposureBiassupportsPhotoHDRsupportsFPS(n)supportsVideoStabilizationMode('cinematic')等;
  • CameraControllerminISOmaxISOminExposureDuration等;
  • VisionCamerasupportsMultiCamSessions

务必使用它们。不要用临时的 try/catch 包裹某个操作来静默跳过——如果无法提前查询支持情况,把它标记为缺失 API(见"已知 API 缺口"),并用it.skip加 TODO 说明需要什么才能把它变成硬需求。

3. 测试行为,而不是测试类型

Nitrogen 与 TypeScript 在编译期、Nitro Modules 在桥接层已经强制了类型。typeof x === 'number'Array.isArray(devices)这类类型形状断言是纯噪音——如果数字真变成了字符串,桥接层早就抛错了。

应该断言那些需要相机真正干活的事实:

  • 快乐路径下操作完成且不抛异常——await session.configure(...)await photoOutput.capturePhoto(...)await recorder.stop()能返回本身就有意义。
  • 错误路径下该抛时抛——例如在configure()之前调用session.start()、对已 dispose 的输出拍摄、请求不支持的targetResolution。用await expect(...).rejects.toThrow()
  • 结果具有正确的语义值而非类型——拍到的Photowidth > 0height > 0;视频文件在磁盘上的大小> 0;返回的 controller 列表length === connections.length
  • 字段之间的 API 契约成立——若device.hasFlash为 false,则capturePhoto({ flashMode: 'on' })必须 reject;若 connection 是mirrorMode: 'auto',则Photo.isMirrored应反映设备前后位置。这类跨字段不变量正是类型捕获不到、真实 bug 常常藏身之处。
  • 近似数值使用 matcher 容差——对坐标、尺寸、时间戳等浮点值,优先expect(actual).toBeCloseTo(expected, digits),而不是手写Math.abs(actual - expected)断言。matcher 更短、失败时能看到期望值,也与坐标测试的整体风格一致。
  • 生命周期与监听器按正确顺序触发——addOnStartedListenerstart()之后 resolve、addOnStoppedListenerstop()之后、录制回调在recorder.stop()之后。等待监听器,不要轮询isRunning

如果某个测试把实现 stub 成throw new Error('TODO')仍然通过,那说明你在测类型系统,而不是相机。

4. 优先回调而非轮询状态

session.isRunning在 Android 上是异步更新的。等待session.addOnStartedListener(...)addOnStoppedListener(...),配合waitUntil(() => started, { timeout: 10_000 }),而不是在 sleep 循环里轮询isRunning。从源码看,visioncamera.session.harness.ts 正是用 test-utils.ts 中的deferred()把监听器回调接入 Promise,再经withTimeout(promise, 10_000, 'session start')限时等待——原生错误会以 Promise rejection 形式带自身消息失败测试,而不是超时。

5. 不要静默吞掉错误

不允许在预期成功的调用外包.catch(() => undefined)try {} catch {}。如果session.stop()可能抛异常,测试就该失败——那是回归。如果某件事 100% 会抛,说明是缺失特性 / 回归,仍应添加测试——视上下文用it(...)it.skip(...)。这相当于一份 TODO 清单,不久后让测试变绿。

6. 只在必要时 dispose

PhotoFrameImage持有大型原生缓冲区——用完立即调用.dispose()。测试中无需disposeCameraDeviceCameraController或输出,JS 运行时 GC 通常会在测试间释放它们。注意:在 JS 中 dispose 一个 HybridObject 后该对象即不可再用,任何后续调用都会抛错——所以只在绝对必要或持有大块原生内存(如PhotoFrameImage)时 dispose。

7. 禁止人为setTimeout延迟

测试只能等待它们真正依赖的事件(session.addOnStartedListeneronRecordingFinished、帧计数器、CompletableDeferred)。随机 sleep 若干毫秒"让相机稳定下来"会引入 flakiness 并掩盖真实回归。如果你发现自己写await sleep(500)来"让它工作",把它当作要修的 bug,而不是要保留的补丁。

唯一的例外是:经过的墙钟时间本身就是被测行为的一部分。视频录制测试可以在startRecording()后短暂 sleep,因为确实需要 recorder 产出非空片段、收集统计、随时间练习暂停 / 恢复,或观察cancelRecording()之后不再发出onRecordingFinished。保持这些 sleep 短小、局限于录制阶段,并从周围测试中能明显看出原因。不要用 sleep 等待会话、预览、帧或监听器生命周期状态。

8. 平台守卫

纯 iOS 特性(CameraObjectOutput、continuity camera、getSupportedVideoCodecs等)或纯 Android 特性(CameraExtension等)应以return context.skip('...: iOS only')/return context.skip('...: Android only')开头。不要Platform.OS分支掩盖本应在两平台一致的行为差异——那应标记为 bug。

如果一个行为两个平台都应支持,写一个共享测试;若某个平台 CI 变红,保持失败可见,直到平台差异被修复。Harness 测试应覆盖公共 API 承诺的最宽泛行为。来自单一原生栈的 bug 报告(如 AVFoundation 断言或 CameraX 异常)不应成为把回归测试做成平台特定的理由——只要用户可见行为应当在所有平台一致。不要为了让测试更窄、更快或更贴近原始报告而添加平台守卫,它只会掩盖另一平台的回归。拿不准时,在所有 Harness 平台上运行共享行为,让 CI 暴露真实的平台差异。

不要用这类标志守卫已暴露运行时可用性检查的特性——例如setFocusLocked(...)可以用device.supportsManualFocus探测,即使它在 Android 上原生总是false。这样未来 Android 一旦支持对焦锁定,测试可自动运行。同样不要守卫因 TODO 尚未在另一平台实现、但技术上可行的特性。setFocusLocked就是这种情况——预期缺失平台上的测试保持红色直到实现,这相当于维护者的任务清单。平台守卫只适用于静态确定的平台专属行为,如 iOS 的CameraObjectOutput或 Android 的CameraExtension

9. 保持断言紧凑且有诊断性

测试应当读起来像某个行为的小型可执行规格:

  • 命名不变量而非实现细节:优先使用expectedBoundsreportedBoundsroundTrippedcapturedPhoto这类本地名,而不是描述临时机制的变量名。
  • 用 matcher 断言替代布尔算术:优先toBeCloseTotoHaveLengthtoContaintoEqualrejects.toThrow,避免内联计算布尔值再断言。这对 AWS Device Farm 的 Harness/Vitest 日志尤其重要:富 matcher 保留 received 和 expected 值,而 max/min 增量这类聚合检查通常只显示派生数字。
  • 对重复维度或用例用循环:边、轴、格式或角点,用一个小型内联数组加一个期望,比四份易漂移的复制粘贴断言更清晰。
  • 把局部数学放进局部命名it块内的小函数命名一次性变换或断言(如getBounds(...))没问题。不要把算术、布尔表达式、map(...)等变换直接塞进expect(...);先赋给有描述性的本地名。不要把多个事实折叠成一个计算断言,如expect(a + b).toBeGreaterThan(0)——应分别断言ab,让 CI 失败时能定位出错的数值。不要抽取共享 setup helper,会话仍需内联构建。
  • 保持 Harness 输出安静:不要给测试添加console.log。用聚焦的 matcher 断言,让失败报告相关的 received 与 expected 值。

运行测试

本机(Android 真机)

来自 apps/simple-camera/package.json 的脚本和 README 的命令组合:

# 1. 构建一次 debug APK cd apps/simple-camera && bun run build:android # 2. 安装并授予相机 / 麦克风 / 定位权限 adb install -r android/app/build/outputs/apk/debug/app-debug.apk BUNDLE_ID=com.margelo.nitro.camera.example.simple adb shell pm grant $BUNDLE_ID android.permission.CAMERA adb shell pm grant $BUNDLE_ID android.permission.RECORD_AUDIO adb shell pm grant $BUNDLE_ID android.permission.ACCESS_FINE_LOCATION adb shell pm grant $BUNDLE_ID android.permission.ACCESS_COARSE_LOCATION # 3. 对已连接设备运行完整 harness 套件 HARNESS_ANDROID_DEVICE_MANUFACTURER=<manufacturer> \ HARNESS_ANDROID_DEVICE_MODEL=<model> \ bun run test:harness:android # 4. 或只跑一个文件 HARNESS_ANDROID_DEVICE_MANUFACTURER=<manufacturer> \ HARNESS_ANDROID_DEVICE_MODEL=<model> \ bun run test:harness:android -- --testPathPatterns=photo

HARNESS_ANDROID_DEVICE_MANUFACTURER/HARNESS_ANDROID_DEVICE_MODEL来自adb shell getprop ro.product.manufacturer/ro.product.model。在 AWS Device Farm 上由工作流自动设置。

权限每次安装只授予一次。如果用adb install -r重装 APK,请在下次测试前重新执行pm grant行——否则第一个测试的expect(cameraPermissionStatus).toBe('authorized')会失败。

.harness/目录由 harness 打包器自动生成且已被 gitignore,可以放心删除。

运行配置

rn-harness.config.mjs 定义了运行器细节,值得了解的要点:

  • 默认 Android bundleId 为com.margelo.nitro.camera.example.simple(可通过HARNESS_ANDROID_BUNDLE_ID覆盖),入口为./index.js,注册组件名SimpleCamera
  • 默认使用物理 Android 设备(manufacturer/model),设HARNESS_ANDROID_DEVICE_MODE=emulator可切到Pixel_API_35模拟器(API 35,可经HARNESS_ANDROID_EMULATOR/HARNESS_ANDROID_API_LEVEL调整);
  • iOS 侧 CI 下使用物理设备(HARNESS_IOS_DEVICE_ID),本地默认模拟器iPhone 16 Pro/ iOS 18.5;
  • 超时按 CI 环境自动放宽:CI 下 bundle 启动超时 90s、桥接超时 120s,本地分别为 15s / 45s,最大 App 重启次数 CI 为 4、本地为 2;
  • 开启了detectNativeCrashesresetEnvironmentBetweenTestFilesforwardClientLogspermissions

已知 API 缺口 / 当前跳过的测试

少数测试已编写但被it.skip,因为 VisionCamera API 尚未暴露它们所需的前置条件。每个 skip 在文件中都带 TODO 指向需要先落地的能力。当前包括:

  • Photo 容器格式支持——HEIC 和 DNG 拍摄在某些设备上可用、另一些失败,但当前没有CameraDevice.supportedPhotoContainerFormats。这些测试it.skip并带 TODO,直到 API 落地;一旦存在,它们会变成由标志门控的软需求。
  • Android 上的initialZoom/initialExposureBias——applyInitialConfigconfigure()时运行,早于 CameraX 的 LifecycleOwner 到达 STARTED。CameraControl.setExposureCompensationIndex在该状态下静默失败。相关测试保持it.skip,直到初始配置的应用时机移到 CameraX 能接受的点。
  • Android 上的enablePreviewSizedOutputBuffers——该标志当前未被HybridFrameOutput.kt采纳(源码注释为TODO: enablePreviewSizedOutputBuffers is not taken into account here.)。
  • Android 上的onFrameDropped——HybridFrameOutput.setOnFrameDroppedCallback当前是空操作(TODO: CameraX does not have a way to figure out if a Frame has been dropped or not.)。

如果遇到另一个因 API 缺失而无法写测试的情况:用it.skip加 TODO 说明前置条件添加测试——这样 API 落地时,我们已经知道该翻转启用哪些测试。

CI 集成与调试

Harness 测试在每次触及本目录、VisionCamera 库或 harness 工作流配置的 push 和 PR 上运行,见 .github/workflows/harness-aws-device.yml 与 .github/workflows/harness-android-emulator.yml。

AWS Device Farm 运行是事实来源:真机、真 SoC、真实相机管线。模拟器运行是尽力而为,可能跳过依赖硬件的测试。CI 侧的 Android 流程可参考 run-harness-android-ci.sh:它等待模拟器、安装 APK、验证应用启动且不立即崩溃(60s 启动超时,失败时导出 crash 日志),再以硬超时(默认 720s,可经HARNESS_ANDROID_TEST_TIMEOUT_SECONDS调整)运行bun run test:harness:android,超时即中止并失败。

PR 的 CI 失败时最快的调试路径:

  1. 从失败工作流下载harness output log工件,它包含每个测试的完整 JS 控制台输出;
  2. 检查 Harness/JUnit 的 skipped 测试摘要,看哪些软需求被跳过——skip 原因会告诉你测试设备缺什么;
  3. 搜索FAIL找出哪些it块失败及其堆栈;
  4. 在 IDE 的 JUnit 查看器中打开 JUnit XML 工件,获得结构化摘要。

理想情况下在真机上运行 Harness 测试并流式查看原生日志(Android 用adb logcat),以理解某些失败或原生崩溃。

高层组件测试(<Camera>useCamera()等)

高层组件测试在 API 表面涉及 React 渲染、布局或组件便利行为时,与命令式套件同目录存放。保持聚焦:渲染复现行为的最小组件树、等待真实生命周期事件、通过公共 ref 或回调断言。

详细的预览渲染、布局、快照、resize-mode 与 implementation-mode 覆盖属于 visioncamera.nativepreviewview.harness.tsx;visioncamera.camera-view.harness.tsx 应专注高层<Camera>包装:isActive、生命周期回调、ref 暴露、输出接线、高层手势 props、React 挂载 / 卸载行为。

目标是命令式 API 测试覆盖一切,高层组件测试只覆盖它们的抽象层或基础特性(底层使用同一套命令式 API)——不要重复命令式 API 的整套测试。例如无需同时在命令式测试和<Camera>/useCamera()高层测试中验证CameraVideoOutputmaxFileSize达到后正确停止录制;这类具体测试只放在命令式 API 测试里(更聚焦、更易在 CI 调试),高层测试保持高层——确保 Camera 能启动、React 生命周期 / 卸载 / 重挂载正常、<Camera>正确渲染、渲染新 session 时能拆除旧会话并重新开始、isActive生效、outputs={[...]}数组更新时挂接输出、测试 ref 方法等。

对于布局回归,优先几何与原生 ref 断言,而非黄金截图——Device Farm 的相机画面不是稳定的视觉基准。AWS Device Farm 的相机传感器常被胶带盖住,不会显示明亮视觉内容,但也不是全黑——是带噪点的灰调,有时呈红褐色,像手指按在镜头上。做视觉测试时确保不是纯黑、纯白或其他纯色——相机预览应该是黑白之间的噪点。这有助于区分真正的相机流与 React 中纯黑 / 纯白背景视图,或resizeMode="contain"的填充 / 留白。

仍然允许挂载原生 Hybrid 视图来练习其 ref 方法。某些命令式 API(如PreviewView.convertViewPointToCameraPointPreviewView.createMeteringPoint)只能通过已挂载、已布局的视图触达。这些测试可以render(<NativePreviewView ... />)、经hybridRef取 ref 后直接调用方法——参见 visioncamera.nativepreviewview.harness.tsx 和 visioncamera.coordinates.harness.tsx 中的命令式模式。后者的坐标往返测试(Frame → Camera → Frame)在 worklet 线程内对帧中心与四角做两次转换并scheduleOnRN回主线程比对,正是"最小可复现 + 硬断言"的范本。

【免费下载链接】react-native-vision-camera📸 A powerful, high-performance React Native Camera library.项目地址: https://gitcode.com/GitHub_Trending/re/react-native-vision-camera

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询