茶器艺科智造HarmonyOS应用实战-22-解析函数全是private,单测为何只能碰全局pending:拆开纯解析器与路由邮箱

想给 notab=2heightMm=70mmwaveLevel=0 补一个单元用例,打开 ChaqiRouteIntent.ets 后却发现:queryParamFromUriqueryRouteParamparseTabIndexparseRouteNumberparseCupParamsparseWant 全是文件内函数;HAR 对外只给 setChaqiRouteFromWanttakeAndClearChaqiRoute。测试若想观察解析结果,只能先写全局 pending,再把它取走。

这样一个失败会同时有三种可能:文本解析错了、无效路由策略错了、邮箱里残留了上一个用例的数据。测试还必须知道“读取会清空”这个存储细节。指定工程当前 libraryhar/src/test/LocalUnit.test.ets 只有通用 assertContain 示例,并没有路由用例。本文把路由处理拆成纯解析器、Want 适配器和 Mailbox 三层,让每层都能独立测试,再用一个很薄的兼容门面维持现有调用名。

拆开纯解析器与路由邮箱

本篇只讨论职责与可测试性:

  1. 解析失败为什么不应通过全局 pending 间接观察。
  2. 纯解析器怎样摆脱 Want 和模块级变量。
  3. Mailbox 怎样明确 latest-wins、破坏性读取与无效输入策略。
  4. 如何用依赖注入测试门面,并保持 Index.ets 的公共 API 克制。

冷启动先恢复还是先消费、onNewWant 何时唤醒页面,已由系列第 02 篇讨论,本篇不重新推演生命周期顺序。

一、源码锚点显示解析、策略与存储挤在一个文件

libraryhar/src/main/ets/routes/ChaqiRouteIntent.ets 当前结构可以压缩为:

24   ChaqiRouteCupParams
48   ChaqiRoutePayload
58   pending: ChaqiRoutePayload | null
60   EMPTY_CUP_PARAMS
78   queryParamFromUri              文件内
105  queryParamFromWantParams       文件内
127  queryRouteParam                文件内
141  parseTabIndex                  文件内
155  parseGenerateFlag              文件内
166  parseRouteNumber               文件内
184  parseCupParams                 文件内
206  parseWant                      文件内
228  setChaqiRouteFromWant          对外
244  takeAndClearChaqiRoute         对外

libraryhar/Index.ets:18 只再次导出最后两个函数。这个边界对业务消费者很简洁,却也让解析逻辑只能通过状态副作用观察。调用 setChaqiRouteFromWant 后,测试必须立即调用 takeAndClearChaqiRoute,否则下一个用例可能读到遗留值;若输入无效,set 当前还会把 pending 清空,解析策略与邮箱策略无法分别断言。

文件内函数并不等于设计错误。真正的问题是纯计算没有自己的可测试边界,而唯一可见出口带全局写入。

二、间接测试会把三个失败面揉成一个断言

假设测试想确认 tab=1abc 应被拒绝,只能写成类似流程:

setChaqiRouteFromWant(wantWithUri('chaqi://entry?tab=1abc'));
const route = takeAndClearChaqiRoute();
expect(route === null).assertTrue();

如果断言失败并拿到 tab=1,可能是 parseTabIndex 前缀解析的问题;如果拿到另一个 payload,可能是上一用例没有清空;如果为 null,也可能是 Want 测试对象构造不符合真实类型。一个布尔断言无法指出责任层。

并行执行更麻烦。所有用例共享模块级 pending,测试顺序、beforeEach 清理和异步回调都可能互相影响。即使 Hypium 当前按某种顺序运行,把顺序当作业务正确性的前提也会让用例脆弱。

更有价值的分层断言是:解析器测试只比较输入与结果;Mailbox 测试只验证 put/take;适配器测试只验证 Want 字段被正确转成普通输入;门面测试使用 fake parser 和独立 mailbox,不触碰生产单例。

三、第一刀:纯解析器只接收普通值

解析规则不需要 Want 类型。建议增加 ChaqiRouteParser.ets,输入只包含 uri 与参数映射,输出为带状态的 decode result:

export interface ChaqiRouteInput {
  uri: string;
  params: Record<string, Object>;
}

export interface ChaqiRouteDecodeResult {
  ok: boolean;
  payload: ChaqiRoutePayload | null;
  issues: RouteDecodeIssue[];
}

export interface ChaqiRouteParser {
  parse(input: ChaqiRouteInput): ChaqiRouteDecodeResult;
}

