Pangolin 开发规范 · 可测试性五支柱

「怎么写才好测」——开发规范是可测试性的前置条件 · 与测试框架咬合

核心立场:测试做不做得起来,根子在代码有没有遵守可测试的结构约定。哪里没遵守,哪里就测不了。这份规范把决定「能不能自动化测」的约定收成 5 条支柱,每条回答三件事:怎么写 → 所以测得了 → 由哪层守门。它与 测试框架架构说明 一一咬合:规范定义接缝,测试在接缝下刀。

五支柱速览

写法采用通用原则 + Pangolin 实例双层——原则可复制到任何多端项目,实例替换即可。

支柱 1 · 接缝即接口
所有跨进程 / 外部依赖(原生隧道、HTTP、子进程、存储、时钟、随机)藏在接口 / 可注入构造之后;业务逻辑里禁止直接 new 或调静态单例。分层单向依赖(token→实现→组件/页面),生成物 *.gen.dart 勿手改。
测试红利L2 集成/交互能换假替身,无需真依赖即可驱动真实路径。
验证钩子每个外部依赖都存在 Fake/Mock + 一条 flow 测试;依赖方向 lint(dart import / go internal 包边界)。
正 / 反例 VpnBridge 接口 → 可换 VpnBridgeMockAuthApi(baseUrl, http.Client) 可注入。 NETunnelProviderManager 直接散在 UI / 业务里 new。
支柱 2 · 契约单源
每段跨边界数据(pangolin/vpn/stats EventChannel、HTTP JSON、gRPC UsageEntry proto、设计 token)有唯一定义源,两端从它生成或校验,禁止两端各写各的。
测试红利L0 契约快照能守住「字段形状」,接缝两端永不漂移。
验证钩子契约快照测试(字段 diff=0)+ codegen 漂移检查(colors_and_type.css*.gen.dart、proto→桩,生成物与源一致)。
针对这条正是「mac 实时统计恒为 0」的根因解药——两端各写 stats、无单源、无守门。
支柱 3 · 纯逻辑与 IO 分离
计算 / 换算 / 状态机是纯函数,不混时间、网络、IO、随机;这些都当「可注入依赖」从外面传,IO 收进薄壳(functional core / imperative shell)。纯核心天然确定 → 不 flaky。
测试红利L1 单元无依赖、可重复、毫秒级;确定性是 E2E 不 flaky 的前提。
验证钩子覆盖率阈值;纯函数测试零 mock;时钟/随机可注入。
正例bytes→速率/时长 格式化、配额计算、时间 Go 端算好传 ?(不用 MySQL NOW())、golden 字体锁定
支柱 4 · 错误是有类型的值
错误带类型 / 码、可断言;禁止吞异常、禁止只打日志不返回。失败路径和成功路径一样要可观测、可测。
测试红利测试能断言失败分支(401、超额、连接失败),而不是只测 happy path。
验证钩子错误路径用例;lint 禁空 catch {} / 裸吞;错误类型可枚举。
正例AuthApiException(statusCode, messageZh, messageEn)——带码、带双语、可断言。
支柱 5 · 可观测即可断言
关键状态 / 事件经稳定通道暴露(status / stats stream、结构化日志、/healthz/v1/usage),让测试读数值断言,而非截屏猜
测试红利L4 端到端能断言:bytes 增长、限流触发、延迟 >0、状态流转。
验证钩子每个关键状态有可程序读取的出口;E2E 冒烟据此断言。
正例VpnStatusStreamHandler / stats EventChannel / /v1/usage 数值出口。

支柱 ↔ 测试层 咬合矩阵

每条支柱直接解锁测试架构里的某些层。这张图就是规范与测试的咬合证明:

图 1 · 五支柱解锁五测试层(● 主要解锁 ○ 间接帮助)

L0契约 L1单元 L2集成·交互 L3视觉 L4E2E 支柱 1 · 接缝即接口 支柱 2 · 契约单源 支柱 3 · 纯逻辑/IO 分离 支柱 4 · 错误是值 支柱 5 · 可观测 主要解锁 间接帮助

