长安链模块测试 · 实战手册(Module Test Playbook)
给谁看 :长安链小组研发同学 看完能干嘛 :理解模块测试的本质,掌握 5 个可复用范式,照清单给自己的模块从零搭一套真正能"提前抓 bug"的模块测试
怎么读 (建议从头到尾顺序读):
- 第一篇 · 认知建立 (What & Why)—— 6 分钟
- 第二篇 · 规划 (Plan,⭐ 决定后续质量)—— 8 分钟
- 第三篇 · 方法论 (How,硬核)—— 16 分钟
- 第四篇 · 上手与答疑 (Do,按需查)
Part 1 · 现状:我们的测试体系缺了什么
我们目前主要靠两层测试保障代码质量—— 单元测试 和 集成测试(跑链) 。
1.1 单元测试:快但失真
✅ 能做的 :函数正确性、边界条件、分支覆盖、回归保护
单元测试是孤立的 ,直接调用方法,场景是人工构造的,不能反映节点真实运行时对存储的访问方式。
单测覆盖率到 90% 仍要靠跑链发现 bug,根因就在这里。
1.2 集成测试(跑链):真但慢
✅ 能做的 :端到端真实业务、跨模块协同、贴近生产
- 反馈周期 30 分钟到小时级 ——改一行代码验证一次很贵
- 出问题难定位
- 间歇性问题难复现,一天能跑的轮次有限
- 不适合 主动构造异常 (kill 进程 / 注入故障 / 蒙特卡洛)——成本太高
1.3 中间这一层是空的
模块修改往往要等集成到主项目、真正跑链才会暴露问题, 反馈周期很长 。
反馈周期 失真度
┌──── 单元测试 ──────────────────── 秒级 高
│
│ ⭐ 缺失的中间层 ←── 我们要补的就是这里
│
└──── 集成测试(跑链)────────── 小时~天 低
1.4 模块测试解决什么问题
一句话: 把目前只能靠跑链才能暴露的一类 bug,前移到分钟级反馈 。具体包括:
- 真实 DB / 文件系统在并发读写下的可见性、原子性问题
- 进程被强杀、磁盘异常等场景下的恢复正确性
- 模块在生命周期切换(Init / Reload / Close)时的竞态
- 上游用各种"真实姿势"调用时(混合读写、异步、批量)的组合 bug
这些 bug 单测看不到、集成测试看得到但代价大, 模块测试是性价比最高的拦截层 。
不集成主项目、不修改生产代码 ,在仓库内部模拟区块链全链路中对模块的真实访问路径,从而独立验证模块的功能正确性。
Part 2 · 模块测试是什么
2.1 一句话定义
在模块对外接口画一个圈,圈内全用真实代码,圈外用可控模拟器替代上下游,验证模块整体的契约。
2.2 视觉化
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌────────────────────┐ │
│ 上游模拟器 ──→│ │ │
│ (Driver) │ 被测模块 │←── 测试夹具 │
│ │ (100% 真实代码) │ (Fixtures) │
│ │ │ │
│ └─────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ 下游模拟器 │ │
│ │ (Fake / Stub) │ │
│ └────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ Verifier 验证器 │ │
│ │ (不变式断言) │ │
│ └────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
↑ ↑
│ │
圈外(替换) 圈内(真实)
- 圈内 :模块自己的所有代码 —— 真实结构体、真实 DB、真实并发、真实生命周期
- 圈外 :上下游依赖 —— Fake/Stub/模拟器,但行为"像真的"
- 圈的边界 由你画,原则是"工程视角的最小自洽单元"
2.3 与其他测试层级的对比
| 维度 | 单测 | 模块测试 | 集成测试 | E2E |
|---|---|---|---|---|
| 范围 | 单函数 | 单模块(多包) | 多模块 | 全系统 |
| 圈外依赖 | 全 mock | 可控模拟器 | 真实 | 真实 |
| DB | 一般 mock | 真实 DB | 真实 | 真实 |
| 并发 | 一般 mock | 真实 goroutine | 真实 | 真实 |
| 启动成本 | <1s | 秒~分钟 | 分钟~小时 | 小时 |
| 能抓的 bug | 逻辑错误 | 状态机 / 并发 / 生命周期 / 恢复 | 跨模块时序 | 真实业务 |
| 反馈周期 | 秒 | 分钟 | 小时 | 天 |
2.4 5 个特征认出模块测试
满足 4 条以上即是模块测试:
- ✅ 用 真实 DB / 文件系统 / 并发
- ✅ 涉及 单模块的多个包
- ✅ 上下游 可控地模拟 (Fake/Stub),不是直接 mock 接口
- ✅ 验证 状态、时序、资源、性能 契约(不只是返回值)
- ✅ 启动有秒级成本,但反馈远短于跑链
Part 3 · 为什么需要它
3.1 现有测试的三大盲区
盲区 1 · 组合性 bug :单测验证 A 对、B 对,但"A 之后调 B"可能错。
盲区 2 · 工程契约 :单测几乎不验证 goroutine 是否泄漏、Init 期间外部读会拿到什么、crash 后已 commit 数据是否还在。这些只有跑链时才暴露,修复成本极高。
盲区 3 · 失真的 mock :
真实 DB 有锁、batch、并发限制、错误恢复——mock 全过滤掉了。"单测 100% + 跑链炸"的根因常在这里。
3.2 模块测试如何填补
| 盲区 | 模块测试怎么解 |
|---|---|
| 组合性 bug | Driver 模拟"操作序列",不是孤立调用 |
| 工程契约 | 用"不变式 + 资源契约"显式断言 |
| 失真的 mock | 圈内全真,圈外才模拟,且模拟器"像真的" |
3.3 反馈周期的算术
传统:改代码(1m) → 单测(1m) → 集成(5m) → 跑链(30m) → 触发问题(数小时~天)
加模块测试:改代码(1m) → 单测(1m) → smoke(1.5s) → race(3s) → stress(30s)
反馈周期从「天」压到「分钟」 ,意味着一天能验证 100+ 次方案,错可以立即抓住。
3.4 它是持续生效的资产
不是一次性投入:每次改产品代码立即知道契约破没破;每发现新 bug 写个回归 case,永不再漏;新人 onboarding 跑测试就懂模块边界;重构有"行为不变"的保障。
Part 4 · 我的模块该不该做
4.1 高 ROI 信号(满足 ≥ 2 条强烈建议做)
- 有 持久化状态 (DB/文件/缓存落盘)
- 有 生命周期 (Init/Start/Stop/Restart/Recover)
- 有 并发 (多 goroutine / 读写并行 / 连接池)
- 耦合主流程 (必须跑链才能完整触发)
- 历史上 反复出 bug ,且都是"集成后才暴露"
- 跨节点行为有时序约束 (同步 / 共识 / 恢复)
4.2 低 ROI 信号(建议先用单测覆盖)
- 纯无状态工具包(utils / encoding / crypto)
- 接口频繁变动
- 模块代码量小,单测能覆盖 90%+
- 你只是改一个小 bug,不是建立长期资产
第二篇 · 规划:先想清楚再动手
跳过这一篇直接动手,写出来的测试只能是"功能清单"—— 永远抓不到真实运行时的问题 。
Part 5 · 规划三问
答不出来不要往下走 ——硬写也是无效测试。
| 问题 | 关注点 | 输出物 |
|---|---|---|
| Q1【位置】 | 我的模块在系统里扮演什么角色? | 位置图(方框+箭头) |
| Q2【访问模式】 | 上下游怎么访问我? | 访问模式清单 |
| Q3【并发关系】 | 这些访问之间是串行/并发/互斥? | 并发关系矩阵 |
三问 递进 :Q1 找出上下游 → Q2 摸清访问方式 → Q3 理出并发约束。
5.1 Q1【位置】
- 上游 (谁调我?):N 个调用方分别是什么角色?
- 下游 (我调谁?):依赖哪些外部组件?
- 对等模块 (兄弟模块):是否互相影响?
要点:用 真实角色名称 ("用户"/"调度器"/"对等节点"),不是"interface A";每条线标接口名 + 方向。
5.2 Q2【访问模式】
每个上游调我的方式可能完全不同:
- 写路径 vs 读路径
- 单点查询 vs 范围迭代 vs 批量更新
- 同步阻塞 vs 异步事件 vs 订阅推送
- 常规流量 vs 配置变更 vs 生命周期事件
每条访问模式都要回答: 谁、用什么接口、什么时机、什么数据量 。
5.3 Q3【并发关系】
最容易被忽视、最关键:
- 哪些路径 必须串行 (如出块流程:打包→执行→提交)?
- 哪些 天然并发 (如查询,多客户端同时来)?
- 哪些 并行共存 (出块串行,但出块期间允许并发查询)?
- 哪些 互斥 (关闭过程中不接受新写入)?
输出物可以是泳道图、时序图,或简单的"谁能和谁同时发生"二维表。
5.4 store 模块的规划
Q1【位置】store 在长安链中的角色
┌─────────────────────┐
出块流程 ────▶ PutBlock │
│ │ ┌──────────┐
查询客户端 ──▶ Get/Block/Tx ◀──▶ LevelDB │
│ │ │ BlockFile│
重启恢复 ────▶ Recovery │ └──────────┘
│ │
存储关闭 ────▶ Stop │
└─────────────────────┘
Q2【访问模式】4 条访问链路
用户发 invoke 交易
→ 节点打包区块
→ 执行交易(读取依赖数据,生成写集) ← 访问存储(读)
→ 提交区块(写入存储) ← 访问存储(写)
→ 打包下一个区块 ...
用户发 query 交易(与上述流程并行)
→ 直接查询存储 ← 访问存储(读)
生命周期 Init / Close 进程启停
执行交易这一步是关键: 虚拟机需要先读取依赖的状态数据,再根据合约逻辑生成写集 ,最后随区块一起提交。这是存储读写访问最密集的环节。
Q3【并发关系矩阵】(节选)
链上有两类交易,它们对存储的访问模式完全不同:
| 类型 | 进交易池 | 访问方式 | 并发关系 |
|---|---|---|---|
| invoke 交易 | 是 | 串行:打包→执行→提交(写入存储) | 出块流程串行推进 |
| query 交易 | 否 | 直接查询存储 | 可与出块流程并行发生 |
这个区别决定了测试框架的核心设计: invoke 路径串行,query 路径可以并发 ,两者可以同时进行。
第三篇 · 方法论
上一篇你已经画好了"位置图 + 访问模式清单 + 并发关系矩阵"三张图。这一篇回答 怎么把这三张图落地成可运行的测试代码 。 Part 8 / 9 / 11 是通用方法论;Part 10 在每个阶段会给一段 store 实例做步骤示范。
Part 8 · 五件套架构
任何模块的模块测试,都可以拆成这 5 个组件—— 这是范式的根 。
┌──────────────────────────────────────────────────────────────────────┐
│ ┌──────────────────┐ │
│ │ ① Driver 驱动器 │ 按真实工作流的调用序列驱动模块 │
│ └────────┬──────────┘ │
│ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ② 被测模块 │←──│ ④ Fixtures 夹具 │ │
│ │ 100% 真实代码 │ │ 种子数据 + 配置 │ │
│ └────────┬──────────┘ └──────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ ③ 上下游模拟器 │ Fake / Stub / Mock / Spy │
│ └────────┬──────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ ⑤ Verifier 验证器 │ 不变式 / 资源契约 / 性能契约 │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
① Driver 驱动器
职责 :拼装模块 + 依赖,按真实工作流的调用序列驱动模块。
❌ 写一堆 func TestXxx() { module.A(); assert(...); },没有"序列"概念 ✅ 抽象一个 Harness/Sim,封装完整的「Setup / RunScenario / AssertInvariants / Teardown」
模拟真实工作流,例如:链行为类要模拟"打包→执行→提交"循环;网络类要模拟"建连→收发→断连→重连";状态机类要模拟状态迁移序列。
② 被测模块(铁律:零侵入)
绝不为了测试修改产品代码 。破例会引入测试专用分支、让"测试通过"不等于"线上正确"、形成路径依赖(每加测试都想再开个 hook)。
唯一例外: 用户明确知情同意 + 强理由 + 写入 design 备案 。
③ 上下游模拟器(分层用)
| 类型 | 行为 | 何时用 |
|---|---|---|
| Fake | 行为正确但简化的实现 | 推荐,绝大多数场景 |
| Stub | 返回固定数据 | 只关心"输入"不关心"行为"时 |
| Mock | 验证调用次数 | 极少用,会和实现耦合 |
| Spy | 真实执行 + 旁路记录 | 验证状态自洽时(影子表) |
经验 :能用 Fake 就别用 Mock。Mock 验证"调用次数"非常容易和实现耦合,重构会全红。
④ Fixtures 测试夹具
核心要求 :可版本化、可重现、可裁剪。
// ❌ 每个用例自己造 50 行初始化
// ✅ 统一夹具库,一行调用:
block := fixtures.GenesisBlock()
workload := fixtures.SmallTxLoad(10)
⑤ Verifier 验证器(最重要)
铁律 :不要只用 assert.Equal,要用 不变式(Invariant) ——任何时刻都必须成立的事实。
| 类型 | 含义 |
|---|---|
| 功能 | 接口返回值正确 |
| 状态 | 内部状态自洽 |
| 时序 | 操作顺序约束成立 |
| 资源 | 资源不泄漏 |
| 性能 | 性能不退化 |
好不变式的特点: 可形式化 (能写成代码)、 跨场景适用 、 违反即 bug (不是主观判断)。
Part 9 · 五大核心范式
五件套是骨架,五大范式是肌肉。任何模块编写测试时都应该参照。
范式 A · 测试章程 CHARTER
是什么 :一篇 200~400 行 markdown,回答 4 个根本问题:为什么做(动机)/ 做什么·不做什么(边界)/ 怎么验收(不变式)/ 怎么推进(路线图)。
为什么 :对齐共识,避免做着做着大家理解不一样;拒绝镀金,明确"不做"清单;可评审,所有后续设计都引用它。
结构骨架 (详见附录 A):
1. 立项动机(Why) ── 列具体痛点 + 期望目标
2. 测试边界(What) ── 模块包含哪些包、哪些不在范围
3. 测试模型 ── 工作流形式化(W1/W2/W3...)
4. 不变式契约 ── INV-1 ~ INV-N,必须形式化
5. 历史故障清单 ── 转化为回归用例的来源
6. 验收标准 ── 覆盖率、反馈时长、bug 类型
7. 推进路线 ── M0~M5 里程碑
8. 零侵入约束 ── 哪些产品代码不能改
9. 场景扩展机制 ── 未来怎么加场景
反例 vs 正例 :
❌ "我们的不变式:所有接口都要返回正确" → 不可形式化 ❌ "测试覆盖率达到 80%" → 行覆盖不等于场景覆盖 ✅ "INV-2:在任何 commit 之后,影子状态表的所有 (k,v) 都能在 statedb 中查到,且字节相等" → 可断言
评审检查点 :
- 不变式 ≥ 5 条,每条能写成代码断言
- 历史故障 ≥ 3 个(少了说明没认真翻 git/TAPD)
- 工作流图覆盖 80%+ 真实调用路径
- 边界图清晰
范式 B · 不变式 INV 提炼方法
是什么 :不变式 = "任何时刻都必须成立的事实"。它 不是某个用例的局部断言 ,而是 所有用例都要满足的契约 。
为什么 :1 个不变式 N 个场景共用;能抓"组合性 bug"(A 操作+B 操作后才出现的状态错乱);逼你想清楚模块的真实契约。
怎么提:5 维提问法
| 维度 | 提问 | 例子 |
|---|---|---|
| 功能 | 模块对外的输出契约? | GetLast() 必须等于最后一次 Put() 成功的值 |
| 状态 | 内部多个状态之间的自洽性? | 索引 X 中存在的 key,主表 Y 必须也存在 |
| 时序 | 操作之间的顺序约束? | crash 后已 commit 的最大序号 ≥ crash 前 |
| 资源 | 资源不泄漏? | Close 后 goroutine = 启动前基线(容忍 ±2) |
| 性能 | 性能契约? | 单次 P99 < 50ms,吞吐回退不超过 20% |
实战技巧 :
- 从历史 bug 倒推 :每个历史 bug 都暗示一条不变式。修过"重启后丢数据",就有"重启前后已提交数据守恒"
- 5~10 条最佳 :少于 5 条说明没想清楚;多于 15 条说明不够抽象,应合并
- 必须能写成代码 :写不出
verifier.Assert<INV>(),说明不够形式化
❌ "INV:内部状态正确" → 含糊,不能断言 ✅ "INV:∀commit, ∀(k,v)∈shadow, store.Get(k) == v" → 形式化、可断言、跨用例
范式 C · 零侵入约束
是什么 :不为测试修改任何产品代码(接口签名、内部字段、错误类型、增加测试 hook 等都不行)。
为什么必须守 :测试改产品 → 测试通过 ≠ 线上正确;一旦破例,"为了测试再加个 hook"会无穷无尽,最终产品代码被腐蚀。
怎么做:先问"非侵入能不能做到"
90% 的"必须改产品代码"其实只是没想清楚 ——绝大多数前两步就能解决。
示范 :测崩溃恢复行为
❌ 改产品代码加 WithDBHandle 选项注入故障 DB → 改了产品 API ✅ 父子进程 + SIGKILL:父进程 fork 子进程跑真实 API,随机时刻 SIGKILL,再用同 DataDir 重启验证
父进程 ── exec.Command(self) ──→ 子进程(真实公开 API + 持续干活)
父进程 ── SIGKILL(随机时刻) ──→ × 子进程死
父进程 ── 同 DataDir 重启 ──→ 验证恢复行为
完全黑盒、零侵入,比内部 hook 更接近真实 OS 级 crash。
范式 D · 6 维场景骨架
场景 = 一组操作序列 + 对应的不变式断言。
很多团队的模块测试失败, 不是没测,是只测了"功能维度" 。下面 6 维是模块测试 真正的价值高地 。
D.1 生命周期 Lifecycle
测 Init/Start/Stop/Restart 各种顺序,模块在各阶段都自洽。
func TestLifecycle_InitCloseRestart(t *testing.T) {
h := harness.New(t).Build()
h.Start(); h.RunWorkload(10); h.Close()
h2 := harness.New(t).WithSameDataDir(h).Build() // 同 DataDir 重启
h2.Start()
verifier.AssertHeightPreserved(t, h, h2)
h2.Close()
}
D.2 异常恢复 Recovery
不同时刻 crash,重启后是否正确恢复。 用蒙特卡洛覆盖时机 ,不要写表驱动的固定时机点。
//go:build moduletest && spike
func TestRecovery_MonteCarloCrash(t *testing.T) {
for i := 0; i < 50; i++ {
cmd := startSubprocess(t)
time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
cmd.Process.Kill() // SIGKILL
h := harness.New(t).WithSameDataDir(cmd).Build()
verifier.AssertNoDataLoss(t, h)
h.Close()
}
}
D.3 并发压力 Concurrent
N 个 goroutine 并发读写,状态是否自洽。go test -race -count=3。
func TestConcurrent_ReadWhileWrite(t *testing.T) {
h := harness.New(t).Build(); defer h.Close()
var wg sync.WaitGroup
wg.Add(1); go func() { defer wg.Done(); writer.Run(h, 100) }()
for i := 0; i < 8; i++ {
wg.Add(1); go func() { defer wg.Done(); reader.Run(h, 10000) }()
}
wg.Wait()
verifier.AssertInvariants(t, h)
}
D.4 资源契约 Resource
goroutine / fd / 内存不泄漏。
func TestResource_NoGoroutineLeak(t *testing.T) {
base := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
h := harness.New(t).Build()
h.Start(); h.RunWorkload(100); h.Close()
}
time.Sleep(200 * time.Millisecond)
require.LessOrEqual(t, runtime.NumGoroutine()-base, 2)
}
D.5 数据兼容 Compat
旧版本数据能否被新代码正确读写。
func TestCompat_OldDataFormatUpgrade(t *testing.T) {
fixtures.UnzipOldDataDir(t, "v2.3.x.zip")
h := harness.New(t).WithDataDir(oldDir).Build()
verifier.AssertCanReadOldData(t, h)
h.RunWorkload(10); verifier.AssertInvariants(t, h)
}
D.6 长跑稳定 Soak
连续运行数小时,资源使用是否稳定。
//go:build moduletest && stress
func TestSoak_8Hour(t *testing.T) {
h := harness.New(t).Build(); defer h.Close()
deadline := time.Now().Add(8 * time.Hour); lastCheck := time.Now()
for time.Now().Before(deadline) {
h.RunOneIteration()
if time.Since(lastCheck) > 10*time.Minute {
verifier.AssertInvariants(t, h)
verifier.AssertResourceUsage(t, h) // 内存/fd/goroutine 不增长
lastCheck = time.Now()
}
}
}
维度 vs 模块特征
| 模块特征 | 必测维度 | 可选维度 |
|---|---|---|
| 持久化 | 生命周期、异常恢复 | 数据兼容 |
| 并发 | 并发压力、资源契约 | 长跑稳定 |
| 复杂状态机 | 生命周期、异常恢复 | 数据兼容 |
| 网络 IO | 资源契约、长跑稳定 | 异常恢复 |
| 计算密集 | 性能契约、长跑稳定 | / |
❌ 100 个用例全是"功能维度" → 抓不到任何工程 bug ❌ 用 mock 模拟 crash(mockDB.SimulateCrash) → 失真,不是真 crash ✅ 6 维至少覆盖 4 维,每维至少 2 个用例
范式 E · 扩展性显式化
一套模块测试体系最终好不好用, 不在于初版有多少用例 ,而在于" 别人想加新东西时,知不知道从哪儿下手 "。
痛点 :第一版整齐,跑三个月会变成——加新场景就复制改改、加新断言塞进现有 verifier、加故障注入硬编码到某个 case。结果 只有原作者改得动,新人不敢碰 。
解法:扩展点四象限
| 核心要素 | 扩展点形式 | "新增 X 只需 Y 步" |
|---|---|---|
| 访问模式 | 接口(如 Operation) | 实现接口 + 注册(2 步) |
| 断言指标 | 接口(如 Asserter) | 实现接口 + 注册(2 步) |
| 场景配置 | 数据结构(如 ScenarioConfig) | 新增一份配置(1 步) |
| 后端实现 | 工厂模式 | 在工厂注册表加一行(1 步) |
设计时 强制问自己 :「如果有人想加一个新的 X,他需要做几步?需要改我的代码吗?」 答案是"要改框架"或"超过 3 步"—— 这个扩展点设计得不够好 。
关键洞察:抽象的不是"调度器",而是"输入侧的多样性"
❌ 抽象 MockVM 接口:想加新交易类型 → 要改 MockVM → 重抽象 ✅ 抽象 Contract 接口:想加新交易类型 → 实现新 Contract + 注册 → 不动 VM
测过滤器:抽象不是"模拟节点",而是"模拟交易类型" 测共识:抽象不是"模拟网络",而是"模拟提案/投票模式" 永远抽象输入侧的多样性,不要抽象调度器
抽象的度
过度抽象 ❌ 抽象不足 ❌ 合理抽象 ✅
什么都做接口 什么都硬编码 只在外部扩展边界抽象
接口套接口 新增要改 5 处 内部协作用具体类型
没人看得懂 扩展全靠"约定" ScenarioConfig 是数据结构
判断标准 :只有"外部会扩展"的点才需要接口;内部协作直接用具体类型。
自检清单
- 新增"访问模式",是不是只要实现接口 + 注册?
- 新增"断言指标",是不是只要实现接口 + 注册?
- 新增"场景配置",是不是只要复制粘贴一份?
- 新增"故障注入点",是不是只要实现接口 + 注册?
- 新增"后端实现",是不是只要加一行配置?
- 上面 5 步 有没有任何一步要改框架代码 ?
方法论可以抄,扩展点必须自己想清楚 ——这是模块测试体系长期生命力的关键。
Part 10 · 推进 SOP(5 阶段流程)
每个阶段先讲「通用要求」,再用「store 实例」做一段步骤示范——你的模块情况不同,照搬不一定合适。
阶段0 准备 阶段1 骨架 阶段2 场景 阶段3 故障注入 阶段4 自动化
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
CHARTER 骨架跑通 覆盖核心场景 crash/资源/ CI 接入
+ 设计 冒烟用例 历史 bug 回归 长跑 + 门禁规则
阶段 0 · 准备
产出 :CHARTER.md(参考附录 A)
必答 6 题 :
- 模块边界:哪些包属于模块?哪些是外部依赖?
- 关键不变式:5~10 条,必须形式化
- 典型工作流:模块在主项目中被怎样调用?
- 历史故障:过去 1 年这个模块出过哪些 bug?
- 零侵入约束:哪些产品代码绝对不能改?
- 成功标准:覆盖率?反馈时长?要发现哪几类 bug?
关键交付动作 :找 1 位资深同事评审 1 小时,否则不进入阶段 1。
📌 store 实例
3 类工作流 :W1 链行为(写)/ W2 用户查询(并发读)/ W3 故障恢复(异常)
10 条不变式 (用 5 维提炼):INV-1 高度单调 / INV-2 影子表↔statedb 一致 / INV-3 并发查询不破坏写入 / INV-4 blockdb↔blockfiledb 一致 / INV-5 crash 后已提交高度不丢 / INV-6 Close 后 goroutine 回归基线 / INV-7 historydb 完整 / INV-8 Init 期间不接受外部读写 / INV-9 性能基线 ±20% / INV-10 Close 后调用不 panic
评审拦下 2 个坑 :① "INV:内部状态正确"被拦下,改写为 INV-2/4/7 三条形式化的;② "跨模块联调"被拦下,明确写入"不在范围"。
阶段 1 · 搭骨架
产出 :能跑通端到端 smoke test。
关键约束 :
- 用
//go:build moduletestbuild tag 隔离 - 不污染主项目目录
- 子目录独立 package
目录范式 :
moduletest/ ← 与产品代码隔离
├── CHARTER.md
├── design.md
├── Makefile ← smoke / race / stress / clean
├── driver/ ← Driver 集合
├── fakes/ ← 上下游 Fake
├── fixtures/ ← 测试夹具
├── verifier/ ← 不变式断言
├── harness/ ← 五件套拼装 + smoke
└── cases/ ← 测试用例
Smoke 标准 :
//go:build moduletest
func TestSmoke_BasicLifecycle(t *testing.T) {
h := harness.New(t).WithDefaultConfig().Build()
defer h.Close()
h.RunWorkload(10)
verifier.AssertBasicInvariants(t, h)
}
📌 store 实例
先 Spike 半天买"主路线保险" :用 spike_test.go 单文件验证 3 个关键未知点——SP-1 真实 Factory.NewStore() 公开 API 启动 ✅;SP-2 父进程 SIGKILL 后同 DataDir 重启能恢复 ✅;SP-3 macOS 行为差异 → 决策:crash 注入只在 Linux CI 跑。意外收获:发现 sender 身份依赖真实 PEM 证书,提前为 fixtures 留坑位。
骨架落地为 12 子包 (chainsim/usersim/runner/vm/shadow/fixtures/verifier/harness/spike 等)。当天结束 smoke 跑通:1.5 秒端到端。
阶段 2 · 填场景
优先级 (按 ROI 倒序):
P0 历史 bug 回归 ← 先做这个!每个历史 bug 一个 case
P1 工程 6 维场景 ← 重点:生命周期/恢复/并发/资源/兼容/长跑
P2 功能边界场景 ← 单测覆盖不到的接口契约
P3 性能基线 ← benchmark + 退化报警
P0 是最高 ROI :它们是过去真实漏掉的,未来很可能再漏。
📌 store 实例
- P1 状态维度(INV-2) :实现 shadow 影子表,chainsim 每次 commit 同步写。 关键陷阱 :必须"先 store 成功再 shadow",否则 false positive
- P1 并发维度(INV-3) :1 writer + 8 reader,160 万次随机查询,0 mismatch、
-race无告警 - P1 资源维度(INV-6) :
runtime.NumGoroutine全局对比 → 当场抓到 F-002 (Close 后 7 个 goroutine 残留) - teardown 时重复 Close → 当场抓到 F-001 (Close 不幂等会 panic)
两个 bug 当天写入 findings.md, 暂不修产品代码 ,作为已知缺陷登记入档。
阶段 3 · 故障注入
参考范式 C 零侵入约束——优先用真实公开 API + 进程级手段(SIGKILL、cgroup、tc)模拟故障,而不是改产品代码加注入点。
这个阶段往往是 抓真 bug 的高峰期 。 关键技巧 :用蒙特卡洛随机化覆盖时机,不要固定时机点。
📌 store 实例
50 分钟实现 runner/subprocess 子进程 SIGKILL 驱动:
for i := 0; i < 50; i++ { ← R=50 蒙特卡洛
cmd := exec.Command(self, "-test.run=...")
cmd.Start()
time.Sleep(rand.Duration(0~500ms)) ← 随机时机
cmd.Process.Kill() ← SIGKILL
h := harness.New(t).WithSameDataDir(...).Build()
verifier.AssertNoDataLoss(t, h) ← INV-5
verifier.AssertNoCorruption(t, h)
}
第一轮就抓到 F-003 :早期 crash(前 3 个块以内)后重启,GetLastBlock() 100% 返回 nil header。这个 bug 任何单测、任何集成都不可能复现,因为它只在"OS 级 crash + 极早期"才发生。
教训 :R 一开始设 10 太少没复现,调到 50 才稳定——故障注入要有"命中分布度量",不能跑通就以为 OK。
阶段 4 · 自动化与门禁
三档测试 (按反馈周期分层):
smoke: ## <2s,每次 commit
go test -tags=moduletest -timeout=30s ./moduletest/harness/...
race: ## <10s,每次 MR
go test -tags=moduletest -race -count=3 -timeout=2m ./moduletest/...
stress: ## <2min,nightly
go test -tags=moduletest,stress -timeout=10m ./moduletest/...
spike: ## crash 注入,nightly(仅 Linux)
go test -tags=moduletest,spike -timeout=10m ./moduletest/runner/...
MR 门禁 :smoke/race 不通过禁止合入;性能回退 >20% 需 owner 显式确认;新增公共接口未补模块测试需 owner 显式确认。
📌 store 实例
| 档位 | 耗时 | 触发时机 |
|---|---|---|
| smoke | 1.5s | 每次 commit |
| race | 3s | 每次 MR |
| stress | 30s | nightly |
| spike | 50s | nightly(仅 Linux) |
反馈周期从过去"小时~天"压到"秒级"。
Part 11 · 验证体系:怎么知道测试本身好
测试体系本身也需要被验证。建立 4 层。
L1 · 场景覆盖率(不是行覆盖率)
行覆盖率会骗人——70% 行覆盖可能只测了 30% 场景。维护 scenarios.md,列出所有应测场景,每个标记 ✅/❌:
W1 工作流:[x] S1 正常顺序提交 [x] S2 大数据量 [ ] S3 热点 key(TODO)
W3 异常路径:[x] S20 操作中途 crash [ ] S21 Init 期间 crash(TODO)
L2 · 突变测试(金标准)
工具:go-mutesting。原理:自动改产品代码(取反 if、删 ++ 等),看测试能否抓住。
- 抓住率 > 70%:质量良好
- 50%~70%:还可以,建议补 case
- < 50%:测试形同虚设
L3 · 故障注入
在 Fake 层注入:随机延迟、随机失败:
L4 · 真实链回放 (最高境界)
从测试网录制一段真实交易流离线回放,能发现"测试夹具不够真实"的盲区。适合稳定后期投入,前期不必强求。
第四篇 · 上手与答疑
Part 12 · 你的模块上车清单
12.1 上手 10 步(约 1 周)
| 时间 | 步骤 | 产出 |
|---|---|---|
| Day 1 上午 | 列模块边界(包依赖图)+ 翻历史 bug 清单 | 包关系图 + history-bugs.md |
| Day 1 下午 | 写 CHARTER.md(5~10 条 INV) | CHARTER.md |
| Day 2 | 写 design.md + 评审 | design.md + 评审记录 |
| Day 3 上午 | Spike:跑通最小 PoC(不要骨架) | spike_test.go 通过 |
| Day 3 下午 | 搭骨架目录 + 子包空文件 | 目录树 |
| Day 4 | 实现 harness + Driver + Smoke | smoke 跑通 |
| Day 5 上午 | 实现 Verifier + 不变式断言 | 不变式可断言 |
| Day 5 下午 | 填 P0 历史 bug 回归用例 | 3~5 个回归用例 |
| Day 6 | CI 接入 + Makefile + 门禁规则 | CI green |
PoC(Proof of Concept,概念验证) = 用最少代码、最快时间,验证一个关键想法能不能跑通。可以丑、可以硬编码、跑完即丢——它是"主路线保险",不是骨架。
12.2 命名规范
| 类型 | 命名模式 | 示例 |
|---|---|---|
| Smoke | TestSmoke_<场景> | TestSmoke_BasicLifecycle |
| 历史 bug 回归 | TestRegression_<bug-id>_<keyword> | TestRegression_BUG123_EarlyCrash |
| 生命周期 | TestLifecycle_<场景> | TestLifecycle_InitCloseRestart |
| 恢复 | TestRecovery_<场景> | TestRecovery_MonteCarloCrash |
| 并发 | TestConcurrent_<场景> | TestConcurrent_ReadWriteRace |
| 资源 | TestResource_<契约> | TestResource_NoGoroutineLeak |
| 兼容 | TestCompat_<场景> | TestCompat_OldDataFormat |
| 长跑 | TestSoak_<时长> | TestSoak_8Hour |
| 性能基线 | BenchmarkBaseline_<操作> | BenchmarkBaseline_PutBlock |
12.3 可复用工具(建议沉淀到 common 库)
| 工具 | 用途 |
|---|---|
harness.Builder | 通用骨架拼装 |
runner.Subprocess | 子进程 SIGKILL 驱动 |
verifier.LeakChecker | goroutine/fd 泄漏检测 |
verifier.InvariantRunner | 不变式驱动器 |
shadow.MapShadow | map 影子表 |
fixtures.SignerProvider | 合法身份夹具 |
建议放到
chainmaker.org/common/moduletest/下作为公共库共享。
12.4 自检清单(CHARTER 评审用)
- 边界清晰 :能画出"圈内/圈外"包依赖图
- 不变式 ≥ 5 条 ,每条可形式化为代码断言
- 历史 bug ≥ 3 个 ,全部在场景表里
- 6 个工程维度 至少覆盖 4 个
- 零侵入 :所有依赖通过公开 API 访问
- 可重现 :fixtures 有版本,数据可重建
- CI 三档 :smoke / race / stress 已规划
- 失败可定位 :日志能看到不变式失败时的关键状态
Part 13 · FAQ
Q1:模块测试和单测有什么本质区别? 单测验证"这个函数对不对",模块测试验证"整个模块在工作流中的契约对不对"。前者细胞级,后者器官级。
Q2:能不能直接抄已有模块的 moduletest 目录? 框架可以抄(harness/runner/verifier 套路),不变式和场景必须自己提——那是模块语义的体现。
Q3:为什么不直接用 chainmaker-go 跑集成测试? 集成测试反馈周期是分钟~小时级。模块测试的目标是把"编译→反馈"压到秒级,这只有"圈内全真+圈外模拟"才能做到。
Q4:为什么坚持零侵入?灵活点不行吗? 一旦破例,"为了测试加 hook" 会无穷无尽,最终产品代码被测试腐蚀。唯一例外:用户明确知情同意,且写入 design.md 备案。
Q5:不变式提不出来怎么办? 3 个方法:① 翻历史 bug,每个 bug 倒推一条;② 找资深同事盘 1 小时;③ 先写 5 条最 obvious 的(接口契约级),等场景写多了自然会发现新的。
Q6:历史 bug 太多(几十个),怎么挑? 按 (影响范围 × 复发概率) 排序,先做 Top 10,剩下的批次补。
Q7:fixtures 数据从哪里来? 3 种:① 手写最小化数据(推荐);② 真实链导出(适合 compat 测试);③ 随机生成(适合 fuzz)。
Q8:goroutine 数对比总是不稳定(±3),是否要紧? 不稳定来自 GC、定时器等系统 goroutine。允许容忍 ±2~3,重点看"多次启停后是否单调增长"。
Q9:CI 上跑不通本地能跑通,怎么办? 80% 是文件系统/时序差异。建议:① 子进程方案锁定 Linux only;② 时序断言用 Eventually 替代固定 sleep;③ 临时目录每次清理。
Q10:测试代码越写越复杂,比产品代码还多,正常吗? 正常。成熟的模块测试套件代码量经常达到产品代码的 50%~80%。但 测试代码必须比产品代码更"无聊" :少抽象、少黑魔法、读起来像剧本。
Q11:写完一遍后谁来维护? 改产品代码的人维护对应的测试 。PR 模板必须强制:改了产品代码必须改/加测试,否则 review 不过。
Q12:和 codebuddy / chainmaker-test-flow 怎么结合?
bugfix-flow已强制"单测先行",可扩展为"模块测试先行",对生命周期/并发类 bug 强制要求- 计划做 codebuddy skill
chainmaker-moduletest-bootstrap:读模块 + 历史 bug,自动生成 moduletest 骨架 PR
附录 A · CHARTER 模板
复制到
<your_module>/moduletest/CHARTER.md,填空即可。
# <模块名> 模块测试章程(CHARTER)
> 版本:v1.0 · 日期:YYYY-MM-DD(周X) · 作者:<你>
## 1. 立项动机
### 1.1 当前痛点
- 痛点 1:xxx(举一个具体的过去 bug 例证)
- 痛点 2:xxx
### 1.2 期望目标
- 反馈周期从 _____ 压到 _____
- 至少能提前发现以下类型的 bug:_____
## 2. 测试边界
### 2.1 模块包含
- 包:xxx/xxx, xxx/xxx
- 公开接口:interface XxxStore { ... }
### 2.2 不在范围
- 不做:跨模块联调
- 不做:性能压测专项
## 3. 测试模型
### 3.1 工作流
- W1:xxx 路径
- W2:xxx 路径
- W3:xxx 路径(异常)
## 4. 不变式契约
| ID | 类别 | 描述 | 适用工作流 |
|---|---|---|---|
| INV-1 | 功能 | ... | W1 |
| INV-2 | 状态 | ... | W1, W2 |
| INV-3 | 并发 | ... | W2 |
| INV-4 | 时序 | ... | W3 |
| INV-5 | 资源 | ... | All |
## 5. 历史故障清单
| Bug ID | 简述 | 修复 commit | 转化为用例名 |
|---|---|---|---|
| BUG-xxx | ... | xxx | TestRegression_xxx |
## 6. 验收标准
- [ ] 不变式覆盖率 ≥ 80%
- [ ] 历史 bug 回归 100%
- [ ] race detector 通过
- [ ] CI smoke/race/stress 全绿
- [ ] 反馈周期:smoke <2s, race <10s, stress <2min
## 7. 推进路线
- M0 准备:CHARTER + design 评审通过
- M1 骨架:smoke 跑通
- M2 场景:P0 历史 bug 全部回归
- M3 故障:crash 注入
- M4 自动化:CI 接入
## 8. 零侵入约束
- 不修改:产品代码任何文件
- 不导出:internal sentinel error
- 不新增:产品包导出 hook
- 例外审批:(如有,列出)
## 9. 场景扩展机制
- 新增场景:cases/ 下加测试文件
- 新增不变式:实现 Verifier 接口 + 注册
- 新增 Mock:实现接口 + Builder Option
附录 B · Checklist
启动前
- 已读完第一篇 + 第二篇
- 已找模块 owner 对齐意愿
- 已盘点历史 bug 清单(≥ 3 个)
- 已找参考模块(推荐 store/moduletest)
CHARTER 阶段
- CHARTER.md 写完,资深同事评审通过
- 不变式 ≥ 5 条,全部可形式化
- 6 个工程维度至少覆盖 4 个
骨架阶段
- 目录按范式建立
-
//go:build moduletest生效 - smoke test 跑通
- Makefile 三档可用
场景阶段
- P0 历史 bug 全部转为回归用例
- P1 6 维至少 1 个用例/维度
- race detector 跑通
- 真的抓到至少 1 个 bug(说明测试不是摆设)
自动化阶段
- CI 接入 smoke + race
- MR 门禁规则启用
- README 写明跑法
附录 C · 参考资料
长安链内部参考
store/moduletest/CHARTER.md—— 完整章程实例store/moduletest/design.md—— 1100+ 行设计文档store/moduletest/findings.md—— 真实 bug 跟踪store/moduletest/Makefile—— 三档测试命令
工具
- go-mutesting —— 突变测试
- go-leak —— goroutine 泄漏检测
go test -race—— Go 内置竞态检测runtime.NumGoroutine()—— 资源契约辅助
经典文献
- Martin Fowler, "Test Double" —— Fake/Stub/Mock/Spy 概念
- Google SRE Workbook · Chapter 17 "Testing for Reliability"
- "Out of the Tar Pit" —— 复杂性来自状态
收尾
模块测试不是银弹,但它是 当前长安链工程链路上 ROI 最高的缺失环节 。
你的下一步 :
- 选你最熟悉、最痛苦的一个模块
- 按附录 A 模板,半天写出 CHARTER.md
- 找一位同事评审 1 小时
- 跟 owner 申请 5 天预算
- 跑一遍,把抓到的 bug 写进 findings.md
- 把过程沉淀到这本手册的下一版
欢迎来
store/moduletest/借鉴一切可复用代码。 更欢迎你在自己的模块上踩出新的坑,反哺这本手册 v3.0。