export class DefaultChaqiRouteParser implements ChaqiRouteParser {
  parse(input: ChaqiRouteInput): ChaqiRouteDecodeResult {
    return decodeChaqiRoute(input.uri, input.params);
  }
}

decodeChaqiRoute 内部组合第 19 篇的精确查询 token 与第 20、21 篇的 typed decode。它不读取或写入模块变量,不访问 UI、Preferences、EventHub,也不捕获生命周期。相同输入必定得到相同结果,单测无需 beforeEach 清理环境。

Want适配、纯解析、Mailbox与页面消费的分层流程

这里把“export”分成两层理解:源文件导出纯函数,方便 libraryhar/src/test 通过相对路径导入;是否从根 Index.ets 再导出给 HAR 消费者,是另一项 API 决策。若纯解析器只服务模块内部测试,可以暂时不加入公共 barrel,减少外部兼容负担。

四、Want 适配器只负责系统类型到普通输入

系统对象适配应很薄,不包含参数合法性。当前代码从 want.uri ?? ''want.parameters as Record<string, Object> | undefined 取值,这一步可单独放进适配器:

import { Want } from '@kit.AbilityKit';

export function routeInputFromWant(want: Want): ChaqiRouteInput {
  const uri = want.uri ?? '';
  const source = want.parameters as Record<string, Object> | undefined;
  const params: Record<string, Object> = source ?? {};
  return { uri: uri, params: params };
}

这样纯解析器测试不需要伪造完整 Want。Want 适配器自身只需少量用例:无 uri 得到空串;无 parameters 得到空 map;字符串、数字和布尔参数保持原值。华为的 Want 使用示例把 uri 与 parameters 作为不同字段展示,业务层仍要对它们进行自己的协议解码。

如果目标 SDK 对空对象或空值合并有更严格的 ArkTS 规则,应将 {} 放进显式实现类或使用专门工厂,并以实际编译为准。文章代码表达的是边界,不声称已在指定工程编译。

五、第二刀:Mailbox 只接受已经解码成功的 payload

Mailbox 不应知道 URI、数字格式或 preset 白名单。它只保存路由载荷。先写接口,再把当前单槽语义做成可实例化实现:

export interface ChaqiRouteMailbox {
  put(payload: ChaqiRoutePayload): void;
  take(): ChaqiRoutePayload | null;
  clear(): void;
}

export class LatestRouteMailbox implements ChaqiRouteMailbox {
  private pending: ChaqiRoutePayload | null = null;

  put(payload: ChaqiRoutePayload): void {
    this.pending = payload;
  }

  take(): ChaqiRoutePayload | null {
    const current = this.pending;
    this.pending = null;
    return current;
  }

  clear(): void {
    this.pending = null;
  }
}

类实例代替裸模块变量后,每个测试都能创建自己的 mailbox,互不污染。生产环境仍可创建一个默认实例,因此这项拆分不要求改变页面的单槽消费方式。

Mailbox 契约应写清三点:连续 put 采用 latest-wins;take 返回当前值并清空;clear 只由明确重置调用。无效原始输入不会传入 mailbox,因为 parser 已在上一层返回失败。本文建议无效输入不要隐式清掉已有有效 payload;若产品坚持“任何新请求都替换旧请求”,也应由协调层显式调用 clear 并写用例,而不是让解析器顺手改状态。

六、协调器用依赖注入组合解析与邮箱

用一个小类负责“适配 Want → 解析 → 成功后 put”。构造函数接收接口,生产和测试都可以替换实现:

export class ChaqiRouteGateway {
  private parser: ChaqiRouteParser;
  private mailbox: ChaqiRouteMailbox;

  constructor(parser: ChaqiRouteParser, mailbox: ChaqiRouteMailbox) {
    this.parser = parser;
    this.mailbox = mailbox;
  }

  receive(input: ChaqiRouteInput): ChaqiRouteDecodeResult {
    const result = this.parser.parse(input);
    if (result.ok && result.payload !== null) {
      this.mailbox.put(result.payload);
    }
    return result;
  }

  take(): ChaqiRoutePayload | null {
    return this.mailbox.take();
  }
}

协调器没有参数规则,也没有 UI 生命周期。它只是保证只有成功 payload 能进入邮箱,并把 decode result 返回给 Ability 层用于受控日志。依赖注入的目的不是搭建复杂框架,而是让测试获得一个独立实例;这里两个接口、一个构造函数已经足够。

纯解析器、Want适配器、Gateway与Mailbox职责结构

