系列文章《鸿蒙开发从入门到专家》第 45 篇 · 中级
第 19 篇介绍过鸿蒙测试框架的基础用法。这篇往下挖——Mock 怎么做、断言怎么写才有效、覆盖率怎么测才不是自欺欺人。
很多团队的单元测试写了等于没写:跑一个 expect(result).assertEqual(expected) 就算过,覆盖率 90% 但全是 trivial 断言。这种测试唯一的作用是让 CI 变绿,线上崩了照样发现不了。
一、测试目录与依赖配置
鸿蒙的测试代码放在 ohosTest 模块下。在 entry 模块同级创建 ohosTest 目录,结构如下:
entry/
ohosTest/
├── src/
│ ├── test/
│ │ ├── LocalTest.test.ts # 本地测试(不需要设备)
│ │ └── AbilityTest.test.ts # 设备测试(需要真机/模拟器)
│ └── ets/
│ └── pages/
│ └── Index.ets # 测试入口页面
├── build-profile.json5
└── oh-package.json5
依赖配置(ohosTest 的 oh-package.json5):
{
"dependencies": {
"@ohos/hypium": "2.0.0"
}
}
两种测试模式的区别:
二、Mock 策略
单元测试的核心原则是隔离——被测函数的依赖项必须可控。如果你的测试函数调了网络请求、读文件、依赖系统 API,不 Mock 的话测试就成了集成测试,又慢又不稳定。
2.1 依赖注入式 Mock
最干净的 Mock 方式是依赖注入。把依赖作为参数传入,测试时替换为 Mock 实现。
// 被测代码
export class UserService {
constructor(private apiClient: ApiClient) {}
async getUserProfile(userId: string): Promise<UserProfile> {
const response = await this.apiClient.get(`/users/${userId}`);
return response.data;
}
}
// 接口定义
interface ApiClient {
get(url: string): Promise<{ data: UserProfile }>;
}
测试时:
import { describe, it, expect } from '@ohos/hypium';
class MockApiClient implements ApiClient {
public mockResponse: { data: UserProfile };
public calledUrl: string;
async get(url: string): Promise<{ data: UserProfile }> {
this.calledUrl = url;
return this.mockResponse;
}
}
export default function userTest() {
describe('UserService', () => {
it('should_return_user_profile_when_api_succeeds', 0, async () => {
// 准备 Mock
const mockClient = new MockApiClient();
mockClient.mockResponse = {
data: { id: '1', name: 'Test User', email: 'test@example.com' }
};
const service = new UserService(mockClient);
// 执行
const profile = await service.getUserProfile('1');
// 验证
expect(profile.name).assertEqual('Test User');
expect(profile.email).assertEqual('test@example.com');
expect(mockClient.calledUrl).assertEqual('/users/1');
});
});
}
这个模式的好处是 Mock 逻辑清晰可读,不需要任何 Mock 框架。坏处是需要被测代码本身支持依赖注入,对已有代码可能需要重构。
2.2 模块替换式 Mock
对于不方便重构的代码,可以用模块替换。在鸿蒙中,可以通过 import 路径替换来 Mock 模块。
假设有个函数依赖 @kit.AbilityKit 获取上下文:
// config.ts
import { common } from '@kit.AbilityKit';
export function getFilesPath(context: common.UIAbilityContext): string {
return context.filesDir;
}
测试时不想真的创建一个 Ability,可以传入一个符合接口的 Mock 对象:
it('should_return_correct_files_path', 0, () => {
const mockContext = {
filesDir: '/data/app/files'
} as unknown as common.UIAbilityContext;
const result = getFilesPath(mockContext);
expect(result).assertEqual('/data/app/files');
});
2.3 Mock 异步操作
异步函数的 Mock 需要考虑成功和失败两条路径:
// 被测代码
export class DataRepository {
constructor(private apiClient: ApiClient) {}
async fetchAndCache(url: string): Promise<boolean> {
try {
const data = await this.apiClient.get(url);
await this.saveToCache(url, data);
return true;
} catch (error) {
console.error('Fetch failed:', error);
return false;
}
}
async saveToCache(key: string, data: any): Promise<void> {
// 缓存逻辑
}
}
测试成功和失败两种情况:
class FailingMockClient implements ApiClient {
async get(url: string): Promise<any> {
throw new Error('Network timeout');
}
}
class SuccessMockClient implements ApiClient {
async get(url: string): Promise<any> {
return { data: { items: [1, 2, 3] } };
}
}
describe('DataRepository', () => {
it('should_return_true_on_success', 0, async () => {
const repo = new DataRepository(new SuccessMockClient());
const result = await repo.fetchAndCache('/api/data');
expect(result).assertTrue();
});
it('should_return_false_on_network_error', 0, async () => {
const repo = new DataRepository(new FailingMockClient());
const result = await repo.fetchAndCache('/api/data');
expect(result).assertFalse();
});
});
三、断言策略:什么值得断言
3.1 断言层级
写好断言的关键是知道在哪个层级断言。不是所有东西都需要 assertEqual。
3.2 好断言 vs 差断言
// 差:只验证"没崩溃"
it('should_parse_json', 0, () => {
const result = parseJson('{"name":"test"}');
expect(result).assertLarger(0); // 这测的是什么?对象属性数?
});
// 好:验证具体值
it('should_parse_json_correctly', 0, () => {
const result = parseJson('{"name":"test"}');
expect(result.name).assertEqual('test');
expect(typeof result.name).assertEqual('string');
});
// 差:断言太多无关内容
it('should_validate_email', 0, () => {
const result = validateEmail('test@example.com');
expect(result).assertTrue();
expect(result === true).assertTrue(); // 重复
expect(typeof result).assertEqual('boolean'); // 类型检查不应该是这个测试的重点
});
// 好:聚焦行为
it('should_accept_valid_email', 0, () => {
expect(validateEmail('user@domain.com')).assertTrue();
expect(validateEmail('user.name@domain.co')).assertTrue();
});
it('should_reject_invalid_email', 0, () => {
expect(validateEmail('not-an-email')).assertFalse();
expect(validateEmail('')).assertFalse();
expect(validateEmail('@domain.com')).assertFalse();
});
3.3 测试边界条件
好的测试用例覆盖三类输入:正常值、边界值、非法值。
describe('clamp', () => {
// 正常值
it('should_return_value_within_range', 0, () => {
expect(clamp(5, 0, 10)).assertEqual(5);
});
// 边界值
it('should_return_min_when_below_range', 0, () => {
expect(clamp(-1, 0, 10)).assertEqual(0);
});
it('should_return_max_when_above_range', 0, () => {
expect(clamp(11, 0, 10)).assertEqual(10);
});
// 非法值
it('should_handle_min_greater_than_max', 0, () => {
// 如果设计是交换 min 和 max
expect(clamp(5, 10, 0)).assertEqual(5);
});
});
四、覆盖率
4.1 三种覆盖率
行覆盖率高不代表测试质量高。一段代码 100% 行覆盖,但 if 的 true 分支只走了一次,false 分支没走,这个测试还是不完整。分支覆盖率更能反映测试有效性。
4.2 在鸿蒙中查看覆盖率
DevEco Studio 运行测试后会生成覆盖率报告。在测试结果面板中查看,或查看 build 目录下的 coverage 文件夹。
HTML 报告会标注每行代码的执行次数:
✓ 1: function clamp(value, min, max) {
✓ 1: if (min > max) {
✓ 1: [min, max] = [max, min];
}
✓ 1: return Math.max(min, Math.min(max, value));
}
4.3 覆盖率的陷阱
覆盖率 80% 看起来不错,但如果那 80% 全是 trivial 断言(只验证不崩溃),剩下的 20% 才是真正的业务逻辑,那这个覆盖率数字没有意义。
一个更实际的指标:关键路径覆盖率。把核心业务逻辑的函数列出来,检查每个函数的关键分支是否都有测试用例覆盖。这比全局覆盖率数字有价值得多。
五、测试组织建议
5.1 命名规范
测试用例名描述"做什么 + 期望结果",用下划线分隔:
should_return_token_when_authenticated
should_throw_error_when_token_expired
should_return_empty_list_when_no_data
should_retry_3_times_on_timeout
这种命名在 CI 失败报告里一眼看出什么功能出了问题。
5.2 测试粒度
函数级测试最多最快。集成级测试最慢最脆弱,不应该占大头。
小结
单元测试写得好不好,看三点:Mock 是否到位(被测函数的依赖是否可控)、断言是否有效(是不是只在验证"没崩溃")、边界是否覆盖(正常值/边界值/非法值都测了没)。
覆盖率数字是参考不是目标。关键路径的分支覆盖率比全局行覆盖率有说服力。
下一篇讲 UI 自动化测试,用 Hypium 框架做界面级别的自动化验证。