Administrator
发布于 2026-09-02 / 883 阅读
0
0

鸿蒙单元测试深入:Mock、断言策略与覆盖率实践

系列文章《鸿蒙开发从入门到专家》第 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"
  }
}

两种测试模式的区别:

模式

运行环境

能力限制

适用场景

LocalTest

开发机本地

不能调用系统能力

纯逻辑函数测试

AbilityTest

真机/模拟器

可调用全部系统能力

UI 交互、系统能力调用测试

二、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。

断言类型

用法

适用场景

精确值断言

assertEqual(expected)

纯函数、确定输出

布尔断言

assertTrue() / assertFalse()

状态判断

范围断言

assertLarger() / assertLess()

数值范围

包含断言

assertContain()

字符串、数组

异常断言

try-catch + assertFalse()

错误路径

未到达断言

assertTrue(false) 在不该到的位置

确保代码不执行

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 三种覆盖率

覆盖率类型

含义

能发现的问题

行覆盖率

执行了多少行代码

未执行的死代码

分支覆盖率

if/else 分支走了多少

遗漏的条件路径

函数覆盖率

函数被调用过没有

未被调用的函数

行覆盖率高不代表测试质量高。一段代码 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 测试粒度

粒度

测试内容

数量建议

函数级

单个函数的输入输出

占 60%

模块级

一个类/模块的交互

占 30%

集成级

跨模块的数据流

占 10%

函数级测试最多最快。集成级测试最慢最脆弱,不应该占大头。

小结

单元测试写得好不好,看三点:Mock 是否到位(被测函数的依赖是否可控)、断言是否有效(是不是只在验证"没崩溃")、边界是否覆盖(正常值/边界值/非法值都测了没)。

覆盖率数字是参考不是目标。关键路径的分支覆盖率比全局行覆盖率有说服力。

下一篇讲 UI 自动化测试,用 Hypium 框架做界面级别的自动化验证。


评论