变异测试(Mutation testing)是一种通过在代码中故意引入错误,并确保测试能够捕获该错误,从而检查测试套件质量的方法。
引入的错误类型通常很直接。请看以下示例:
// original function
function mint() external payable {
require(msg.value >= PRICE, "insufficient msg value");
}
// mutated function
function mint() external public {
require(msg.value < PRICE, "insufficient msg value");
}
在上面的例子中,不等号运算符被翻转了。如果单元测试仍然通过,那么单元测试仅仅是在提供虚假的保证。
重要的是,这些错误在语法上必须是有效的,也就是说,依然能生成可编译的 Solidity 代码。如果代码无法编译,那么就不可能运行单元测试。
缺乏测试的行覆盖率
让我们使用运行 forge init 后 Foundry 提供的默认示例,并注释掉断言语句(assert statements)。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function testIncrement() public {
counter.increment();
//assertEq(counter.number(), 1);
}
function testSetNumber(uint256 x) public {
counter.setNumber(x);
//assertEq(counter.number(), x);
}
}
如果运行 forge coverage,我们会得到下表:

表面上看,尽管没有任何断言语句,我们在 Counter.sol 上却拥有了 100% 的行覆盖率和分支覆盖率!这意味着我们可以随意引入错误,而测试依然会通过。
当然,这是一个典型的错误示范。但在优化覆盖率时,人们很容易不小心犯下这个错误。覆盖率只能告诉你代码运行过且没有发生回滚(revert)。你需要确保所有预期的状态变化都实际发生了(有关更多信息,请参见我们关于 Solidity unit testing best practices 的另一篇文章)。
变异体类型
以下是一些可能有用的变异类型:
- 删除函数修饰符
- 翻转不等式比较
- 更改常量值或将字符串常量替换为空字符串
- 将 true 替换为 false
- 将
&&替换为||,将按位&替换为按位| - 交换算术运算符(例如
+变为-) - 删除代码行
- 交换代码行
自动化变异测试
按照上述规则手动对代码进行变异,然后再运行测试套件,这个过程会相当繁琐。因此,现有一些工具可以自动完成这项工作。它们会生成数十种潜在的变异,对代码进行修改,运行测试套件,保存结果,并在之后生成报告。结果可能出现以下三种情况:
- 变异体存活(mutant survived)
- 等价变异体(equivalent mutant)
- 变异体被杀死(mutant killed)
变异体存活意味着代码被修改了,但测试仍然通过了。当执行变异后字节码没有发生改变时,就会产生等价变异体。如果一个符号被随机替换为同一个符号,或者变异没有改变业务逻辑且编译器优化忽略了该更改,就可能发生这种情况。
以下是一个可能出现等价变异体的示例:
// before
x = x + 1;
y = y + 1;
// after
y = y + 1;
x = x + 1;
在某些情况下,像这样的变异后,编译器可能会生成相同的字节码。这就是等价变异。等价变异体可能暗示着不必要的或死代码(dead code),如下例所示:
require(false);
// anything that happens here doesn't matter
最后,变异体被杀死的情况是我们期望得到的结果。它意味着代码被变异,并且测试失败了。因此,这说明当出现问题时,测试确实能够将其检测出来。如果变异导致代码无法编译(例如,删除了后续会用到的变量声明),那么该变异体也会被认为是被杀死了。
100% 的行和分支覆盖率对变异测试至关重要
如果某一行或某个分支未被覆盖,那么对这一行进行变异自然不会导致测试失败。
请看以下示例:
function mint(address to_, string memory questId_) public onlyMinter {
// business logic
}
这里的 onlyMinter 修饰符隐含了一个分支。如果测试只覆盖了铸币者调用该函数的情况,那么删除 onlyMinter 将不会导致测试失败。如果 onlyMinter 修饰符未能阻止非铸币者调用,那么单元测试也无法捕获这个问题。
顺便说一句,尽管这个例子看起来像是刻意编造的,但它实际上取自真实的 codearena report。
差一错误(Off by One Errors)和边界条件
变异测试对于捕获差一错误非常有用。请看以下变异:
uint256 public LIMIT = 5;
// original
function mint(uint256 amount) external {
require(amount < LIMIT, "exceeds limit");
}
// mutation
function mint(uint256 amount) external {
require(amount <= LIMIT, "exceeds limit");
}
如果我们的单元测试将 amount 设置为 3 和 8,相对于该测试,代码将具有 100% 的分支覆盖率。然而,变异测试会失败,因为严格不等式被替换为非严格不等式后,测试依然通过了。这是因为测试没有准确地表达预期的功能。具体来说,测试应该强制验证上限是 4 还是 5。使用像 3 或 8 这样的 amount 值进行测试,并没有完全定义该函数的智能合约规范。
Vertigo-rs
RareSkills 积极维护着一个用于 Solidity 的变异测试工具 vertigo-rs。它是从已不再维护的 vertigo 仓库分支而来的。现在已经添加了对 Foundry 框架的支持。该工具兼容 Foundry、Hardhat 和 Truffle。运行该工具的说明在 Readme 中。无需对 Solidity 代码库或测试进行任何修改。只需克隆该代码库,安装依赖项,然后在正在测试的 Solidity 项目中运行它即可。
其他变异测试工具
虽然 vertigo-rs 是唯一能自动运行测试套件的工具,但还有其他一些值得注意的工具可以生成变异(不过它们不支持自动重新运行测试套件并汇总结果)。
- Gambit ——由 Certora 开发
- Universal Mutator ——由 sambucha 开发
另外还有一些其他工具,但显然它们已经不再维护了。
变异得分(Mutation Score)
除了 Solidity 之外的语言工具,有时会提供一个 mutation score(变异得分)。这是被杀死变异体的百分比。如果 100% 的变异体被杀死了,那么就可以信赖单元测试能够检测出代码库中不需要或意外的更改。
对于非常大的代码库,获得 100% 的得分可能不太实际。与传统代码库(例如大多数后端和前端应用程序)相比,Solidity 智能合约相当小。对于那么大的代码库,追求 100% 的变异得分可能是不可行的。但由于 Solidity 智能合约相对较小,且错误往往具有灾难性后果,因此应对幸存的变异体进行仔细审查。
变异测试的局限性
由于变异测试旨在测试单元测试的质量,而单元测试通常是无状态的,因此变异测试无法自然地表明有状态的业务逻辑得到了正确的测试。
变异测试可以创建数百种变异,但为了节省时间,大多数工具仅运行其中的一个子集。这意味着可能会遗漏能发现测试套件中错误的重要变异。
了解更多
本资料是我们 Solidity bootcamp 的一部分。您也可以通过我们的免费 Solidity course 免费学习 Solidity。
最初发布于 2023 年 4 月 14 日