欢迎光临
我们一直在努力

HarmonyOS应用<节气通>开发第45篇:单元测试——构建质量保障体系

在这里插入图片描述

引言

单元测试是软件质量的基石。在节气通这类知识类应用中,核心业务逻辑(如农历计算、节气判定、日期格式化、数据过滤排序等)的正确性直接影响用户体验。HarmonyOS 提供了 @ohos/hypium 测试框架,支持本地单元测试(JVM)和设备端测试(真机/模拟器),配合 ArkTS 的类型系统,可以编写出高质量、可维护的测试代码。

一个完善的测试体系应该具备以下特点:

  • 覆盖全面:核心业务逻辑、工具函数、数据处理函数都有对应测试
  • 分层清晰:单元测试(纯逻辑)与集成测试(组件/服务)分离
  • 可维护:测试代码结构清晰,新增功能时容易补充测试
  • 执行高效:测试运行快速,适合在CI中频繁执行
  • Mock隔离:外部依赖(网络、存储、系统API)可被模拟替换

本文为实战总结版本,基于 @ohos/hypium 框架,涵盖测试框架搭建、纯函数测试、Service层Mock测试、以及测试最佳实践。


学习目标

完成本文后,你将能够:

  • ✅ 理解 HarmonyOS 测试架构(本地测试 vs 设备端测试)
  • ✅ 使用 hypium 编写和运行单元测试
  • ✅ 对纯函数/工具类编写高覆盖率测试
  • ✅ 对 Service 层进行 Mock 隔离测试
  • ✅ 使用断言库验证各种条件
  • ✅ 构建项目级测试目录结构
  • ✅ 在 CI 流水线中集成自动化测试

需求分析

测试层次设计

层次测试对象执行环境速度示例
单元测试 工具函数、数据模型转换 JVM(本地) <1s 农历转公历、日期格式化
Service测试 业务逻辑(需Mock外部依赖) JVM / 设备 1-5s 节气查询、缓存读写
组件测试 UI组件渲染与交互 设备(真机/模拟器) 5-30s 列表展示、页面跳转
集成测试 完整功能流程 设备 10-60s 从启动到查看详情

项目中的测试重点模块

模块测试重点复杂度
DateUtils 日期格式化、时间差计算、节气日期判定 中
SolarTermModel 节气数据校验、属性默认值 低
I18nService 语言切换、字符串获取、回退逻辑 高(需Mock)
ThemeService 主题切换、颜色获取、持久化 中(需Mock)
NotificationService 权限检查、通知构造、发送逻辑 高(需Mock)
MarkdownParser MD→HTML转换、边界情况 中

测试覆盖目标

类型目标覆盖率说明
核心工具函数 ≥90% DateUtils、StringUtils 等
数据模型 ≥80% 校验逻辑、默认值处理
Service业务逻辑 ≥70% Mock外部依赖后测试核心路径
UI组件 关键路径 不追求行覆盖率,关注交互正确性

核心实现

步骤1: 测试框架搭建 —— 目录结构

entry/src/
├── test/ # 本地单元测试 (JVM)
│ └── LocalUnit.test.ets # 本地测试入口
│
├── ohosTest/ # 设备端测试
│ ├── module.json5 # 测试模块配置
│ └── ets/
│ └── test/
│ ├── TestRunner.ets # 测试运行入口
│ ├── Ability.test.ets # Ability测试
│ ├── List.test.ets # 列表组件测试
│ └── services/ # Service层测试
│ ├── I18nService.test.ets
│ └── ThemeService.test.ets

module.json5 配置

// entry/src/ohosTest/module.json5
{
"module": {
"name": "entry_test",
"type": "feature",
"deviceTypes": ["phone", "tablet"],
"installationFree": false,
"deliveryWithInstall": true,
"virtualMachine": "ark0.0.0"
}
}

TestRunner 入口

// entry/src/ohosTest/ets/test/TestRunner.ets
import hilog from '@ohos.hilog';
import TestRunner from '@ohos.application.testRunner';
import AbilityDelegatorRegistry from '@ohos.application.abilityDelegatorRegistry';

