HarmonyOS趣味相机实战第22篇:Hypium测试分层、CameraKit替身与真机回归矩阵

HarmonyOS趣味相机实战第22篇:Hypium测试分层、CameraKit替身与真机回归矩阵

HarmonyOS趣味相机实战第22篇:Hypium测试分层、CameraKit替身与真机回归矩阵

摘要

相机应用不能只靠“打开页面拍一张图”验证。权限拒绝、无前置镜头、PhotoAvailable 迟到、PixelMap 解码失败、Preferences 坏 JSON、页面退出中的倒计时、前后台重建等问题都具有时序性,人工偶尔点一次很难稳定复现。

本文基于D:/APP/1quweixiangji趣味相机工程,盘点entry/src/testentry/src/ohosTest的现状,并建立四层测试金字塔:纯函数与模型、服务适配器、ArkUI 工作流、CameraKit 真机回归。重点不是追求用例数量,而是把不可控硬件边界包在小型适配器后,让状态机、选型算法、迁移和资源所有权在本地快速测试。

工程背景与测试文件

文件当前内容结论
entry/src/test/LocalUnit.test.ets水印快照创建与保存克隆有真实业务断言
entry/src/test/List.test.ets注册 localUnitTest本地测试入口
entry/src/ohosTest/ets/test/Ability.test.etsabc包含b模板断言尚未覆盖 Ability
entry/src/ohosTest/ets/test/List.test.ets注册 abilityTest真机测试入口
PhotoAlbumService.etsPreferences、克隆与默认值适合本地单测
CameraDeviceService.etsCameraManager 直接调用需要适配器替身
CameraPreviewService.etsSession、回调、PixelMap适合集成和真机测试
Index.ets页面工作流适合状态与组件测试

环境与测试边界

项目当前值说明
测试框架@ohos/hypium 1.0.25根 oh-package 配置
Mock 依赖@ohos/hamock 1.0.0可用于替身能力
本地测试目录entry/src/test不依赖真机硬件
设备测试目录entry/src/ohosTestAbility 与系统能力
target SDK6.0.2(22)当前工程
核心设备能力CAMERA需要真实权限和镜头

一、先看现有有效测试

项目已有:

it('photo snapshot keeps watermark metadata',0,()=>{constphoto=PhotoAlbumService.createPhoto(1,[],'无滤镜','无相框','标准模式','real','测试拍照',0,'标准',0,'12MP (4:3)',{enabled:true,template:'work',title:'工作',locationText:'杭州',note:'今日拍摄',timeText:'06-30 19:49'});expect(photo.title).assertEqual('水印照片 1');expect(photo.layerCount).assertEqual(0);expect(photo.watermark?.title).assertEqual('工作');expect(photo.watermark?.locationText).assertEqual('杭州');});

以及保存克隆:

constsaved=PhotoAlbumService.savePhoto(photo);expect(saved.watermark?.template).assertEqual('check');expect(saved.watermark?.note).assertEqual('记录一下');expect(saved.status).assertEqual('saved');

这两条用例测试了业务,而不是框架本身,应保留并扩展。

二、删除模板断言,换成真实Ability验收

当前 ohosTest:

it('assertContain',0,()=>{leta='abc';letb='b';expect(a).assertContain(b);expect(a).assertEqual(a);});

这只能证明 Hypium 能运行,不能证明应用可用。冒烟用例应至少覆盖:

EntryAbility能启动 -> WindowStage加载pages/Index -> 首页关键元素存在 -> 不授权相机时应用不崩溃 -> 相册和文档空态可显示

模板用例在框架接通后应移除,避免测试数量看起来增加但没有风险覆盖。

三、测试金字塔按硬件依赖分层

层级比例建议运行位置例子
纯函数/模型60%本地Profile评分、坐标映射、迁移
服务适配器20%本地权限、设备、Preferences替身
ArkUI工作流10%模拟或设备弹层、Tab、状态闭环
CameraKit真机10%真机预览、拍照、回调、释放

硬件用例最慢也最不稳定,因此只保留系统契约和关键路径;组合爆炸放到纯函数层覆盖。

四、先抽出纯函数

以下逻辑不需要相机:

  • Profile 宽高比和像素评分。
  • camera.Rect归一化。
  • 旋转、镜像、Cover 坐标映射。
  • 前后镜头可用性决策。
  • 水印摘要与默认值。
  • Preferences JSON 迁移。
  • 装饰套装引用校验。
  • 人脸与骨骼目标匹配评分。
  • 拍照状态提示文案。

把它们放到独立模块后,本地测试可以在毫秒级完成。

五、CameraDeviceService需要依赖反转

当前直接调用:

constmanager:camera.CameraManager=camera.getCameraManager(context);constsupportedDevices:camera.CameraDevice[]=manager.getSupportedCameras();

可定义最小端口:

