
引言
单元测试是软件质量的基石。在节气通这类知识类应用中,核心业务逻辑(如农历计算、节气判定、日期格式化、数据过滤排序等)的正确性直接影响用户体验。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 配置
json5
// entry/src/ohosTest/module.json5
{
"module": {
"name": "entry_test",
"type": "feature",
"deviceTypes": ["phone", "tablet"],
"installationFree": false,
"deliveryWithInstall": true,
"virtualMachine": "ark0.0.0"
}
}
TestRunner 入口
typescript
// 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 测试
typescript
// 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 测试
typescript
// 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 解析器的测试非常适合验证各种边界情况。
typescript
// 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 测试
typescript
// 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 测试
typescript
// 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: 数据模型测试
typescript
// 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: 运行测试与报告
命令行运行
bash
# 运行所有本地单元测试
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. 测试命名规范
好的测试名应该像一句话描述预期行为:
typescript
// ❌ 模糊不清
it('testFormat', ...)
// ✓ 清晰描述
it('应将Date格式化为YYYY-MM-DD格式', ...)
it('当输入为null时应返回空字符串', ...)
it('切换语言后getString返回对应语言的翻译', ...)
常用中文前缀:
应...--- 正常行为当...时应...--- 条件分支不...时应...--- 反向场景边界...--- 边界值
3. AAA 模式编排测试
每个测试用例遵循 Arrange-Act-Assert 结构:
typescript
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 中自动运行