鸿蒙单测方案的探索与适配

77 阅读7分钟

本文主要简单讲解一下在原生鸿蒙应用开发中单元测试的一些方案探索以及在真实项目中的一些实践。

本文实践的可运行工程在 ohos-rs/harmony-test

背景

为什么我们需要测试(单元测试、自动化测试、E2E 测试等)?

以下内容由 Claude 3.5 Sonnet 提供:

17296683155846.jpg

笔者在迁移 napi-rs 到鸿蒙的过程中,发现了很多问题,其中有 60% 以上需要适配的问题都是在 release 之前通过单元测试发现的,丰富完善的测试能够为我们提早发现问题以及保证功能的完善性。基于这个事件的背景,简单写一篇在鸿蒙上测试的一些探索和个人实践。

现状

鸿蒙原生应用开发目前也支持进行单元测试,主要依托于 arkxtest 测试框架能力进行测试。

17296675270733.jpg

目前基于 API12 新建的项目基础结构中包含了三个与测试相关的文件夹:

  1. mock
    用于存储测试中需要 mock 的内容
  2. ohosTest
    用于存储 Instrument test 的测试用例,简单理解就是这部分代码会最终依赖于真机/模拟器进行运行。目前跟 Native 模块相关的能力都必须使用该测试方案。
  3. test
    本地测试,在当前开发环境运行,无需依赖于设备。当然也无法测试与 Native 相关的代码。

所有的能力从 @ohos/hypium 包中提供,也就是这里提到的 arkxtest 包能力,包括基础的断言、mock 等能力,这里不再详细的讲如何进行编写,具体可以参考:arkxtest

探索与实践

现状的鸿蒙测试能力中有一个最大的问题:目前主要基于 ArkTS 编写,ArkTS 本身做了大量的语法限制,实际上我们有些代码又是 JS/TS 混编的,这就导致我们会遇到一个问题我们想要编写一些比较极端的单元测试需要使用一些动态化能力的时候,在 ets 文件中是不允许且没有绕过方案的。

如果修改为 TS/JS 文件的话,又不能引用 ets 文件的内容,而 @ohos/hypium 则是通过 ets 导出的,就导致无法使用断言等能力。

基于这种背景下,我们其实可以很轻易的想到一个解决方案:整个单测的部分都使用 TS/JS 实现不就没有这个限制了吗?

完全正确,实际上 arkxtest 的仓库在实现上面也是基本上完全基于 TS/JS 实现的,这也就意味着我们的思路是可行的。不过在实际的操作中还有一些事项需要额外进行处理。

思考一个简单的问题:为什么对于单元测试我们不考虑完全使用 ArkTS 进行实现呢?

这要从为什么要使用 ArkTS 的角度来思考,除去 ArkUI 部分,我们使用 ArkTS 其中一个最重要的作用就是能够使我们编写的代码能够使用方舟编译器进行编译,最终提升运行效率。但是对于测试这种场景来说,性能并非首要考虑的问题,因此及时 TS/JS 对于我们目前这种测试场景有一定的性能影响,我们也可以认为是无关紧要的。

引入及适配

首先最基本的就是需要将 arkxtest 的源码引入到我们的项目工程中,其中只需要 jsunit/src 目录下的所有资源以及 jsunit/src/index.js 即可。

源码地址: arkxtest

我们将这个目录的所有文件保存到了 entry/src/ohosTest/ets/test/utils/core 目录下。注意:我们一般将 entry/src/ohosTest/ets/test 认为是单测的源码目录,而后续的路径则可以根据项目自身来进行修改。

jsunit/src/index.js 则需要在本地新建一个文件或者直接保存即可。

我们在测试中所有依赖于 @ohos/hypium 从引入的能力都修改为从本地的 index.js 文件中引入。

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

// 新代码
// ./utils/framework.test 文件中的内容就是 jsunit/src/index.js 中的内容
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from './utils/framework.test';

然后我们需要参考 Test Setup 来创建自定义的 Runner 以及 Ability 来初始化整个运行流程。

适配 ava 测试框架

ava 是一个专注于 Node.js 的单元测试框架。napi-rs 的单测均基于 ava 实现,但是我们可以清楚的预见,不同的单测框架提供的 API 是不一样的,但是能力上基本上是一致的。

为了避免对单测代码的大规模修改,我们最简单的办法是提供一个适配层,将 API 进行一次转化,让最终调用的时候保持一致,而我们只需要将引入的地方修改为适配层即可。

