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 配置
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 中自动运行

相关链接