Foundry Fuzz Testing:让测试自动寻找 Solidity Bug

Fuzz Testing(模糊测试)

普通测试是我们自己指定输入:

perl 复制代码
function test_Add() public {
    counter.setNumber(10);
    assertEq(counter.number(), 10);
}

但 Solidity 合约可能面对成千上万种输入。

难道每一种情况都手动测试?

显然不现实。

Fuzz Testing 的思路就是:

让测试框架自动生成大量不同的输入,尝试找到程序中的异常情况。


一、什么是 Fuzz Testing?

假设我们有一个简单的函数:

ini 复制代码
function withdraw(uint256 amount) public {
    require(amount <= balance[msg.sender], "insufficient balance");

    balance[msg.sender] -= amount;
}

传统测试可能只测试:

ini 复制代码
amount = 10
amount = 100
amount = 1000

但真正的问题可能出现在:

ini 复制代码
amount = 0
amount = 1
amount = type(uint256).max

甚至某个非常特殊的数值。

Fuzz Testing 就是让框架自动尝试:

erlang 复制代码
0
1
10
100
999
...

然后检查:

无论输入是什么,程序是否始终满足我们定义的规则?


二、Foundry 的 Fuzz Test

Foundry 的 Fuzz Test 非常简单。

只需要给测试函数增加参数:

perl 复制代码
function testFuzz_SetNumber(uint256 x) public {
    counter.setNumber(x);

    assertEq(counter.number(), x);
}

注意这里:

复制代码
uint256 x

并不是我们手动传入。

而是:

Foundry 自动生成 x。

执行:

bash 复制代码
forge test

Foundry 会自动运行大量不同的输入。


三、Fuzz Testing 的核心

普通测试:

perl 复制代码
function test_SetNumber() public {
    counter.setNumber(100);

    assertEq(counter.number(), 100);
}

相当于:

复制代码
固定输入 → 执行 → 检查

Fuzz Testing:

perl 复制代码
function testFuzz_SetNumber(uint256 x) public {
    counter.setNumber(x);

    assertEq(counter.number(), x);
}

相当于:

markdown 复制代码
自动生成输入
      ↓
   执行合约
      ↓
   检查断言
      ↓
发现失败?
      ↓
保存导致失败的输入

所以 Fuzz Test 最重要的不是:

"运行很多次"

而是:

尝试寻找能够破坏程序性质的输入。


四、一个真正容易出现 Bug 的例子

假设我们写了一个 Token:

ini 复制代码
contract Token {
    mapping(address => uint256) public balance;

    function deposit(uint256 amount) public {
        balance[msg.sender] += amount;
    }

    function withdraw(uint256 amount) public {
        require(
            balance[msg.sender] >= amount,
            "insufficient balance"
        );

        balance[msg.sender] -= amount;
    }
}

我们希望保证:

复制代码
withdraw 之后
余额不能变成负数

可以写:

scss 复制代码
function testFuzz_Withdraw(uint256 depositAmount) public {
    token.deposit(depositAmount);

    uint256 beforeBalance = token.balance(address(this));

    token.withdraw(depositAmount);

    assertEq(
        token.balance(address(this)),
        beforeBalance - depositAmount
    );
}

这里:

复制代码
depositAmount

由 Foundry 自动生成。


五、Fuzz 最有价值的地方:边界值

很多 Bug 并不是出现在正常输入,而是出现在:

边界条件。

例如:

ini 复制代码
uint256 x;

它的范围是:

复制代码
0 ~ 2^256 - 1

普通测试很难覆盖:

lua 复制代码
0
1
type(uint256).max
type(uint256).max - 1

而这些值恰恰可能触发:

  • 整数溢出
  • 下溢
  • 除零
  • 边界判断错误
  • 权限判断错误
  • 价格计算错误

所以 Fuzz Testing 特别适合 Solidity。


六、Foundry 会保存失败的输入

假设我们的代码存在 Bug:

java 复制代码
function calculate(uint256 x) public pure returns (uint256) {
    return 100 / x;
}

然后测试:

scss 复制代码
function testFuzz_Calculate(uint256 x) public {
    calculate(x);
}

如果 Foundry 找到了:

ini 复制代码
x = 0

导致:

csharp 复制代码
division by zero

测试失败时,Foundry 会把导致失败的输入报告出来。

这非常重要。

因为我们不需要自己猜:

"到底哪个输入导致 Bug?"

Foundry 会告诉我们:

ini 复制代码
Counterexample:
x = 0

这就是 Counterexample(反例) 。


七、Fuzz Testing ≠ 随机乱测

这是理解 Fuzz Testing 很重要的一点。

它不是简单地:

复制代码
随机生成一个数字
随机生成一个数字
随机生成一个数字

然后碰运气。

测试框架会围绕输入空间进行探索,并在发现失败时给出能够复现问题的输入。

因此一个好的 Fuzz Test,本质上是在描述:

程序必须永远满足什么性质?

例如:

perl 复制代码
function testFuzz_Deposit(uint256 amount) public {
    token.deposit(amount);

    assertEq(
        token.balance(address(this)),
        amount
    );
}

