Solidity 变异测试

文章介绍了突变测试的概念,即通过故意在代码中引入错误来检查测试套件的质量。文章详细举例说明了突变测试的应用,包括如何在Solidity代码中实施,并介绍了自动突变测试工具及其可能的结果。此外,还讨论了突变测试的重要性和局限性,并推荐了一些相关的学习资源。

变异测试是一种通过故意在代码中引入错误来检查测试套件质量的方法,并确保测试能够捕捉这些错误。

引入的错误类型是简单明了的。考虑以下示例:

// 原始函数
function mint() external payable {
    require(msg.value >= PRICE, "消息值不足");
}

// 变异函数
function mint() external public {
    require(msg.value < PRICE, "消息值不足");
}

在上面的示例中,不等号被翻转。如果单元测试仍然通过,那么单元测试无疑提供了虚假的保证。

重要的是这些错误在语法上是有效的,即仍然会生成可编译的 Solidity 代码。如果代码不能编译,那么就无法运行单元测试。

无测试的行覆盖率

让我们使用默认示例Foundry,在运行 forge init 后并注释掉 assert 语句。

// 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% 的行和分支覆盖率,尽管没有 assert 语句!这意味着我们可以随意引入错误,而测试仍然会通过。

当然,这显然是一个不应该出现的例子。但在追求覆盖率时,很容易意外犯这个错误。覆盖率仅仅告诉你你运行了代码,并且它没有恢复。你想要确保所有预期的状态变化实际发生(更多关于Solidity 单元测试最佳实践的信息,请参见我们的其他帖子)。

变异的种类

以下是一些可能有用的变异类型:

  • 删除函数修饰符
  • 反转不等式比较
  • 改变常量值或将字符串常量替换为空字符串
  • 用 false 替代 true
  • || 替代 &&,并用位运算 | 替代位运算 &
  • 交换算术运算符(例如,+ 变为 -
  • 删除行
  • 交换行

自动变异测试

手动根据上述规则变异代码并运行测试套件是一件相当繁琐的事情。因此,存在一些工具可以自动执行此操作。它们生成数十个潜在的变异,变异代码,运行测试套件,存储结果,并在之后生成报告。可能有三种结果:

  • 变异存活
  • 同值变异
  • 变异被杀死

变异存活意味着代码被改变而测试仍然通过。同值变异发生在运行变异后字节码没有改变的情况下。这可能会在符号随机替换为相同符号时发生,或者变异没有改变业务逻辑,编译器优化忽略了这个变化。

以下是一个可能发生同值变异的例子:

// 之前
x = x + 1;
y = y + 1;

// 之后
y = y + 1;
x = x + 1;

在某些情况下,编译器可能在这样的变异后生成相同的字节码。这就是同值变异。同值变异可能会指示不必要或无效的代码,正如以下示例所示:

require(false);
// 这里发生的任何事情都无关紧要

最后,变异被杀死的场景是理想的。这意味着代码被变异且测试失败。因此,测试能够实际检测到问题发生。如果变异导致代码无法编译,例如,删除一个在后续中使用的变量声明,那么该变异将被认为是被杀死。

100% 行和分支覆盖率对于变异测试的重要性

如果某一行或分支没有被覆盖,那么自然地变异这一行不会导致测试失败。

考虑以下示例:

function mint(address to_, string memory questId_) public onlyMinter {
    // 业务逻辑
}

此处隐含存在一个分支,使用 onlyMinter 修饰符。如果这个情况只在助记者调用函数时进行测试,那么删除 onlyMinter 并不会导致测试失败。如果 onlyMinter 修饰符没有阻止非助记者,那么单元测试不会捕捉到这一点。

顺便说一下,尽管这个例子看起来很牵强,它来自于真实的 codearena 报告

超出边界的错误和边界条件

变异测试可以用来捕获超出边界的错误。考虑以下变异:

uint256 public LIMIT = 5;

// 原始
function mint(uint256 amount) external {
    require(amount < LIMIT, "超过限制");
}

// 变异
function mint(uint256 amount) external {
    require(amount <= LIMIT, "超过限制");
}

如果我们的单元测试将 amount 设置为 3 和 8,代码在此测试下会有 100% 的分支覆盖率。然而,由于严格的不等式被替换为不等式,变异测试会失败,而测试仍然通过。这是因为测试没有准确表达预期的功能。具体来说,测试应该强制检查上限是 4 还是 5。测试 amount 的值如 3 或 8 并不能完全定义此函数的智能合约规范。

Vertigo-rs

RareSkills 积极维护一个用于 Solidity 的变异测试工具 vertigo-rs。该工具是从不再维护的 vertigo 仓库中分叉的,并且添加了对 Foundry 框架的支持。该工具适用于 Foundry、Hardhat 和 Truffle。运行该工具的说明在 Readme 中。无需对 Solidity 代码库或测试进行修改。只需克隆仓库,安装依赖项,然后在你正在测试的 Solidity 项目中运行它。

其他变异测试工具

尽管 vertigo-rs 是唯一一个自动运行测试套件的工具,但还有其他值得注意的工具用于生成变异(但它们不支持自动重新运行测试套件并总结结果)。

还有其他工具,但显然它们不再维护。

变异得分

除了 Solidity 之外的其他语言的工具有时提供一个 变异得分。这是被杀死变异的百分比。如果 100% 的变异被杀死,那么可以依赖单元测试检测代码库中的不希望或意外更改。

对于非常大的代码库,100% 的得分可能是不切实际的。与传统的代码库相比,Solidity 智能合约通常相对较小,例如大多数后端和前端应用程序。针对如此大的代码库追求 100% 的变异得分可能是不可行的。然而,由于 Solidity 智能合约相对较小,并且错误带来的后果是灾难性的,存活的变异应当仔细审查。

变异测试的局限性

由于变异测试测试的是单元测试的质量,而单元测试通常是无状态的,变异测试无法自然揭示有状态的业务逻辑是否正确测试。

变异测试能够产生数百个变异,但出于时间考虑,大多数工具仅运行其中的一部分。这意味着可能会错过揭露测试套件中错误的重要变异。

了解更多

这些材料是我们Solidity 实训营的一部分。你也可以通过我们的免费Solidity 课程免费学习 Solidity。

最初发布于 2023 年 4 月 14 日

  • 原文链接: rareskills.io/post/solid...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/