读法:补齐支柱 2(契约单源)就点亮 L0;做好支柱 1 才有 L2 的假桥;支柱 5L4 端到端能断言的唯一前提。规范不是道德要求,是测试能力的开关。

反例 → 真实 bug → 本可由哪条支柱防住

规范的说服力来自真实的坑。下表把项目里遇到/潜在的问题,回溯到被违反的支柱:

现象 / 真实 bug根因(违反了什么)本可由哪条支柱防住
mac 实时统计恒为 0 原生侧产出、Dart 侧消费 stats,两端各写、无单源;触发条件一变即静默失效,无任何测试守门。 支柱 2 契约单源 + 支柱 5 可观测
延迟显示 stale / 拿不到
Android·Windows urltest
延迟值没有稳定出口、没有契约校验,靠各端自行解析、刷新时机不一致。 支柱 5 可观测 + 支柱 2 契约单源
记账若用 MySQL 专属时间函数 时间/方言混进 SQL,无法在 SQLite 真库测、不可移植。 支柱 3 纯逻辑/IO 分离
时间 Go 端算、方言走 dialect
连接交互测不了
(通用风险)
连接逻辑直接依赖 NETunnelProviderManager / 真子进程,不连真隧道就无法驱动。 支柱 1 接缝即接口
藏到 VpnBridge 后面
失败路径不可测 / UI 卡死
(通用风险)
连接失败只打日志不返回、空 catch 吞异常,调用方拿不到可断言的错误。 支柱 4 错误是有类型的值

执行机制 · 四道闸

规范只靠文档一定漂。执行 = 尽量把每条往机器自动守门推,推不动的用脚手架降低犯错成本 + 评审清单兜底。这不是一个动作,是从「写之前」卡到「push」的四道闸——越早拦,成本越低。

图 2 · 四道闸:从「写之前」预防到「push」硬闸,外加评审兜底

← 越早拦,成本越低 写之前 写的时候 提交时 push · PR 闸 1 · 脚手架 接口 + Fake 骨架 codegen 默认 预防 闸 2 · lint/analyze 空 catch · import 方向 IDE 即时报 即时 闸 3 · pre-commit format · 快检 codegen 漂移检查 本地拦 闸 4 · CI 硬闸 全量测试 · 契约快照 覆盖率 · golden · E2E 挡 merge 评审清单(人治兜底) · 机器判不了的:接缝抽象是否合理 / 关键状态有没有留可观测出口

诚实区分「能不能机器强制」——能强制的硬卡 CI,强制不了的靠脚手架 + 评审兜底:

支柱主执行手段强制度现状
1 接缝即接口import 方向 lint + 脚手架 + 评审清单半自动待补
2 契约单源契约快照 diff=0 + codegen 漂移检查强制待补(OpenAPI 已有)
3 纯逻辑/IO 分离覆盖率阈值 + 可移植 SQL 扫描强制部分已有
4 错误是值lint 禁空 catch + 失败路径用例半自动待补
5 可观测关键状态出口评审 + L4 冒烟据出口断言半自动随 L4

这个仓库的落点:.gitea/workflows/ci.yml(shellcheck、OpenAPI 校验、红线词扫描、flutter analyze + test)= 闸 4 骨架已在。:契约快照、golden 进 CI、可移植 SQL 扫描、pre-commit hook、自定义 lint、PR checklist。

可复用:通用原则 vs 本项目实例

换到你别的多端项目时,五条原则不变,只替换右列实例:

通用原则Pangolin 实例(换项目时替换)
接缝即接口VpnBridge / KernelProcess / TokenStore / AuthApi(http.Client)
契约单源stats EventChannel · /v1/* JSON · UsageEntry proto · colors_and_type.css
纯逻辑/IO 分离速率/时长格式化 · 配额计算 · 时间 Go 端算 · dialect 方言层
错误是值AuthApiException · 控制面错误码
可观测status/stats stream · 结构化日志 · /v1/usage · /healthz

本规范为「怎么写」的真相源;「怎么验证」见 测试框架架构说明。两份配套维护,新增接缝时同步更新两边。