这里真正测试的是:

ini 复制代码
对于任意 amount:

deposit(amount)
之后

balance == amount

也就是:

ini 复制代码
∀ amount,balance == amount

八、可以同时 Fuzz 多个参数

并不是只能测试一个参数:

vbnet 复制代码
function testFuzz_Transfer(
    uint256 amount,
    address to
) public {
    ...
}

Foundry 可以同时改变:

css 复制代码
amount
to

从而测试更多输入组合。

例如:

ini 复制代码
amount = 0
to = address(0)

amount = 1
to = 某地址

amount = max uint256
to = 某地址

这对于 Token、DeFi 等合约尤其有价值。


九、Fuzz + Cheatcode

前面学习的 Cheatcode 可以和 Fuzz Testing 一起使用。

例如:

scss 复制代码
function testFuzz_Withdraw(
    uint256 amount
) public {
    vm.deal(address(this), 100 ether);

    token.deposit{value: 100 ether}();

    ...
}

还可以:

ini 复制代码
vm.prank(user);

配合:

scss 复制代码
function testFuzz_Transfer(
    uint256 amount
) public {
    vm.prank(alice);

    token.transfer(bob, amount);
}

于是测试可以同时模拟:

diff 复制代码
不同用户
+
不同金额
+
不同时间
+
不同余额

测试能力会强很多。


十、Fuzz Testing 最适合测试什么?

特别适合下面这些场景:

1. 数值计算

复制代码
价格
利率
手续费
数量
兑换比例

2. 边界条件

复制代码
0
1
最大值
最小值

3. Token

arduino 复制代码
transfer
approve
mint
burn

4. DeFi

复制代码
swap
deposit
withdraw
liquidity
interest

5. 状态变化

例如:

css 复制代码
调用 A
调用 B
调用 C

之后检查:

复制代码
状态是否仍然满足某个规则

十一、普通测试和 Fuzz Testing 怎么选择?

可以简单理解:

类型 输入 目的
Unit Test 手动指定 验证具体场景
Fuzz Test 自动生成 寻找边界和异常输入
Invariant Test 自动生成调用序列 验证系统长期不变量

例如:

scss 复制代码
// 普通测试
function test_Deposit100() public {
    token.deposit(100);
}

// Fuzz
function testFuzz_Deposit(uint256 amount) public {
    token.deposit(amount);
}

普通测试关注:

这个场景对不对?

Fuzz Testing 关注:

换成各种输入还对不对?

而下一层的 Invariant Testing 则关注:

不断调用各种函数之后,系统规则还能不能保持?


十二、Fuzz Testing 的核心思想

其实可以把它总结成一句话:

复制代码
普通测试:
我告诉你测试什么输入。

Fuzz Testing:
你自己寻找可能出问题的输入。

而测试代码负责定义:

复制代码
什么叫"正确"?

例如:

perl 复制代码
assertEq(balanceAfter, balanceBefore + amount);

或者:

scss 复制代码
assertGe(balance, 0);

或者:

scss 复制代码
assertTrue(totalSupply <= MAX_SUPPLY);

断言写得越好,Fuzz Testing 越有价值。


总结

Foundry Fuzz Testing 的核心就是:

java 复制代码
测试函数增加参数
        ↓
Foundry 自动生成输入
        ↓
执行 Solidity 合约
        ↓
检查 assert
        ↓
发现 Bug
        ↓
给出 Counterexample

记住三个关键词:

vbnet 复制代码
Fuzz Test
   ↓
自动生成输入

Property
   ↓
定义程序必须满足的规则

Counterexample
   ↓
找到违反规则的具体输入

所以 Fuzz Testing 并不是"帮你写测试",而是:

你负责告诉它什么是正确的,它负责尽可能寻找错误。

相关推荐
ZealSinger15 分钟前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go
w***48821 小时前
SpringBoot整合easy-es
spring boot·后端·elasticsearch
dpharness1 小时前
踩完 dsh-ads 的四个坑,我说说虚构排名该怎么看
后端
预知同行1 小时前
深入解析 AI 应用可观测性:OpenTelemetry GenAI 规范下的调用链追踪与 Token 成本治理
后端·架构
张宏宇2 小时前
我把微软开源的 tgrep 做成了本地版 GitHub Code Search:45,629 个文件里搜一次 10ms
前端·后端
打工仔折腾 AI2 小时前
把模型切换交给平台:用蓝耘智能路由搭建商品评论分析工具
java·服务器·前端·后端·python·性能优化·ai agent 实战
后端LV2 小时前
Caffeine 源码详解:为什么它是最快的 Java 本地缓存——一次压测引发的源码考古
java·后端
dd聊技术2 小时前
RAG 召回不准,先别急着换模型:用 RRF 把 BM25 和向量召回接起来
后端
dadaobusi2 小时前
Trace-driven 建模(基于轨迹/踪迹的建模)
java·后端·spring
136096757232 小时前
服务器回显里的四个假故障
后端