系列工程化进阶篇。上篇讲完调试技巧,有读者问:“每次改完代码都要手动点一遍所有页面,怕改出新bug,有没有自动化的验证方法?” 这恰恰是工程化开发的核心——单元测试。很多新手觉得测试是“额外工作”,但企业级开发中,70%的线上bug都是回归测试没做到位导致的。本文将基于我们写了18篇的电商Demo,拆解HarmonyOS 6.1的测试体系:从工具类的纯逻辑测试,到UI组件的渲染测试,再到Mock分布式服务、云函数等外部依赖,最终实现“改一行代码,跑一遍测试,3秒验证正确性”。所有方案均适配API23,含官方文档未提及的测试环境配置细节。


一、前言:为什么单元测试是“高级开发者的标配”?

在之前的系列里,我们实现了电商Demo的所有功能:从@State状态管理到分布式流转,从端云一体化到元服务卡片。但每次修改代码(比如调整购物车计算逻辑),我都要手动做这些事:

  1. 启动模拟器,打开App

  2. 找到商品详情页,点击“加入购物车”

  3. 回到列表页,检查总价是否正确

  4. 流转到平板,再检查一遍

这一套流程下来至少要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模块 → NewModuleOhos Test Module

  • Module Name: entry_test

  • Test Type: 选择Ability Test(支持UI测试和单元测试)

  • Runner: 选择HypiumTestRunner

创建完成后,会生成entry_test/src/main/ets/test目录,我们的测试代码都放在这里。


3.2 第二步:测试纯逻辑代码(工具类测试)

我们先测试之前写的GlobalState工具类,它的addToCart方法是核心逻辑,非常适合写单测。

创建entry_test/src/main/ets/test/globalState.test.ets



import { 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): Promise<any> {
    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.ets



import { 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.ets



import { 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个细节)

  1. 测试环境不能调用真实云函数:之前我没加Mock,测试时直接调用了真实云函数,导致产生了大量测试订单,还污染了线上数据。一定要在测试前把isMockMode设为true

  2. @State变量的测试技巧:测试@State变量时,不能直接new组件,必须通过UIContext获取组件实例,否则状态变化不会触发UI更新。

  3. UI测试的等待逻辑:UI渲染是异步的,测试时必须调用uiContext.waitForIdle()等待渲染完成,否则断言会失败。

  4. 测试报告的生成路径:测试完成后,报告默认生成在entry_test/build/reports/tests/目录下,官方文档没说清楚,找了半天才找到。

  5. 分布式服务的Mock限制:分布式流转的continuationManager目前无法完全Mock,测试时需要真实的两台设备,或者用远程模拟器的“虚拟流转”功能。

  6. 测试环境的权限问题:测试模块需要申请和主模块一样的权限,否则会报Permission denied错误,记得在entry_test/src/main/module.json5中配置权限。

  7. 测试用例的独立性:每个测试用例必须是独立的,不能依赖其他用例的执行结果,否则会出现“本地跑通,CI跑不通”的问题。

  8. 测试覆盖率的计算:DevEco Studio可以生成覆盖率报告,但目前只支持TypeScript代码,ArkTS的覆盖率统计还不完善,建议重点关注核心逻辑(如GlobalState)的覆盖率。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