跳转至

长安链模块测试 · 实战手册(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 条以上即是模块测试:

  1. ✅ 用 真实 DB / 文件系统 / 并发
  2. ✅ 涉及 单模块的多个包
  3. ✅ 上下游 可控地模拟 (Fake/Stub),不是直接 mock 接口
  4. ✅ 验证 状态、时序、资源、性能 契约(不只是返回值)
  5. ✅ 启动有秒级成本,但反馈远短于跑链

Part 3 · 为什么需要它

3.1 现有测试的三大盲区

盲区 1 · 组合性 bug :单测验证 A 对、B 对,但"A 之后调 B"可能错。

盲区 2 · 工程契约 :单测几乎不验证 goroutine 是否泄漏、Init 期间外部读会拿到什么、crash 后已 commit 数据是否还在。这些只有跑链时才暴露,修复成本极高。

盲区 3 · 失真的 mock

mockDB.On("Get", "key").Return("value", nil)

真实 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%

实战技巧

  1. 从历史 bug 倒推 :每个历史 bug 都暗示一条不变式。修过"重启后丢数据",就有"重启前后已提交数据守恒"
  2. 5~10 条最佳 :少于 5 条说明没想清楚;多于 15 条说明不够抽象,应合并
  3. 必须能写成代码 :写不出 verifier.Assert<INV>(),说明不够形式化

❌ "INV:内部状态正确" → 含糊,不能断言 ✅ "INV:∀commit, ∀(k,v)∈shadow, store.Get(k) == v" → 形式化、可断言、跨用例


范式 C · 零侵入约束

是什么 :不为测试修改任何产品代码(接口签名、内部字段、错误类型、增加测试 hook 等都不行)。

为什么必须守 :测试改产品 → 测试通过 ≠ 线上正确;一旦破例,"为了测试再加个 hook"会无穷无尽,最终产品代码被腐蚀。

怎么做:先问"非侵入能不能做到"

1. 能否通过真实公开 API + 外部观察 完成验证?
        ↓ 不能
2. 能否通过 OS / 进程级手段(信号、cgroup、tc)替代?
        ↓ 不能
3. 才考虑改产品代码(需评审 + 写明动机)

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

// ✅
type Contract interface {
    Name() string
    Execute(store Store, args ...) (RWSet, error)
}

测过滤器:抽象不是"模拟节点",而是"模拟交易类型" 测共识:抽象不是"模拟网络",而是"模拟提案/投票模式" 永远抽象输入侧的多样性,不要抽象调度器

抽象的度

过度抽象 ❌            抽象不足 ❌            合理抽象 ✅
什么都做接口          什么都硬编码           只在外部扩展边界抽象
接口套接口            新增要改 5 处          内部协作用具体类型
没人看得懂            扩展全靠"约定"         ScenarioConfig 是数据结构

判断标准 :只有"外部会扩展"的点才需要接口;内部协作直接用具体类型。

自检清单

  • 新增"访问模式",是不是只要实现接口 + 注册?
  • 新增"断言指标",是不是只要实现接口 + 注册?
  • 新增"场景配置",是不是只要复制粘贴一份?
  • 新增"故障注入点",是不是只要实现接口 + 注册?
  • 新增"后端实现",是不是只要加一行配置?
  • 上面 5 步 有没有任何一步要改框架代码

方法论可以抄,扩展点必须自己想清楚 ——这是模块测试体系长期生命力的关键。


Part 10 · 推进 SOP(5 阶段流程)

每个阶段先讲「通用要求」,再用「store 实例」做一段步骤示范——你的模块情况不同,照搬不一定合适。

阶段0 准备       阶段1 骨架       阶段2 场景       阶段3 故障注入    阶段4 自动化
   │                │                │                │                │
   ▼                ▼                ▼                ▼                ▼
CHARTER          骨架跑通          覆盖核心场景      crash/资源/      CI 接入
+ 设计           冒烟用例          历史 bug 回归    长跑              + 门禁规则

阶段 0 · 准备

产出 :CHARTER.md(参考附录 A)

必答 6 题

  1. 模块边界:哪些包属于模块?哪些是外部依赖?
  2. 关键不变式:5~10 条,必须形式化
  3. 典型工作流:模块在主项目中被怎样调用?
  4. 历史故障:过去 1 年这个模块出过哪些 bug?
  5. 零侵入约束:哪些产品代码绝对不能改?
  6. 成功标准:覆盖率?反馈时长?要发现哪几类 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 moduletest build 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 层注入:随机延迟、随机失败:

fakes.WithErrorRate(0.01)               // 1% 错误率
fakes.WithLatency(50*time.Millisecond)  // 模拟慢盘

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 最高的缺失环节

你的下一步

  1. 选你最熟悉、最痛苦的一个模块
  2. 按附录 A 模板,半天写出 CHARTER.md
  3. 找一位同事评审 1 小时
  4. 跟 owner 申请 5 天预算
  5. 跑一遍,把抓到的 bug 写进 findings.md
  6. 把过程沉淀到这本手册的下一版

欢迎来 store/moduletest/ 借鉴一切可复用代码。 更欢迎你在自己的模块上踩出新的坑,反哺这本手册 v3.0。

评论