这也是我们日常比较常用的一种开发手段,来减少适配的工作量。

一个🌰如下所示:

import { expect, it as test, describe } from "./framework.test";
import type { TestFn } from "./types/test-fn";

const testContext: ESObject = {
  is: (actual: ESObject, expected: ESObject) => {
    expect(actual).assertEqual(expected);
  },
  deepEqual: (actual: ESObject, expected: ESObject) => {
    expect(actual).assertDeepEquals(expected);
  },
  throws: (fn: ESObject, expected: ESObject) => {
    expect(fn).assertThrowError(expected);
  },
  notThrows: (fn: ESObject, expected: ESObject) => {
    expect(fn).not().assertThrowError(expected);
  },
  throwsAsync: async (fn: ESObject, expected: ESObject) => {
    let ret = fn instanceof Promise ? fn : fn();
    if (expected) {
      expect(ret).assertPromiseIsRejectedWith(expected);
    } else {
      expect(ret).assertPromiseIsRejected();
    }
  },
  notThrowsAsync: async (fn: ESObject, expected: ESObject) => {
    let ret = fn instanceof Promise ? fn : fn();
    if (expected) {
      expect(ret).assertPromiseIsResolvedWith(expected);
    } else {
      expect(ret).assertPromiseIsResolved();
    }
  },
  true: (actual: ESObject, message: ESObject) => {
    expect(actual).assertEqual(true);
  },
  false: (actual: ESObject, message: ESObject) => {
    expect(actual).assertEqual(false);
  },
  not: (actual: ESObject, expected: ESObject) => {
    expected(actual).not().assertDeepEquals(expected);
  }
};

const testRunner = ((title: ESObject, spec: ESObject) => {
  test(title, 0, async (done) => {
    spec(testContext);
    done();
  });
}) as TestFn;

export { testRunner as test, describe };

这样我们就完成了在鸿蒙上对于 ava 单测框架的简单适配。

执行

接下来我们就是来正式运行我们的单测了,在开发者工具中我们只需要右键 Run All 即可。

17296747895400.jpg

不出意外的话,应该是要出意外了,你会发现他居然只会运行一个默认的测试用例???所有新增在 List.ets.ts 的测试用例并没有被执行。

17296749320045.jpg

从目前已知的信息来看,这是因为 IDE 集成的单测能力是对单测文件的后缀做了一定的过滤和处理,我们使用 TS/JS 编写的测试用例会被自动过滤掉。

不过好在测试框架能力提供了基于命令行运行的能力。

hdc shell aa test -b com.ohos.napi -m entry_test -s unittest /ets/testrunner/OpenHarmonyTestRunner -s timeout 15000

其中 -b 是我们的工程名称,-m 是单测的自定义 ability 一般情况下是真实的模块名与 test 通过下划线构成,后面都是固定的参数。

这样就能避免我们使用 TS/JS 编写的用例被过滤掉。如此之后,我们便能正常的看到我们的所有测试用例被正常执行了。

17296753612978.jpg

当然这种方案在目前还有一些小的缺陷,比如:

  1. 没有 IDE 集成的能力那么直观的运行结果。
  2. 不好进行 debug 调试。

但是对于目前的这种测试场景来看,已经算是比较好的解决办法了。当然如果有更好的方案,也欢迎一起交流~

注意事项

除此之外,我们还有一些小的注意事项需要我们额外关注。

  1. 每次需要重新运行用例之前,需要先使用 IDE 的 Run All,然后再使用命令运行,确保我们构建的是最新的单测用例程序,当然也可以选择使用命令构建程序。

  2. 异步的测试方案必须调用done函数

    const testRunner = ((title: ESObject, spec: ESObject) => {
      test(title, 0, async (done) => {
        spec(testContext);
        // 必须调用 否则会阻塞执行
        done();
      });
    }) as TestFn;
    
  3. 如果使用真机执行,也必须对程序进行签名,使用模拟器运行则无需签名。

  4. 不支持 // @ts-ignore-error 语法

收益

在 ohos-rs 适配的过程中,我们基于 napi-rs 已有的单元测试发现了 7 个表现的差异问题,占整体需要适配内容的 60% 以上,部分问题鸿蒙系统侧内部仍在确认中。

同时确认了我们大部分能力在鸿蒙系统上运行的稳定性和正确性,后续我们还将这种方案使用在 node-addon-api-ohos 的适配上,以确保 node-addon-api 的能力在鸿蒙系统上的可用性。