Solidity 变异测试详解:用故意制造的 bug 检验测试质量
目录
- 一、定义与目的
- 二、核心概念:变异体与测试质量
- 三、三种变异结果
- 四、常见变异操作符
- 五、覆盖率悖论
- 六、边界条件例子
- 七、Solidity 的变异测试工具
- 八、变异分数
- 九、前提:覆盖率要求
- 十、局限性
- 十一、总结
- 十二、动手练习项目:变异测试实战 MutationKiller
一、定义与目的
变异测试是”通过故意往代码里注入 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-rs | RareSkills 维护的 Solidity 变异测试工具,支持 Foundry/Hardhat/Truffle,无需改代码,项目目录里直接跑 |
| Gambit | Certora 出品 |
| Universal Mutator | sambacha 出品 |
后两者生成变异但不自动重跑测试套件、也不出汇总报告;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 payable:require(qty > 0 && qty <= MAX_PER_TX)、require(msg.value >= PRICE * qty)、铸造逻辑;withdraw() external onlyOwner:转出收益;setPrice(uint256) external onlyOwner(埋一个边界)。
测试与变异要求
第一阶段:写”弱测试”
- 写一组只用”中间值”的测试(如 qty=3、msg.value 充足),达到 100% 行覆盖;
- 运行 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 单独测,杀死
&&→||变异;
- 重跑工具,目标变异分数 100%。
要求记录
- 用表格记录:每个变异操作符 → 第一阶段存活/杀死 → 补了什么测试 → 第二阶段杀死;
- 复现”覆盖率悖论”:把断言注释掉,证明 100% 覆盖率下变异全部存活;
- 写一份 README 说明变异分数从 X% 提升到 100% 的过程。
验证步骤(本地)
forge coverage确认 100% 行/分支覆盖;- 安装并运行 vertigo-rs:在项目目录执行,查看变异报告;
- 逐个分析存活变异体,补测试;
- 重跑直到 0 存活(除等价变异体外)。
进阶挑战(可选)
- 手动制造一个”等价变异体”(变异后字节码不变),理解为什么它无法被杀死,是否提示死代码;
- 写注释回答:为什么 100% 覆盖率不等于测试充分?变异测试和不变量测试分别补足了什么盲区?