七、兼容门面保留现有函数名,缩小调用方改动

entry 和 HSP 当前分别调用 setChaqiRouteFromWanttakeAndClearChaqiRoute。拆分后可以保留这两个公共名字,把它们改成默认 gateway 的薄包装:

const defaultMailbox = new LatestRouteMailbox();
const defaultParser = new DefaultChaqiRouteParser();
const defaultGateway = new ChaqiRouteGateway(defaultParser, defaultMailbox);

export function setChaqiRouteFromWant(want: Want): void {
  const input = routeInputFromWant(want);
  const result = defaultGateway.receive(input);
  reportRouteIssues(result.issues);
}

export function takeAndClearChaqiRoute(): ChaqiRoutePayload | null {
  return defaultGateway.take();
}

libraryhar/Index.ets 可继续只导出这两个兼容函数和公共 payload 类型。测试则直接导入 parser、mailbox 与 gateway 文件。这样业务模块不需要一次性改名,内部测试边界却已经打开。

reportRouteIssues 只记录问题码、字段和计数,不输出完整 URI。若不需要日志,也可以由 receive 返回 result,让 Ability 自己决定处理;不要为了测试重新引入全局 lastError。

八、三组单测分别锁定三种契约

纯解析器用例直接覆盖问题输入,不碰 mailbox:

it('rejects numeric suffix without touching state', 0, () => {
  const parser = new DefaultChaqiRouteParser();
  const input: ChaqiRouteInput = {
    uri: 'chaqi://entry?tab=1abc',
    params: {}
  };
  const result = parser.parse(input);
  expect(result.ok).assertFalse();
  expect(result.issues[0].code).assertEqual('TAB_FORMAT_INVALID');
});

Mailbox 用例只验证单槽语义:

it('keeps latest payload and clears on take', 0, () => {
  const box = new LatestRouteMailbox();
  box.put(routeForTab(1));
  box.put(routeForTab(3));
  expect(box.take()?.tab).assertEqual(3);
  expect(box.take() === null).assertTrue();
});

Gateway 用 fake parser 验证失败不会清理已有值:

it('does not replace valid mail on parse failure', 0, () => {
  const box = new LatestRouteMailbox();
  box.put(routeForTab(2));
  const gateway = new ChaqiRouteGateway(new RejectingParser(), box);
  gateway.receive(emptyRouteInput());
  expect(box.take()?.tab).assertEqual(2);
});

这些用例各有单一失败面。解析规则改变只影响 parser 用例;Mailbox 策略改变只影响 mailbox 用例;协调策略改变才影响 gateway 用例。

九、验证矩阵、故障排查与证据边界

层级输入预期不应依赖
Parser精确 URI/paramspayload 或 issues全局 pending、Want 实例
Want adapterWant 字段ChaqiRouteInput数字范围、Mailbox
Mailbox两个 payload 连续 puttake 得到后者并清空URI、解析器
Gatewayfake parser 成功put 一次页面生命周期
Gatewayfake parser 失败不替换既有值字符串解析细节
Compatibility facade实际 Want结果进入默认实例测试顺序猜测
现象首查常见根因修正
parser 用例仍需 beforeEach 清 pending测试导入对象仍从公共 set/take 间接测直接导入纯解析器
用例偶发读到旧路由mailbox 是否独立实例共享生产单例每例 new LatestRouteMailbox
无效请求抹掉有效路由clear 的调用位置旧 set 隐式置 null失败默认不触碰 mailbox
修改解析规则导致邮箱用例全坏分层是否彻底mailbox 仍接收 raw input只接受 payload
HAR 外部 API 膨胀Index.ets 导出列表内部测试符号全部再导出区分文件导出与 barrel API
fake 需要实现大量系统字段parser 是否依赖 Want纯层仍绑 AbilityKit增加 ChaqiRouteInput 适配

源码可确认的当前事实是:解析函数全部留在文件内,模块级 pending 与解析函数同文件,set/take 是唯一公开行为,Index.ets 只导出这两个函数,HAR 单测仍是脚手架示例。本文提出的 Parser、Want adapter、Mailbox、Gateway、依赖注入与测试均为建议,没有修改 D:\ProgramData\huawei\lesson\chaqi-app-gitee-master。本次没有运行 Hypium、HAR 测试、HAP 构建、模拟器、真机或 CSDN 发布;这些代码片段需要在目标 SDK 中编译并按矩阵执行后,才能证明拆分在工程里真实可用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