export default class OpenHarmonyTestRunner implements TestRunner {
constructor() {}

onPrepare(): void {
hilog.info(0x0000, 'testTag', 'OpenHarmonyTestRunner onPrepare');
}

async onRun(): Promise<void> {
hilog.info(0x0000, 'testTag', 'OpenHarmonyTestRunner onRun start');
const delegator = AbilityDelegatorRegistry.getAbilityDelegator();
await delegator.executeAbilityTest(
'com.example.jieqitong.TestAbility',
null,
(err: string, msg: string) => {
hilog.info(0x0000, 'testTag', `executeAbilityTest err: ${JSON.stringify(err)} msg: ${msg}`);
}
);
hilog.info(0x0000, 'testTag', 'OpenHarmonyTestRunner onRun end');
}
}


步骤2: 纯函数单元测试 —— 工具类测试

纯函数是单元测试的最佳对象:输入确定则输出确定,无副作用,无需Mock。

DateUtils 测试

// entry/src/test/utils/DateUtils.test.ets

import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium';

// 导入被测模块(实际项目中按路径导入)
// import { DateUtils } from '../../main/ets/utils/DateUtils';

export default function dateUtilsTest() {
describe('DateUtils', () => {

// ====== 格式化测试 ======

describe('formatDate', () => {

it('应将Date格式化为YYYY-MM-DD', 0, () => {
const date = new Date(2026, 0, 15); // 2026-01-15
// const result = DateUtils.formatDate(date);
// expect(result).assertEqual('2026-01-15');

// 示例:直接验证格式化逻辑
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
expect(`${year}–${month}–${day}`).assertEqual('2026-01-15');
});

it('应正确处理个位数的月份和日期', 0, () => {
const date = new Date(2026, 2, 3); // 2026-03-03
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
expect(month).assertEqual('03');
expect(day).assertEqual('03');
});

it('应处理闰年2月29日', 0, () => {
const isLeapYear = (year: number): boolean =>
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;

expect(isLeapYear(2024)).assertTrue();
expect(isLeapYear(2026)).assertFalse();
expect(isLeapYear(2000)).assertTrue();
});
});

// ====== 时间差计算测试 ======

describe('daysBetween', () => {

it('同一天应为0天', 0, () => {
const d1 = new Date(2026, 0, 1);
const d2 = new Date(2026, 0, 1);
const diff = Math.floor((d2.getTime() – d1.getTime()) / (1000 * 60 * 60 * 24));
expect(diff).assertEqual(0);
});

it('相邻两天应为1天', 0, () => {
const d1 = new Date(2026, 0, 1);
const d2 = new Date(2026, 0, 2);
const diff = Math.floor((d2.getTime() – d1.getTime()) / (1000 * 60 * 60 * 24));
expect(diff).assertEqual(1);
});

it('跨年计算应正确', 0, () => {
const d1 = new Date(2025, 11, 31); // 2025-12-31
const d2 = new Date(2026, 0, 1); // 2026-01-01
const diff = Math.floor((d2.getTime() – d1.getTime()) / (1000 * 60 * 60 * 24));
expect(diff).assertEqual(1);
});
});

// ====== 节气日期相关 ======

describe('getSolarTermInfo', () => {

it('立春应在2月3-5日之间', 0, () => {
// 立春通常在2月3日~5日
const lichunDays = [3, 4, 5];
expect(lichunDays.includes(3)).assertTrue();
expect(lichunDays.includes(5)).assertTrue();
});

it('24个节气名称列表应完整', 0, () => {
const solarTerms = [
'立春','雨水','惊蛰','春分','清明','谷雨',
'立夏','小满','芒种','夏至','小暑','大暑',
'立秋','处暑','白露','秋分','寒露','霜降',
'立冬','小雪','大雪','冬至','小寒','大寒'
];
expect(solarTerms.length).assertEqual(24);
expect(solarTerms[0]).assertEqual('立春');
expect(solarTerms[23]).assertEqual('大寒');
});
});
});
}

StringUtils 测试

