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 并不是"帮你写测试",而是:
你负责告诉它什么是正确的,它负责尽可能寻找错误。