interfaceCameraDevicePort{totalCount():number;hasPosition(position:CameraPositionKey):boolean;}classCameraKitDeviceAdapterimplementsCameraDevicePort{constructor(privatemanager:camera.CameraManager){}totalCount():number{returnthis.manager.getSupportedCameras().length;}hasPosition(position:CameraPositionKey):boolean{// 调用getCameraDevice并转换异常returntrue;}}

业务函数只依赖端口:

functiondiscoverWithPort(port:CameraDevicePort,preferred:CameraPositionKey):CameraDeviceState{consthasFront=port.hasPosition('front');consthasBack=port.hasPosition('back');returnbuildDeviceState(port.totalCount(),hasFront,hasBack,preferred);}

六、设备发现决策表

frontbackpreferredactiveavailable
falsefalsebackback或占位false
truefalsebackfronttrue
falsetruefrontbacktrue
truetruefrontfronttrue
truetruebackbacktrue

Hypium 用例:

it('falls back to front when back is absent',0,()=>{conststate=buildDeviceState(1,true,false,'back');expect(state.available).assertTrue();expect(state.activePosition).assertEqual('front');});

nextPosition()也覆盖单镜头不切换和双镜头往返。

七、权限服务返回业务结果而不是抛异常

权限测试至少有:

已授权 用户首次同意 用户拒绝 系统API抛BusinessError Context不可用

适配器:

interfaceCameraPermissionPort{check():Promise<boolean>;request():Promise<boolean>;}classFakePermissionPortimplementsCameraPermissionPort{constructor(privatechecked:boolean,privaterequested:boolean,privateshouldThrow:boolean=false){}asynccheck():Promise<boolean>{if(this.shouldThrow){thrownewError('check failed');}returnthis.checked;}asyncrequest():Promise<boolean>{returnthis.requested;}}

业务层断言状态和用户文案,不需要真的弹系统授权框。

八、Preferences测试使用内存仓库

定义:

interfaceKeyValueStore{get(key:string,fallback:string):string;put(key:string,value:string):Promise<void>;flush():Promise<void>;}classMemoryStoreimplementsKeyValueStore{values:Map<string,string>=newMap();flushCount:number=0;get(key:string,fallback:string):string{returnthis.values.get(key)??fallback;}asyncput(key:string,value:string):Promise<void>{this.values.set(key,value);}asyncflush():Promise<void>{this.flushCount+=1;}}

覆盖:

  • 空存储返回空数组。
  • 坏 JSON 回退且不崩溃。
  • 保存后最新照片位于首位。
  • 相册最多 60 条。
  • 文档最多 80 条。
  • delete 后调用 flush。
  • clone 返回值修改不会污染缓存。

九、深拷贝用例防止状态串改

it('list result cannot mutate cache watermark',0,async()=>{constfirst=awaitservice.listPhotos();if(first[0].watermark){first[0].watermark.locationText='changed';}constsecond=awaitservice.listPhotos();expect(second[0].watermark?.locationText).assertEqual('original');});

数组复制不等于嵌套对象复制。水印快照必须单独 clone。

十、Profile评分测试不依赖真实设备

输入候选尺寸,断言:

it('profile choice is independent from source order',0,()=>{constleft=choosePreviewProfile([p720,p1080,p480]);constright=choosePreviewProfile([p480,p720,p1080]);expect(left.size.width).assertEqual(right.size.width);expect(left.size.height).assertEqual(right.size.height);});

此外覆盖预算、比例、空数组和全部超预算降级。

十一、坐标映射使用表驱动

interfaceMappingCase{name:string;rotation:camera.ImageRotation;mirror:boolean;inputX:number;inputY:number;expectedX:number;expectedY:number;}

为四个角点生成 0/90/180/270 和前后镜头组合。表驱动比复制几十个it()更容易审查。

十二、CameraPreviewService拆成会话端口

最难测试的是直接持有 CameraKit 类。可定义:

interfaceCameraSessionPort{open():Promise<void>;configure():Promise<void>;start():Promise<void>;capture():Promise<void>;stop():Promise<void>;release():Promise<void>;}

Fake 记录调用顺序:

classFakeSessionPortimplementsCameraSessionPort{calls:string[]=[];failAt:string='';asyncinvoke(name:string):Promise<void>{this.calls.push(name);if(this.failAt===name){thrownewError(`${name}failed`);}}asyncopen(){awaitthis.invoke('open');}asyncconfigure(){awaitthis.invoke('configure');}asyncstart(){awaitthis.invoke('start');}asynccapture(){awaitthis.invoke('capture');}asyncstop(){awaitthis.invoke('stop');}asyncrelease(){awaitthis.invoke('release');}}

断言失败点后的清理顺序,而不是模拟整个 CameraManager。

十三、会话故障注入矩阵

故障点预期
input.open返回 error,清理已创建输入
createPreviewOutput释放 input
commitConfig释放 session、outputs、input
session.start完整停止与释放
metadata.start基础预览继续,识别降级
capture预览保持 running
photoAvailable error本次拍照失败,不重建预览
PixelMap decode释放 Photo 和 ImageSource

十四、异步时间使用可控时钟

倒计时和识别采样若依赖真实setTimeout,测试慢且不稳定。抽象:

interfaceClock{now():number;delay(ms:number):Promise<void>;}classFakeClockimplementsClock{current:number=0;now():number{returnthis.current;}asyncdelay(ms:number):Promise<void>{this.current+=ms;}}

可立即测试:

  • 3/5/10 秒倒计时序列。
  • 页面退出后 generation 失效。
  • 2200ms 识别结果过期。
  • 拍照回调超时。

十五、PixelMap用Lease替身验证释放次数

classFakeLease{releaseCount:number=0;asyncrelease():Promise<void>{this.releaseCount+=1;}}

断言:

  • 解码失败释放一次。
  • 成功转移时服务不释放。
  • 弹层关闭释放一次。
  • 关闭后页面退出不会释放第二次。
  • 迟到任务无人接收时释放。

十六、ArkUI工作流用状态模型驱动

将页面动作从 Builder 中抽出:

typeCaptureUiStatus='idle'|'countdown'|'capturing'|'preview'|'saving'|'error';interfaceCaptureUiState{status:CaptureUiStatus;countdown:number;message:string;hasPixelMap:boolean;}

测试事件:

点击拍照 -> countdown/capturing 回调成功 -> preview 点击重拍 -> idle并释放 点击保存 -> saving -> idle 保存失败 -> preview或error,可重试 页面退出 -> idle且资源清空

UI 只渲染状态,核心转换可本地测试。

十七、ohosTest只验证真实系统契约

真机用例建议:

  1. EntryAbility 冷启动成功。
  2. CAMERA 权限拒绝后页面可继续操作相册。
  3. 授权后 Preview 进入 running。
  4. PhotoOutput 回调在超时内到达。
  5. 前置拍照 mirror 方向正确。
  6. 前后台切换后相机可恢复。
  7. 连续切镜头后无重复回调。
  8. 停止后相机资源可被其他应用使用。

不要在真机上重复纯函数的几十种组合。

十八、测试数据不能包含真实隐私

固定水印夹具可以使用:

constwatermark:WatermarkSnapshot={enabled:true,template:'work',title:'测试水印',locationText:'测试地点',note:'自动化测试',timeText:'01-01 08:00'};

不要把真实照片、真实地点、用户相册 URI 或浏览器登录信息提交到测试仓库。

十九、测试命名表达业务结果

推荐:

falls back to back camera when front is absent keeps latest 60 photos after persistence releases pixel map when preview is closed ignores stale capture result after page exit does not fail preview when face metadata is unsupported

避免:

test1 assertContain cameraTest works

失败报告应让开发者不打开测试代码也能理解风险。

二十、持续回归门槛

提交前:

ArkTS静态检查 -> src/test本地单元测试 -> Debug构建

候选发布包:

Release构建 -> ohosTest核心真机用例 -> 前后镜头兼容矩阵 -> 连续拍照和前后台压力测试 -> 权限与隐私检查

测试失败不能只截图记录,应保存非隐私日志、设备能力摘要、用例步骤和版本号。

二十一、常见问题排查

现象原因改进
测试全绿但相机黑屏只有模板断言增加真机Session契约用例
本地测试无法导入CameraKit业务直接依赖系统类引入最小端口和适配器
倒计时用例需要等10秒使用真实Timer注入FakeClock
测试偶发失败依赖固定sleep等待状态或控制Promise
坏JSON覆盖不到Preferences不可替换注入MemoryStore
连续用例相互污染静态缓存未重置提供测试生命周期重建
资源泄漏无法断言直接使用PixelMap引入Lease和释放计数
真机用例数量爆炸所有组合都上设备组合放纯函数层

二十二、上线前验收清单

  • 删除无业务价值的 Hypium 模板断言。
  • 保留并扩展水印快照真实测试。
  • Profile、坐标、迁移和状态机抽为纯函数。
  • CameraManager 和权限 API 通过最小端口隔离。
  • Preferences 测试使用内存仓库。
  • 深拷贝测试覆盖嵌套 WatermarkSnapshot。
  • 会话启动每个失败点都有清理断言。
  • Timer 和时间戳通过可控 Clock 测试。
  • PixelMap 所有权有释放次数测试。
  • ArkUI 工作流使用明确状态模型。
  • ohosTest 聚焦真实系统和硬件契约。
  • 真机覆盖权限拒绝、切镜头、拍照和前后台。
  • 测试夹具不包含真实照片和地点。
  • 发布构建前执行固定回归门槛。

总结

HarmonyOS 相机测试的关键不是把 CameraKit 全部 Mock 掉,而是划清可控和不可控边界。Profile 评分、坐标映射、数据迁移、状态机和资源所有权都能在本地稳定测试;真实权限、Session、PhotoAvailable 和设备差异则由少量高价值 ohosTest 验证。

当前工程已经有两条有效水印快照用例,下一步应删除模板断言并引入端口、FakeClock、MemoryStore 和 PixelMap Lease。测试分层后,故障注入不再依赖手工碰运气,真机回归也能聚焦真正需要硬件证明的契约。