// entry/src/test/utils/StringUtils.test.ets

import { describe, it, expect } from '@ohos/hypium';

export default function stringUtilsTest() {
describe('StringUtils', () => {

describe('truncate', () => {

it('长度不足时不截断', 0, () => {
const text = '你好世界';
expect(text.length <= 10).assertTrue(); // 未超过最大长度
});

it('超出时应截断并添加省略号', 0, () => {
const text = '这是一段很长的文本内容用于测试截断功能是否正常工作';
const maxLen = 10;
if (text.length > maxLen) {
const result = text.substring(0, maxLen) + '…';
expect(result.length).assertLessThanOrEqual(maxLen + 3);
}
});
});

describe('isEmpty', () => {

it('空字符串返回true', 0, () => {
const isEmpty = (s: string | null | undefined): boolean =>
s === null || s === undefined || s.trim().length === 0;
expect(isEmpty('')).assertTrue();
expect(isEmpty(' ')).assertTrue();
expect(isEmpty(null as any)).assertTrue();
});

it('非空字符串返回false', 0, () => {
const isEmpty = (s: string): boolean => s.trim().length === 0;
expect(isEmpty('hello')).assertFalse();
});
});
});
}


步骤3: MarkdownParser 测试 —— 边界情况全覆盖

Markdown 解析器的测试非常适合验证各种边界情况。

// entry/src/test/utils/MarkdownParser.test.ets

import { describe, it, expect } from '@ohos/hypium';

/**
* 简化的 MarkdownParser(仅用于测试示例)
* 实际项目中从 utils/MarkdownParser.ts 导入
*/

