HarmonyOS 6.1 单元测试与自动化Mock:保障代码质量的最后一道防线

📅 2026/7/16 9:47:06
HarmonyOS 6.1 单元测试与自动化Mock:保障代码质量的最后一道防线
系列工程化进阶篇。上篇讲完调试技巧有读者问“每次改完代码都要手动点一遍所有页面怕改出新bug有没有自动化的验证方法” 这恰恰是工程化开发的核心——单元测试。很多新手觉得测试是“额外工作”但企业级开发中70%的线上bug都是回归测试没做到位导致的。本文将基于我们写了18篇的电商Demo拆解HarmonyOS 6.1的测试体系从工具类的纯逻辑测试到UI组件的渲染测试再到Mock分布式服务、云函数等外部依赖最终实现“改一行代码跑一遍测试3秒验证正确性”。所有方案均适配API23含官方文档未提及的测试环境配置细节。一、前言为什么单元测试是“高级开发者的标配”在之前的系列里我们实现了电商Demo的所有功能从State状态管理到分布式流转从端云一体化到元服务卡片。但每次修改代码比如调整购物车计算逻辑我都要手动做这些事启动模拟器打开App找到商品详情页点击“加入购物车”回到列表页检查总价是否正确流转到平板再检查一遍这一套流程下来至少要2分钟如果改了10次代码就要重复20分钟。更可怕的是有时候改了A功能不小心把B功能搞坏了——这就是回归bug。单元测试的核心价值就是自动化验证把手动测试的逻辑写成代码改完代码后一键运行几秒钟就能确认所有功能是否正常。对于鸿蒙开发来说单元测试还有两个特殊意义鸿蒙的分布式能力、元服务等特性无法在普通JS环境测试需要专门的测试框架ArkTS的强类型特性非常适合写单测能提前发现类型错误减少运行时崩溃二、核心概念辨析官方没讲透的边界很多新手会把几个测试概念搞混先明确它们的定位和适用场景测试类型测试对象运行环境适用场景执行速度单元测试​单个函数/类如GlobalState本地JVM/JS引擎验证核心逻辑如购物车计算、数据加密⚡️ 毫秒级集成测试​多个模块协作如云函数调用UI更新模拟器/真机验证模块间交互如下单流程、流转逻辑 秒级UI测试​页面交互如点击按钮、页面跳转模拟器/真机验证用户操作流程如从列表到详情的跳转 秒级Mock​外部依赖如云函数、分布式服务本地测试环境模拟不可用的服务如离线状态、服务端异常⚡️ 毫秒级核心认知Mock不是“造假”而是控制变量。比如测试下单逻辑时我们不希望真的调用云函数产生订单也不希望依赖网络环境——Mock就是用“假”的云函数代替“真”的让测试结果可预测、可重复。三、代码实现给电商Demo加单元测试DevEco Studio内置了ohos/hypium测试框架我们不需要额外安装依赖。下面分三步给电商Demo添加测试3.1 第一步创建测试模块右键点击entry模块 →New→Module→Ohos Test ModuleModule Name:entry_testTest Type: 选择Ability Test支持UI测试和单元测试Runner: 选择HypiumTestRunner创建完成后会生成entry_test/src/main/ets/test目录我们的测试代码都放在这里。3.2 第二步测试纯逻辑代码工具类测试我们先测试之前写的GlobalState工具类它的addToCart方法是核心逻辑非常适合写单测。创建entry_test/src/main/ets/test/globalState.test.etsimport { describe, it, expect, beforeAll, afterAll } from ohos/hypium import { GlobalState } from ../../../../entry/src/main/ets/common/GlobalState import { distributedDataObject } from kit.ArkData import { AppStorage } from kit.ArkUI // 测试套件描述要测试的功能模块 describe(GlobalState单元测试, () { // 每个测试用例执行前的初始化 beforeAll(() { // 清空AppStorage避免之前的数据影响测试 AppStorage.clear() // 初始化分布式数据对象测试中用Mock代替真实DDO GlobalState.initMockDDO() }) // 每个测试用例执行后的清理 afterAll(() { AppStorage.clear() }) // 测试用例1测试添加商品到购物车 it(should add product to cart correctly, 0, () { // 1. 初始状态购物车为空 let counts GlobalState.getCartCounts() expect(counts.length).assertEqual(3) // 我们的Demo有3个商品 expect(counts[0]).assertEqual(0) // 2. 执行添加操作给第一个商品加1 GlobalState.updateCartCount(0, 1) // 3. 验证结果第一个商品数量变为1 counts GlobalState.getCartCounts() expect(counts[0]).assertEqual(1) }) // 测试用例2测试减少商品数量不能小于0 it(should not allow negative cart count, 0, () { // 1. 初始状态第一个商品数量为1 GlobalState.updateCartCount(0, 1) let counts GlobalState.getCartCounts() expect(counts[0]).assertEqual(1) // 2. 执行减少操作减到0再减一次 GlobalState.updateCartCount(0, 0) GlobalState.updateCartCount(0, -1) // 尝试减到负数 // 3. 验证结果数量仍为0不会变成-1 counts GlobalState.getCartCounts() expect(counts[0]).assertEqual(0) }) // 测试用例3测试总价计算逻辑 it(should calculate total price correctly, 0, () { // 1. 准备测试数据3个商品价格分别是5999、6999、1299 const goodsList [ { price: 5999 }, { price: 6999 }, { price: 1299 } ] // 2. 设置购物车数量第一个商品2个第二个1个第三个0个 GlobalState.updateCartCount(0, 2) GlobalState.updateCartCount(1, 1) GlobalState.updateCartCount(2, 0) // 3. 验证总价2 * 5999 1 * 6999 0 * 1299 18997 const total GlobalState.calcTotalPrice(goodsList) expect(total).assertEqual(18997) }) })3.3 第三步Mock外部依赖云函数/分布式服务上面的测试用到了GlobalState的真实逻辑但如果我们要测试云函数调用失败或者分布式设备离线的场景就需要Mock外部依赖——总不能真的断网测试吧我们可以在GlobalState中添加一个Mock开关测试时切换为Mock实现修改entry/src/main/ets/common/GlobalState.ets添加Mock逻辑// GlobalState.ets 新增Mock相关代码 export class GlobalState { // Mock开关测试时为true生产环境为false private static isMockMode: boolean false // Mock的云函数返回结果 private static mockCloudResult: any { code: 200, data: { orderId: mock_order_123 } } // 初始化Mock环境仅测试用 static initMockDDO(): void { this.isMockMode true // Mock分布式数据对象不调用真实DDO直接用AppStorage AppStorage.setOrUpdate(cartCounts, [0, 0, 0]) } // 重置为生产环境 static resetToProdMode(): void { this.isMockMode false } // 设置Mock云函数返回结果 static setMockCloudResult(result: any): void { this.mockCloudResult result } // 修改后的云函数调用方法 static async callCloudFunction(funcName: string, params: any): Promiseany { if (this.isMockMode) { // Mock模式直接返回预设结果不调用真实云函数 console.log(Mock调用云函数${funcName}参数, params) return this.mockCloudResult } // 生产模式调用真实云函数 return await CloudUtil.callFunction(funcName, params) } // 修改后的更新购物车方法 static updateCartCount(index: number, count: number): void { const newCounts [...this.getCartCounts()] newCounts[index] Math.max(0, count) if (this.isMockMode) { // Mock模式只更新AppStorage不调用DDO AppStorage.setOrUpdate(cartCounts, newCounts) return } // 生产模式调用真实DDO const ddo this.getDDO() if (ddo) { ddo.set(cartCounts, newCounts) ddo.set(lastModifyTime, Date.now()) } AppStorage.setOrUpdate(cartCounts, newCounts) } }然后编写Mock相关的测试用例创建entry_test/src/main/ets/test/mock.test.etsimport { describe, it, expect, beforeEach } from ohos/hypium import { GlobalState } from ../../../../entry/src/main/ets/common/GlobalState import { CloudUtil } from ../../../../entry/src/main/ets/common/CloudUtil describe(Mock外部依赖测试, () { beforeEach(() { // 每个测试前初始化Mock环境 GlobalState.initMockDDO() }) // 测试1云函数调用失败时的降级逻辑 it(should fallback when cloud function fails, 0, async () { // 1. 设置Mock云函数返回错误 GlobalState.setMockCloudResult({ code: 500, message: Server Error }) // 2. 调用云函数 const result await GlobalState.callCloudFunction(create-order, { goodsId: 1, count: 1 }) // 3. 验证结果返回Mock的错误信息 expect(result.code).assertEqual(500) expect(result.message).assertEqual(Server Error) }) // 测试2分布式设备离线时的降级逻辑 it(should fallback when DDO is offline, 0, () { // 1. Mock模式下DDO不可用验证降级逻辑 const counts GlobalState.getCartCounts() expect(counts.length).assertEqual(3) // 2. 更新购物车验证AppStorage是否更新降级逻辑生效 GlobalState.updateCartCount(0, 1) const newCounts GlobalState.getCartCounts() expect(newCounts[0]).assertEqual(1) }) })3.4 第四步UI组件测试验证渲染逻辑除了逻辑测试我们还可以测试UI组件的渲染结果比如测试商品卡片是否正确显示价格。创建entry_test/src/main/ets/test/ui/goodsItem.test.etsimport { describe, it, expect, beforeAll } from ohos/hypium import { UIContext } from kit.ArkUI import { GoodsItem } from ../../../../entry/src/main/ets/pages/GoodsItem import { AbilityDelegatorRegistry } from kit.TestKit describe(GoodsItem UI组件测试, () { let uiContext: UIContext | null null beforeAll(async () { // 获取UI上下文UI测试必须 const delegator AbilityDelegatorRegistry.getAbilityDelegator() uiContext await delegator.getUIContext() }) // 测试1商品卡片是否正确渲染价格和标题 it(should render product info correctly, 0, async () { if (!uiContext) { expect(true).assertTrue() // 避免无上下文时测试失败 return } // 1. 准备测试数据 const testGoods { id: 1, title: 测试商品, price: 5999 } // 2. 渲染GoodsItem组件 const component new GoodsItem() component.goods testGoods component.pausedId -1 component.count 0 // 3. 验证组件属性ArkUI的组件测试需要等待渲染完成 await uiContext!.waitForIdle() // 验证价格是否正确这里需要根据实际组件结构调整断言逻辑 // 注ArkUI的UI测试目前支持有限复杂断言建议结合ArkUI Inspector手动验证 expect(component.goods.price).assertEqual(5999) expect(component.goods.title).assertEqual(测试商品) }) })四、踩坑记录官方文档没写的8个细节测试环境不能调用真实云函数之前我没加Mock测试时直接调用了真实云函数导致产生了大量测试订单还污染了线上数据。一定要在测试前把isMockMode设为true。State变量的测试技巧测试State变量时不能直接new组件必须通过UIContext获取组件实例否则状态变化不会触发UI更新。UI测试的等待逻辑UI渲染是异步的测试时必须调用uiContext.waitForIdle()等待渲染完成否则断言会失败。测试报告的生成路径测试完成后报告默认生成在entry_test/build/reports/tests/目录下官方文档没说清楚找了半天才找到。分布式服务的Mock限制分布式流转的continuationManager目前无法完全Mock测试时需要真实的两台设备或者用远程模拟器的“虚拟流转”功能。测试环境的权限问题测试模块需要申请和主模块一样的权限否则会报Permission denied错误记得在entry_test/src/main/module.json5中配置权限。测试用例的独立性每个测试用例必须是独立的不能依赖其他用例的执行结果否则会出现“本地跑通CI跑不通”的问题。测试覆盖率的计算DevEco Studio可以生成覆盖率报告但目前只支持TypeScript代码ArkTS的覆盖率统计还不完善建议重点关注核心逻辑如GlobalState的覆盖率。