Solidity 变异测试详解:用故意制造的 bug 检验测试质量

理解变异测试如何通过注入变异体衡量测试套件质量、变异分数与 vertigo 等工具

6 分钟阅读
Solidity 变异测试详解:用故意制造的 bug 检验测试质量

Solidity 变异测试详解:用故意制造的 bug 检验测试质量

目录


一、定义与目的

变异测试是”通过故意往代码里注入 bug、检验测试能否抓到这些 bug,来衡量测试套件质量的方法”。

它不直接看代码覆盖率,而是衡量测试检测故意缺陷的有效性。核心问题:你的测试到底是真在验证逻辑,还是只是”跑过了代码但没断言什么”?

二、核心概念:变异体与测试质量

做法:创建语法合法的代码变体(变异体 mutant),这些变体理应让好的测试失败。例如:

// 原始
require(msg.value >= PRICE, "insufficient msg value");
// 变异(翻转不等号)
require(msg.value < PRICE, "insufficient msg value");

如果代码被这样变异后测试仍然通过,说明你的测试只是给了虚假的安全感,而非真正的保障。

三、三种变异结果

自动化工具把结果分三类:

结果含义
变异体被杀死(Killed)变异后测试失败——期望的结果,说明测试抓到了 bug
变异体存活(Survived)注入缺陷后测试仍通过——暴露测试薄弱
等价变异体(Equivalent)变异后字节码不变,有时表示死代码

存活的变异体是重点:每一个存活的变异体都对应一个”你的测试套件抓不住的潜在 bug”。

四、常见变异操作符

  • 删除函数修饰符(如删掉 onlyOwner);
  • 翻转不等比较(<>=><=);
  • 修改常量或把字符串替换成空;
  • 替换布尔值(true → false);
  • 交换逻辑运算符(&&||、位 &|);
  • 改变算术运算符(+- 等);
  • 删除或重排代码行。

五、覆盖率悖论

一个震撼的例子:把所有 assert 注释掉,测试达到 100% 行覆盖和分支覆盖,仍能”通过”所有变异。

覆盖率只告诉你代码被运行了且没 revert

这揭示了关键区别:执行覆盖 ≠ 行为验证。你可能跑遍了每一行,却没有任何有意义的断言去检验结果对不对。变异测试正是用来揪出这种”假覆盖”。

六、边界条件例子

差一错误(off-by-one)最能体现变异测试的价值:

uint256 public LIMIT = 5;
// 原始
require(amount < LIMIT, "exceeds limit");
// 变异
require(amount <= LIMIT, "exceeds limit");

用 amount = 3 和 8 测试虽然有覆盖,但漏掉了真正的边界 4 或 5——于是这个 <<= 的变异体存活了。变异测试逼你补上边界用例(amount = 4、5)。

七、Solidity 的变异测试工具

工具说明
Vertigo-rsRareSkills 维护的 Solidity 变异测试工具,支持 Foundry/Hardhat/Truffle,无需改代码,项目目录里直接跑
GambitCertora 出品
Universal Mutatorsambacha 出品

后两者生成变异但不自动重跑测试套件、也不出汇总报告;vertigo-rs 是端到端的。

八、变异分数

变异分数 = 被杀死的变异体百分比。100% 意味着测试能可靠检测所有意外的代码改动。

虽然大型传统软件做到 100% 不现实,但 Solidity 合约相对小,所以”存活的变异体应当被仔细审查”——每一个都可能是真实漏洞的信号。

九、前提:覆盖率要求

100% 行 + 分支覆盖是变异测试有效的前提。未覆盖的分支(如只被授权调用者测过的访问控制修饰符)在被删除时不会失败。

文章引用一个真实的 Code4rena 漏洞:缺失的 minter 访问控制没被抓到,正是因为那个修饰符分支没被正确测试——变异测试删掉修饰符时本应报警,但因为缺覆盖而沉默。

十、局限性

  • 主要评估无状态单元测试,无法天然检验有状态业务逻辑是否被正确测试(那是不变量测试的活,上一篇);
  • 工具因时间限制通常只跑部分可能的变异,可能漏掉重要 bug。

十一、总结

  • 变异测试通过注入语法合法的 bug(变异体)检验测试是否真在验证逻辑;
  • 结果分被杀死/存活/等价,存活的暴露测试薄弱;
  • 揭穿”覆盖率悖论”:100% 覆盖率 + 注释掉断言仍能通过;
  • 变异分数 = 杀死率,Solidity 合约小,应追求高分;
  • 前提是 100% 行/分支覆盖;用 vertigo-rs 等工具;
  • 与不变量测试互补:变异管单元测试质量,不变量管有状态逻辑。

十二、动手练习项目:变异测试实战 MutationKiller

项目目标

写一个有边界逻辑和访问控制的合约,先写”看似充分”的测试,用变异测试工具发现存活变异体,再补强测试直到变异分数 100%。

合约要求

SaleContract.sol

  • uint256 public constant PRICE = 1 ether;uint256 public constant MAX_PER_TX = 5;
  • buy(uint256 qty) external payablerequire(qty > 0 && qty <= MAX_PER_TX)require(msg.value >= PRICE * qty)、铸造逻辑;
  • withdraw() external onlyOwner:转出收益;
  • setPrice(uint256) external onlyOwner(埋一个边界)。

测试与变异要求

第一阶段:写”弱测试”

  1. 写一组只用”中间值”的测试(如 qty=3、msg.value 充足),达到 100% 行覆盖;
  2. 运行 vertigo-rs(或 Gambit),记录存活的变异体——预期会有:<=< 边界、&&||、删 onlyOwner、>= 翻转等存活。

第二阶段:杀死变异体 3. 针对每个存活变异体补测试:

  • 边界:测 qty = MAX_PER_TX(5)和 MAX_PER_TX+1(6),杀死 <=/< 变异;
  • msg.value 边界:恰好 PRICE*qty 和少 1 wei,杀死 >=/> 变异;
  • 访问控制:用非 owner 调 withdraw/setPrice 断言 revert,杀死”删 onlyOwner”变异;
  • 逻辑运算:qty=0 单独测,杀死 &&|| 变异;
  1. 重跑工具,目标变异分数 100%

要求记录

  1. 用表格记录:每个变异操作符 → 第一阶段存活/杀死 → 补了什么测试 → 第二阶段杀死;
  2. 复现”覆盖率悖论”:把断言注释掉,证明 100% 覆盖率下变异全部存活;
  3. 写一份 README 说明变异分数从 X% 提升到 100% 的过程。

验证步骤(本地)

  1. forge coverage 确认 100% 行/分支覆盖;
  2. 安装并运行 vertigo-rs:在项目目录执行,查看变异报告;
  3. 逐个分析存活变异体,补测试;
  4. 重跑直到 0 存活(除等价变异体外)。

进阶挑战(可选)

  • 手动制造一个”等价变异体”(变异后字节码不变),理解为什么它无法被杀死,是否提示死代码;
  • 写注释回答:为什么 100% 覆盖率不等于测试充分?变异测试和不变量测试分别补足了什么盲区?

💬 评论