class TestMarkdownParser {
static parse(markdown: string): string {
if (!markdown) return '';
let html = markdown;

// 标题
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
html = html.replace(/^## (.+)$/gm, '<h2>$2</h2>');

// 粗体
html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');

// 斜体
html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>');

// 行内代码
html = html.replace(/`([^`\\n]+)`/g, '<code>$1</code>');

// 链接
html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2">$1</a>');

// 无序列表
html = html.replace(/^[\\-\\*] (.+)$/gm, '<li>$1</li>');

// 分割线
html = html.replace(/^—+$/gm, '<hr/>');

// 段落
html = html.split('\\n\\n').map((block: string) => {
block = block.trim();
if (!block) return '';
if (/^<(h[1-6]|ul|ol|li|pre|hr)/i.test(block)) return block;
return `<p>${block}</p>`;
}).join('\\n');

return html;
}
}

export default function markdownParserTest() {
describe('MarkdownParser', () => {

describe('标题解析', () => {

it('一级标题转换为h1', 0, () => {
const result = TestMarkdownParser.parse('# 你好世界');
expect(result).assertContain('<h1>你好世界</h1>');
});

it('二级标题转换为h2', 0, () => {
const result = TestMarkdownParser.parse('## 二级标题');
expect(result).assertContain('<h2>二级标题</h2>');
});

it('多级标题共存时分别转换', 0, () => {
const md = '# 主标题\\n\\n## 副标题';
const result = TestMarkdownParser.parse(md);
expect(result).assertContain('<h1>主标题</h1>');
expect(result).assertContain('<h2>副标题</h2>');
});
});

describe('粗体和斜体', () => {

it('双星号包裹转为strong', 0, () => {
const result = TestMarkdownParser.parse('这是**粗体**文字');
expect(result).assertContain('<strong>粗体</strong>');
});

it('单星号包裹转为em', 0, () => {
const result = TestMarkdownParser.parse('这是*斜体*文字');
expect(result).assertContain('<em>斜体</em>');
});

it('不匹配的单星号不应被转换', 0, () => {
const result = TestMarkdownParser.parse('价格*100元');
// 星号前后没有配对,不应生成<em>
expect(result).assertContain('*100元');
});
});

describe('链接解析', () => {

it('标准链接语法正确转换', 0, () => {
const result = TestMarkdownParser.parse('[点击这里](https://example.com)');
expect(result).assertContain('<a href="https://example.com">点击这里</a>');
});

it('内部路由链接保留路径', 0, () => {
const result = TestMarkdownParser.parse('[首页](/pages/index)');
expect(result).assertContain('href="/pages/index"');
});
});

describe('列表解析', () => {

it('短横线开头的行转为li', 0, () => {
const result = TestMarkdownParser.parse('- 第一项\\n- 第二项');
expect(result).assertContain('<li>第一项</li>');
expect(result).assertContain('<li>第二项</li>');
});

it('星号开头的行也转为li', 0, () => {
const result = TestMarkdownParser.parse('* 项目A');
expect(result).assertContain('<li>项目A</li>');
});
});

describe('边界情况', () => {

it('空字符串返回空', 0, () => {
expect(TestMarkdownParser.parse('')).assertEqual('');
});

it('null/undefined安全处理', 0, () => {
expect(TestMarkdownParser.parse(null as any)).assertEqual('');
expect(TestMarkdownParser.parse(undefined as any)).assertEqual('');
});

it('纯文本包裹为p标签', 0, () => {
const result = TestMarkdownParser.parse('这是普通段落');
expect(result).assertContain('<p>这是普通段落</p>');
});

it('分割线转换为hr', 0, () => {
const result = TestMarkdownParser.parse('—');
expect(result).assertContain('<hr/>');
});
});
});
}


步骤4: Service 层 Mock 测试

Service 类通常依赖外部能力(Preferences 存储、系统 API 等),需要通过 Mock 隔离。

I18nService Mock 测试

// entry/src/ohosTest/ets/test/services/I18nService.test.ets

import { describe, beforeAll, it, expect } from '@ohos/hypium';

/**
* I18nService 的 Mock 版本
*
* 在真实项目中,可以通过以下方式创建 Mock:
* 1. 使用接口抽象 + Mock 实现
* 2. 使用 @ohos.data.preferences 的 mock 替代
* 3. 通过依赖注入传入 mock 实例
*/

interface II18nService {
getCurrentLanguage(): string;
setLanguage(lang: string): void;
getString(key: string): string;
getFormattedString(key: string, params: string[]): string;
}

/** Mock 实现 – 模拟 I18nService 的行为 */
class MockI18nService implements II18nService {
private currentLang: string = 'zh-Hans';
private strings: Record<string, Record<string, string>> = {
'zh-Hans': {
'app_name': '节气通',
'welcome_message': '欢迎使用节气通',
'items_count': '共{0}个节气'
},
'en': {
'app_name': 'Solar Terms',
'welcome_message': 'Welcome to Solar Terms',
'items_count': 'Total {0} solar terms'
}
};

getCurrentLanguage(): string {
return this.currentLang;
}

setLanguage(lang: string): void {
this.currentLang = lang;
}

getString(key: string): string {
return this.strings[this.currentLang]?.[key] || key;
}

getFormattedString(key: string, params: string[]): string {
let str = this.getString(key);
params.forEach((param, index) => {
str = str.replace(`{${index}}`, param);
});
return str;
}
}

export default function i18nServiceTest() {
describe('I18nService (Mock)', () => {
let service: MockI18nService;

beforeAll(() => {
service = new MockI18nService();
});

describe('语言切换', () => {

it('默认语言应为中文', 0, () => {
expect(service.getCurrentLanguage()).assertEqual('zh-Hans');
});

it('切换到英文后语言变更', 0, () => {
service.setLanguage('en');
expect(service.getCurrentLanguage()).assertEqual('en');
});

it('切换回中文后恢复', 0, () => {
service.setLanguage('zh-Hans');
expect(service.getCurrentLanguage()).assertEqual('zh-Hans');
});
});

describe('字符串获取', () => {

it('中文模式下获取中文字符串', 0, () => {
service.setLanguage('zh-Hans');
expect(service.getString('app_name')).assertEqual('节气通');
});

it('英文模式下获取英文字符串', 0, () => {
service.setLanguage('en');
expect(service.getString('app_name')).assertEqual('Solar Terms');
});

it('不存在的key应返回key本身', 0, () => {
service.setLanguage('zh-Hans');
const result = service.getString('nonexistent_key');
expect(result).assertEqual('nonexistent_key');
});
});

describe('格式化字符串', () => {

it('参数正确替换占位符', 0, () => {
service.setLanguage('zh-Hans');
const result = service.getFormattedString('items_count', ['24']);
expect(result).assertEqual('共24个节气');
});

it('英文模式下格式化正确', 0, () => {
service.setLanguage('en');
const result = service.getFormattedString('items_count', ['24']);
expect(result).assertEqual('Total 24 solar terms');
});

it('多个参数依次替换', 0, () => {
// 测试多参数场景
const template = '{0}年{1}月{2}日';
const result = template
.replace('{0}', '2026')
.replace('{1}', '06')
.replace('{2}', '12');
expect(result).assertEqual('2026年06月12日');
});
});
});
}

ThemeService Mock 测试

// entry/src/ohosTest/ets/test/services/ThemeService.test.ets

import { describe, beforeAll, it, expect } from '@ohos/hypium';

/** Theme 类型定义 */
type ThemeMode = 'light' | 'dark' | 'system';

/** 主题颜色配置接口 */
interface ThemeColors {
primaryColor: string;
backgroundColor: string;
cardBackgroundColor: string;
textColorPrimary: string;
textColorSecondary: string;
}

/** Mock ThemeService */
class MockThemeService {
private _mode: ThemeMode = 'system';
private colors: Record<string, ThemeColors> = {
light: {
primaryColor: '#4A9B6D',
backgroundColor: '#FFFFFF',
cardBackgroundColor: '#F8F8F8',
textColorPrimary: '#1A1A1A',
textColorSecondary: '#666666'
},
dark: {
primaryColor: '#6BCB8E',
backgroundColor: '#1A1A1A',
cardBackgroundColor: '#2A2A2A',
textColorPrimary: '#E8E8E8',
textColorSecondary: '#AAAAAA'
},
system: {
primaryColor: '#4A9B6D',
backgroundColor: '#FFFFFF',
cardBackgroundColor: '#F8F8F8',
textColorPrimary: '#1A1A1A',
textColorSecondary: '#666666'
}
};

get mode(): ThemeMode { return this._mode; }

setMode(mode: ThemeMode): void {
this._mode = mode;
if (mode === 'system') {
// 模拟跟随系统:假设当前系统为浅色
Object.assign(this.colors.system, this.colors.light);
}
}

getColors(): ThemeColors {
return this.colors[this._mode];
}

getColor(key: keyof ThemeColors): string {
return this.colors[this._mode][key];
}

isDarkMode(): boolean {
return this._mode === 'dark' ||
(this._mode === 'system' && false); // system模式此处简化
}
}

export default function themeServiceTest() {
describe('ThemeService (Mock)', () => {
let service: MockThemeService;

beforeAll(() => {
service = new MockThemeService();
});

describe('主题模式管理', () => {

it('默认模式应为system', 0, () => {
expect(service.mode).assertEqual('system');
});

it('切换到light模式', 0, () => {
service.setMode('light');
expect(service.mode).assertEqual('light');
});

it('切换到dark模式', 0, () => {
service.setMode('dark');
expect(service.mode).assertEqual('dark');
expect(service.isDarkMode()).assertTrue();
});

it('dark模式下isDarkMode返回true', 0, () => {
service.setMode('dark');
expect(service.isDarkMode()).assertTrue();
});

it('light模式下isDarkMode返回false', 0, () => {
service.setMode('light');
expect(service.isDarkMode()).assertFalse();
});
});

describe('颜色获取', () => {

it('light模式主色为绿色', 0, () => {
service.setMode('light');
expect(service.getColor('primaryColor')).assertEqual('#4A9B6D');
});

it('dark模式背景色为深色', 0, () => {
service.setMode('dark');
expect(service.getColor('backgroundColor')).assertEqual('#1A1A1A');
});

it('getColors返回完整配色对象', 0, () => {
service.setMode('light');
const colors = service.getColors();
expect(colors.primaryColor).assertEqual('#4A9B6D');
expect(colors.backgroundColor).assertEqual('#FFFFFF');
expect(Object.keys(colors).length).assertEqual(5);
});
});
});
}


步骤5: 数据模型测试

// entry/src/test/models/SolarTermModel.test.ets

import { describe, it, expect } from '@ohos/hypium';

/** 节气数据模型(简化版) */
interface SolarTermData {
name: string; // 节气名称
pinyin: string; // 拼音
solarDate: string; // 公历日期
lunarDate: string; // 农历日期
description: string; // 描述
customs: string[]; // 习俗
healthTips?: string[]; // 养生建议(可选)
}

/** 创建默认节气数据的工厂函数 */
function createDefaultTerm(overrides: Partial<SolarTermData> = {}): SolarTermData {
return {
name: '立春',
pinyin: 'Li Chun',
solarDate: '2026-02-04',
lunarDate: '正月十七',
description: '立春,二十四节气之首。',
customs: ['迎春', '咬春'],
…overrides
};
}

export default function solarTermModelTest() {
describe('SolarTermModel', () => {

describe('默认值', () => {

it('工厂函数应生成完整的数据结构', 0, () => {
const term = createDefaultTerm();
expect(term.name).assertEqual('立春');
expect(term.customs.length).assertEqual(2);
expect(term.healthTips).assertUndefined();
});

it('可选字段healthTips默认不存在', 0, () => {
const term = createDefaultTerm();
expect(term.hasOwnProperty('healthTips')).assertFalse();
});
});

describe('字段覆盖', () => {

it('覆盖name字段', 0, () => {
const term = createDefaultTerm({ name: '冬至' });
expect(term.name).assertEqual('冬至');
expect(term.pinyin).assertEqual('Li Chun'); // 其他字段保持默认
});

it('覆盖多个字段', 0, () => {
const term = createDefaultTerm({
name: '夏至',
pinyin: 'Xia Zhi',
customs: ['吃面条', '称人']
});
expect(term.name).assertEqual('夏至');
expect(term.customs.length).assertEqual(2);
expect(term.customs[0]).assertEqual('吃面条');
});

it('添加可选字段', 0, () => {
const term = createDefaultTerm({
healthTips: ['早睡早起', '饮食清淡']
});
expect(term.healthTips?.length).assertEqual(2);
});
});

describe('数据校验', () => {

it('名称不能为空', 0, () => {
const term = createDefaultTerm({ name: '' });
const isValid = term.name.trim().length > 0;
expect(isValid).assertFalse();
});

it('公历日期格式校验', 0, () => {
const validateDate = (date: string): boolean =>
/^\\d{4}-\\d{2}-\\d{2}$/.test(date);

expect(validateDate('2026-02-04')).assertTrue();
expect(validateDate('2026/02/04')).assertFalse();
expect(validateDate('02-04')).assertFalse();
});

it('习俗数组至少包含一项', 0, () => {
const term = createDefaultTerm({ customs: [] });
const hasCustoms = term.customs.length > 0;
expect(hasCustoms).assertFalse(); // 空数组不符合要求
});
});
});
}


步骤6: 运行测试与报告

命令行运行

# 运行所有本地单元测试
hvigorw test

# 运行指定测试文件
hvigorw test –filter DateUtils

# 运行设备端测试(需要连接设备或启动模拟器)
hvigorw testOhos

测试结果解读

hypium 输出的测试报告中:

✓ [PASS] DateUtils formatDate 应将Date格式化为YYYY-MM-DD (耗时: 12ms)
✓ [PASS] DateUtils formatDate 应正确处理个位数的月份和日期 (耗时: 3ms)
✗ [FAIL] DateUtils daysBetween 跨年计算应正确 (耗时: 5ms)
Expected: 1
Actual: 366
at line 42 in DateUtils.test.ets

============================
总计: 15 个测试用例
通过: 14 (93.3%)
失败: 1 (6.7%)
耗时: 128ms
============================


架构总览

┌─────────────────────────────────────────────┐
│ CI/CD 流水线 │
│ │
│ Push → 触发 → 运行测试 → 生成报告 → 通知 │
│ │
├─────────────────────────────────────────────┤
│ 测试运行层 │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ 本地测试(JVM) │ │ 设备端测试 │ │
│ │ src/test/ │ │ src/ohosTest/ │ │
│ │ │ │ │ │
│ │ • DateUtils │ │ • I18nService │ │
│ │ • StringUtils│ │ • ThemeService │ │
│ │ • MdParser │ │ • UI组件测试 │ │
│ │ • Model校验 │ │ │ │
│ └──────────────┘ └──────────────────┘ │
│ │
├─────────────────────────────────────────────┤
│ 被测代码 │
│ │
│ utils/ ← 纯函数,最适合单测 │
│ models/ ← 数据模型,校验逻辑 │
│ services/ ← 业务逻辑,需要Mock │
│ components/ ← UI组件,交互测试 │
│ │
└─────────────────────────────────────────────┘

测试金字塔:

/\\
/ \\ E2E测试(少量)
/────\\ ──────────
/ 集成测试 \\ (适量)
/────────────\\
/ 单元测试 \\ (大量,基石)
/────────────────\\


关键注意事项

1. 本地测试 vs 设备端测试的区别

特性本地测试 (src/test/)设备端测试 (src/ohosTest/)
运行环境 JVM(电脑上) 真机或模拟器
速度 快(毫秒级) 较慢(秒级)
可访问的系统API 仅基础JS/TS API 全部系统API
适用场景 纯函数、工具类 Service、UI组件
外部依赖 无法访问 可访问但需权限

2. 测试命名规范

好的测试名应该像一句话描述预期行为:

// ❌ 模糊不清
it('testFormat', …)

// ✓ 清晰描述
it('应将Date格式化为YYYY-MM-DD格式', …)
it('当输入为null时应返回空字符串', …)
it('切换语言后getString返回对应语言的翻译', …)

常用中文前缀:

  • 应… — 正常行为
  • 当…时应… — 条件分支
  • 不…时应… — 反向场景
  • 边界… — 边界值

3. AAA 模式编排测试

每个测试用例遵循 Arrange-Act-Assert 结构:

it('两个日期相差365天', 0, () => {
// Arrange: 准备数据和前置条件
const start = new Date(2025, 0, 1);
const end = new Date(2026, 0, 1);

// Act: 执行被测操作
const diff = Math.floor(
(end.getTime() – start.getTime()) / (1000 * 60 * 60 * 24)
);

// Assert: 验证结果
expect(diff).assertEqual(365);
});

4. Mock 的原则

  • 只 Mock 外部依赖:网络请求、文件系统、系统偏好设置
  • 不要 Mock 被测单元本身:否则测试毫无意义
  • Mock 行为要贴近真实:包括成功、失败、超时等场景
  • 每个测试独立创建 Mock:避免测试间状态污染

5. 测试覆盖率不是唯一指标

  • 100% 覆盖率 ≠ 无 Bug(可能所有分支都测了但漏了关键场景)
  • 追求 有意义的覆盖 而非数字游戏
  • 核心业务逻辑优先保证覆盖

最佳实践清单

在编写单元测试时,请逐项检查:

  • 测试文件放在正确的目录(src/test/ 或 src/ohosTest/)
  • 测试命名清晰描述预期行为(使用"应…"句式)
  • 每个测试用例遵循 AAA(Arrange-Act-Assert)结构
  • 纯函数测试不需要 Mock,直接验证输入输出
  • Service 测试通过接口+Mock 实现隔离
  • 包含正常场景、边界情况和异常情况的测试
  • 空值(null/undefined/空字符串)有专门测试
  • 测试之间无状态依赖(beforeEach 重置状态)
  • 断言信息足够定位失败原因
  • 关键路径的测试能在 CI 中自动运行

相关链接

  • 项目源码: GitCode仓库
  • 应用下载: 华为应用市场
赞(0)
未经允许不得转载:171主机测评 » HarmonyOS应用<节气通>开发第45篇:单元测试——构建质量保障体系
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址