引言
鸿蒙工程的依赖与测试体系由两块组成:ohpm 负责「代码怎么被组织与复用」,Hypium 负责「代码怎么被验证」。两者看似无关,实际上紧密耦合——包形态的选择直接决定了测试能不能跑、覆盖率能不能统计、依赖方能不能拿到你的测试工具类。
工程上的难点在包形态与依赖边界。HAR 是静态包,代码会被编进使用方的产物;HSP 是动态包,运行时共享一份实例。选错了要么包体积暴涨,要么出现「两个模块各持有一个单例」的诡异 bug。而依赖的版本约束写错,则会在多模块协作时出现「本地能编译、CI 上失败」。
本文按「包管理、包形态、测试、覆盖率」四条线展开。工程结构与模块划分见 HarmonyOS NEXT 全景与开发环境搭建 ,本文不重复工程创建部分。
目录
- 包管理在鸿蒙工程中的位置
- ohpm 命令行基础
- oh-package.json5 字段详解
- 版本约束语法
- 依赖类型与作用域
- HAR 静态共享包
- HSP 动态共享包
- HAR 与 HSP 选型
- 私仓与 ohpmrc 配置
- Hypium 单元测试
- UI 测试与依赖打桩
- 覆盖率与测试运行配置
- 权衡取舍
- 常见坑清单
- 小结
1. 包管理在鸿蒙工程中的位置
鸿蒙工程是「一个工程多个模块」的结构,模块之间通过包依赖组织。
| 层级 | 配置文件 | 职责 |
|---|---|---|
| 工程级 | 根目录 oh-package.json5 | 声明全局依赖与共享的版本约束 |
| 模块级 | 各模块 oh-package.json5 | 声明该模块自己的依赖 |
| 锁定文件 | oh-package-lock.json5 | 记录实际解析到的版本与校验值 |
| 仓库配置 | .ohpmrc | 指定仓库地址、认证信息、代理 |
一条硬规则:oh-package-lock.json5 必须提交到版本库。它保证团队与 CI 解析出完全相同的依赖树,不提交的话,别人拉下代码后 ohpm install 会拿到最新匹配版本,出现「我这能跑你那不能跑」的经典问题。
模块划分上,推荐按「entry 壳工程 + feature 业务模块 + common 公共模块」三层组织。entry 只放入口 Ability 与路由表,业务逻辑全部下沉到 feature 模块,跨模块复用的工具与类型放 common。这样做的收益在测试环节体现得最明显:feature 模块可以独立跑单元测试,不需要拉起整个应用。
2. ohpm 命令行基础
ohpm 是鸿蒙的包管理器,随 DevEco Studio 一起安装,也可以在 CI 上单独部署。
ohpm -v # 查看版本
ohpm install # 按 oh-package.json5 安装全部依赖
ohpm install @ohos/lottie # 安装指定三方库
ohpm install ./libs/mylib.har # 安装本地 HAR 包
ohpm update # 按版本约束升级依赖
ohpm uninstall @ohos/lottie # 卸载
ohpm list # 列出当前依赖树
ohpm publish ./mylib.har # 发布到私仓
ohpm install ./libs/mylib.har 这种本地安装方式在调试自研库时非常实用:改完库代码后重新打包、重新安装,比走私仓发布快得多。安装后依赖会记录在 oh-package.json5 里,路径形式依赖不会进 oh-package-lock.json5,因此本地路径依赖不能进发布流程。
CI 环境上推荐用 ohpm install --all 一次装齐所有模块的依赖,而不是逐个模块 cd 进去装。同时要设置 OHPM_HOME 环境变量指定缓存目录,否则每次构建都会重新下载。
3. oh-package.json5 字段详解
oh-package.json5 是依赖声明的核心文件,字段不多但每个都有明确语义。
{
"name": "feature_reader",
"version": "1.0.0",
"description": "阅读器业务模块",
"main": "Index.ets",
"author": "Leeting Yan",
"license": "Apache-2.0",
"dependencies": {
"@ohos/lottie": "^2.0.0",
"common_utils": "file:../common"
},
"devDependencies": {
"@ohos/hypium": "1.0.19",
"@ohos/hamock": "1.0.0"
},
"dynamicDependencies": {
"feature_live": "^1.0.0"
}
}
| 字段 | 作用 | 注意点 |
|---|---|---|
| name | 包名,模块间引用靠它 | 改名会让所有引用方编译失败 |
| version | 包版本 | 发布 HAR 时必须递增 |
| main | 对外导出入口文件 | 不写则默认 Index.ets,路径错误会让 import 拿到空对象 |
| dependencies | 运行时依赖 | 会被打进产物 |
| devDependencies | 开发期依赖 | 不参与打包,测试框架放这里 |
| dynamicDependencies | 动态依赖 | 仅 HSP 支持,运行时按需加载 |
main 字段是最容易出问题的一个。它的路径是相对包根目录的,写 Index.ets 与 ./Index.ets 都可以,但写 src/Index.ets 就必须保证文件真的在那个位置。配置错误的表现是 import { X } from 'feature_reader' 编译通过但运行时 X 为 undefined,而且 IDE 通常不给提示。
4. 版本约束语法
ohpm 的版本约束沿用语义化版本规范,三种写法的语义差别很大。
| 写法 | 含义 | 允许升级到 | 建议场景 |
|---|---|---|---|
| 1.0.19 | 精确版本 | 不升级 | 测试框架、工具链 |
| ^1.0.19 | 兼容版本 | 1.x 的最高版 | 多数三方库 |
| ~1.0.19 | 补丁版本 | 1.0.x 的最高版 | 对次版本变更敏感时 |
| 1.x | 主版本内任意 | 1.x 的最高版 | 语义化版本严格遵循时 |
| * | 任意版本 | 最新 | 仅原型验证,不要进生产 |
选择原则是「库越底层,约束越紧」。测试框架、构建工具这类对版本敏感的依赖用精确版本,避免 CI 上因为自动升级而出现难以复现的失败;业务三方库可以用 ^,减少手动升级的频率。
一个具体的坑:^1.0.19 在库作者没有严格遵守语义化版本时会带来破坏性升级。判断依据是看这个库的主版本号是否已经稳定(大于等于 1)。主版本为 0 的库用 ^ 风险很高,因为 0.x 系列允许任意破坏性变更,此时应该用精确版本或 ~。
5. 依赖类型与作用域
三种依赖类型决定了「代码是否进入产物」与「何时被加载」。
{
"dependencies": {
"@ohos/lottie": "^2.0.0"
},
"devDependencies": {
"@ohos/hypium": "1.0.19",
"@ohos/hamock": "1.0.0"
},
"dynamicDependencies": {
"feature_live": "^1.0.0"
}
}
| 类型 | 打包行为 | 加载时机 | 典型内容 |
|---|---|---|---|
| dependencies | 进入产物 | 应用启动即可用 | 业务三方库、公共模块 |
| devDependencies | 不进产物 | 仅构建与测试期 | Hypium、Hamock、lint 工具 |
| dynamicDependencies | 不进主包 | 运行时按需加载 | 低频功能模块、大体积模块 |
把测试框架写进 dependencies 是高频错误:它会让 Hypium 被打进发布包,既增加体积又暴露测试代码。正确的做法是 @ohos/hypium 与 @ohos/hamock 一律放 devDependencies。
dynamicDependencies 只对 HSP 生效,它的收益是把低频模块从主包里摘出去,代价是首次加载有延迟。判断标准是「这个模块在首屏会不会用到」:会用到就不要动态化,用不到才考虑。
6. HAR 静态共享包
HAR(Harmony Archive)是静态共享包,本质上是一份可被编译进使用方的源码与资源集合。
// 库模块的 build-profile.json5
{
"apiType": "stageMode",
"buildOption": {},
"targets": [
{
"name": "default",
"applyToProducts": ["default"]
}
]
}
HAR 的特点是编译期内联:使用方在编译时把 HAR 的代码合并进自己的产物,因此运行时只有一份代码副本在各自模块里。这带来两个直接后果:其一,HAR 里的全局单例在每个使用方那里都是独立的,跨模块共享状态会失效;其二,HAR 的代码会被重复打包,多个模块依赖同一个 HAR 会让包体积成倍增长。
HAR 里可以放 ArkTS 代码、资源文件、Native 库(.so),也可以放测试代码。HAR 的对外 API 必须提供 consumer-rules.txt,否则使用方开启混淆后调用你的接口会因为类名被改写而失败。
7. HSP 动态共享包
HSP(Harmony Shared Package)是动态共享包,运行时在多个模块之间共享同一份代码实例。
// HSP 模块的 module.json5 关键字段
{
"module": {
"name": "shared_common",
"type": "shared",
"description": "$string:shared_desc"
}
}
HSP 的核心价值是单例共享:状态管理、网络客户端、数据库连接这类需要全局唯一的对象放在 HSP 里,所有模块拿到的是同一个实例。这与 HAR 的行为完全不同,也是选择 HSP 最主要的理由。
代价也很明确:HSP 必须随应用一起安装,不能单独更新;它的加载有初始化开销,首次调用会触发模块加载;HSP 之间不能循环依赖,依赖关系必须是有向无环图。HSP 的版本必须与应用版本同步发布,因为它不能独立升级。
// 使用 HSP 中的单例
import { GlobalStore } from 'shared_common';
// 所有模块拿到的是同一个实例
const store = GlobalStore.getInstance();
8. HAR 与 HSP 选型
两种包形态的差异集中在「是否共享实例」与「体积成本」上。
| 维度 | HAR | HSP |
|---|---|---|
| 打包方式 | 编译期内联 | 运行期加载 |
| 实例数量 | 每个使用方一份 | 全局一份 |
| 包体积 | 多模块依赖时成倍增长 | 只存一份 |
| 能否独立更新 | 随宿主更新 | 不能,随应用更新 |
| 能否含 Native 库 | 可以 | 可以 |
| 动态依赖支持 | 不支持 | 支持 |
| 适用内容 | 工具函数、UI 组件、纯逻辑 | 全局单例、大体积公共模块 |
选型判断只有两条:需要跨模块共享状态就用 HSP,其余一律用 HAR。UI 组件库、工具函数、类型定义这些无状态的代码用 HAR 更简单,不需要关心加载时序;而网络客户端、状态容器、数据库封装这类有状态对象必须用 HSP,否则每个模块一个实例会导致数据不一致。
一个常见的误判是把「体积大」当成选 HSP 的理由。体积优化的正确手段是 dynamicDependencies 加低频模块拆分,而不是把所有公共代码都塞进 HSP——HSP 自身的加载开销与依赖约束会带来新的复杂度。
9. 私仓与 ohpmrc 配置
三方库默认从 ohpm 官方仓库拉取,企业内网环境或自研库需要配置私仓。
registry=https://ohpm.openharmony.cn/ohpm/
@mycompany:registry=https://ohpm.mycompany.com/repo/
strict_ssl=true
cache=~/.ohpm/cache
@mycompany:registry 这种带作用域的前缀写法可以只把某个 scope 的包指向私仓,其余仍走官方仓库,这是混合使用的最佳实践。strict_ssl=true 建议保持开启,关闭会带来中间人风险。
认证信息不要写进 .ohpmrc 并提交到版本库,正确做法是通过环境变量注入:
export OHPM_AUTH_TOKEN="$(cat /run/secrets/ohpm_token)"
ohpm install --all
CI 上尤其要注意这一点。把 token 写进配置文件并提交,等同于把私仓的读写权限公开,而撤销 token 需要重新配置所有构建节点。
10. Hypium 单元测试
Hypium 是鸿蒙的测试框架,API 风格与主流的 xUnit 接近,用 describe 组织用例、it 定义单个用例、expect 做断言。
import { describe, it, expect, beforeAll, beforeEach } from '@ohos/hypium';
export default function readerTest() {
describe('ReadingProgress', () => {
let calculator: ProgressCalculator;
beforeAll(() => {
calculator = new ProgressCalculator();
});
beforeEach(() => {
calculator.reset();
});
it('should_return_zero_for_empty_input', 0, () => {
expect(calculator.percent('')).assertEqual(0);
});
it('should_clamp_percent_to_100', 0, () => {
calculator.update(120, 100);
expect(calculator.percent()).assertEqual(100);
});
it('should_handle_async_load', 0, async (done: Function) => {
const result = await calculator.loadFromCache('book_1');
expect(result).assertTrue();
done();
});
});
}
it 的第二个参数是过滤级别(0 表示必跑),第三个参数是用例体。异步用例必须显式接收 done 回调并调用它,否则测试框架会认为用例未结束,表现为「测试一直挂起直到超时」。
Hypium 的断言方法比较丰富,选对断言能让失败信息更可读:
| 断言 | 语义 | 适用 |
|---|---|---|
| assertEqual | 严格相等 | 数值与字符串比较 |
| assertTrue / assertFalse | 布尔判断 | 条件校验 |
| assertNull / assertUndefined | 空值判断 | 可选值校验 |
| assertThrowError | 断言抛异常 | 异常路径覆盖 |
| assertClose | 浮点近似相等 | 浮点计算 |
| assertInstanceOf | 类型判断 | 多态场景 |
浮点比较不要用 assertEqual,用 assertClose 指定误差范围,否则会因为精度问题出现偶发失败。
11. UI 测试与依赖打桩
UI 测试通过 @ohos.UiTest 驱动真实界面,适合验证跨组件的交互流程。
import { describe, it, expect } from '@ohos/hypium';
import { Driver, ON } from '@ohos.UiTest';
export default function uiTest() {
describe('ReaderFlow', () => {
it('should_open_detail_on_click', 0, async () => {
const driver = Driver.create();
await driver.delayMs(1000);
// 通过文本定位控件并点击
await driver.assertComponentExist(ON.text('打开书籍'));
const button = await driver.findComponent(ON.text('打开书籍'));
await button.click();
await driver.delayMs(500);
await driver.assertComponentExist(ON.text('目录'));
});
});
}
UI 测试的三条纪律:控件定位优先用稳定标识(ON.id 优于 ON.text,因为文案会变);每次交互后加等待,UI 渲染不是同步的;用例之间要互相独立,不能依赖上一个用例留下的状态。
依赖打桩用 Hamock 完成,它的典型用途是把网络请求与数据库替换成可控的假实现:
import { MockKit, when } from '@ohos/hamock';
const mock = new MockKit();
const httpMock = mock.mockFunc(HttpUtil, 'get');
when(httpMock).apply('articles').afterReturn(Promise.resolve('[]'));
打桩的前提是被测代码的依赖可以被替换。这反过来要求业务代码做依赖注入——如果 Repository 内部直接 new HttpUtil(),就没法替换。可测试性是设计出来的,不是测出来的,这一点与 前端测试策略
里讲的结论一致:把依赖从内部构造改为外部传入,是提升可测性收益最大的重构。
12. 覆盖率与测试运行配置
测试的运行入口在模块的 build-profile.json5 中配置,DevEco Studio 会读取它生成测试任务。
{
"targets": [
{
"name": "default",
"applyToProducts": ["default"],
"runtimeOS": "HarmonyOS"
}
]
}
覆盖率统计在 DevEco 的测试面板里开启,它会按文件粒度给出行覆盖率与分支覆盖率。几条实践建议:
- 不要追求 100% 覆盖率,把目标定在核心业务逻辑的行覆盖率达到 70% 以上即可,UI 胶水代码的覆盖率意义不大。
- 重点覆盖分支而不是行数,一个
if的两个分支都跑过才算真正验证了逻辑。 - 把测试命令接进 CI,用
hvigorw test触发,失败即阻断合并,否则测试会在两周内腐化。
hvigorw --mode module -p module=feature_reader@default test \
--coverage --coverage-output ./reports
覆盖率报告要作为构建产物归档,而不是只在本地看。覆盖率是趋势指标而不是门禁指标,它的价值在于「这次改动让覆盖率掉了多少」,而不是「有没有达到 80%」。把它当门禁会催生大量无断言的凑数用例,反而降低测试质量。
权衡取舍
包管理与测试的取舍集中在「复用便利」与「构建复杂度」之间。
| 决策点 | 方案 A | 方案 B | 建议 |
|---|---|---|---|
| 包形态 | HAR(静态内联) | HSP(动态共享) | 无状态用 A,全局单例用 B |
| 版本约束 | 精确版本 | ^ 兼容版本 | 工具链用 A,业务库用 B |
| 依赖安装 | 私仓发布 | 本地路径依赖 | 开发期用 B,进主干前换 A |
| 测试范围 | 全量覆盖 | 核心逻辑覆盖 | 优先 B,覆盖率当趋势看 |
| 大体积模块 | 常驻主包 | dynamicDependencies | 首屏不用才动态化 |
一个容易被低估的成本是构建时间。HAR 越多,编译期需要处理的代码就越多;HSP 越多,运行期的模块加载次数就越多。团队规模较小时,模块拆分的收益往往抵不过构建变慢的成本,建议在模块数量超过十个之后再认真做包形态的划分。
常见坑清单
oh-package-lock.json5未提交。 团队与 CI 解析出不同依赖树,出现「本地能跑 CI 挂」。- 测试框架写进
dependencies。 Hypium 被打进发布包,体积增加且暴露测试代码。 main字段路径错误。import编译通过但运行时拿到 undefined,IDE 通常不提示。- 本地路径依赖进了主干。
file:../common在 CI 上路径不存在,构建直接失败。 - 主版本为 0 的库用
^。 允许任意破坏性升级,一次ohpm update就可能编译失败。 - HAR 里的单例被多个模块依赖。 每个模块拿到独立实例,状态不共享且难以排查。
- HSP 之间循环依赖。 依赖关系必须是有向无环图,环会导致加载失败。
- HAR 未提供
consumer-rules.txt。 使用方开混淆后调用接口失败,仅发布包复现。 - 认证 token 写进
.ohpmrc并提交。 等同于公开私仓读写权限。 - 异步用例未调用
done()。 测试挂起直到超时,误判为用例执行慢。 - 浮点比较用
assertEqual。 精度问题导致偶发失败,改用assertClose。 - UI 测试用文案定位控件。 文案一改用例全挂,应优先用
ON.id。
小结
包管理与测试在鸿蒙工程里是一体的:包形态决定了依赖边界,依赖边界决定了可测性。记住三条选择原则:需要跨模块共享状态用 HSP、其余用 HAR;工具链依赖用精确版本、业务库用 ^;测试框架一律放 devDependencies。测试侧则记住三条纪律:异步用例必须调 done()、浮点比较用 assertClose、覆盖率当趋势指标而不是门禁指标。
最后一条工程建议:把「可测试性」写进代码评审的检查项。依赖是否可以从外部注入、纯逻辑是否与 UI 解耦、模块边界是否清晰,这三条决定了单元测试能不能写、写起来贵不贵。测试写不下去,通常不是测试框架的问题,而是设计的问题。想继续了解模块划分与工程结构,可以回看 HarmonyOS NEXT 全景与开发环境搭建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。