Solidity 单元测试工程化:Foundry 测试框架的 Fuzzing 与不变式验证
Solidity 单元测试工程化:Foundry 测试框架的 Fuzzing 与不变式验证
一、当 Happy Path 测试覆盖不了闪电贷攻击
大多数 Solidity 测试的现状是这样的:为每个函数写两三个手写用例 —— Alice 转账给 Bob、Bob approve 给合约、正常赎回成功 —— 跑通即止。这种 Happy Path 测试在 2023 年以前或许够用,但目前 DeFi 攻击已进化到高度组合化的阶段:攻击者在一个交易里嵌套 5-8 次跨协议调用,利用瞬时价格快照、MEV 捆绑和重入包装来构造攻击序列。手写用例几乎不可能穷举这种组合空间。
Foundry 的 fuzzing 测试是应对这种复杂度的实用方案。它的核心思路很简单:你不用手动枚举边界条件,而是定义"输入空间"和"预期行为",让框架用随机输入去砸。搭配不变式测试(Invariant Testing),你还可以让框架在整个合约生命周期中持续验证某些"无论如何都不能违背"的规则。
flowchart LR A[定义测试合约] --> B{测试类型} B -->|Fuzz Test| C[随机输入生成器] B -->|Invariant Test| D[状态序列生成器] C --> E[执行函数调用] D --> E E --> F{断言检查} F -->|失败| G[记录最小复现用例] F -->|通过| H[继续下一轮] G --> I[输出 calldata]在 Foundry 的生态中,fuzzing 已经不再是一个可选的高级技巧。它是构建复杂 DeFi 协议时的工程底线 —— 你可以少写一些单测,但关键的状态转换和处理外部输入的路径,fuzzing 应该成为默认选项。
二、Fuzzing 与不变式测试的原理差异
很多开发者把 Fuzzing 和 Invariant Testing 混为一谈,但它们在 Foundry 中的实现机制有本质区别。理解这个区别直接关系到你能发现什么类型的 bug。
Fuzz Test(模糊测试):Foundry 随机生成函数参数,对同一个函数反复调用,每次调用之间是独立的。它的搜索空间是函数输入的类型空间。对于function deposit(uint256 amount)来说,Foundry 会生成各种uint256值 —— 0、1、max、以及各种中间值。如果某个值导致了 revert 或 assert 失败,Foundry 会尝试缩小范围找到最小复现值。
Invariant Test(不变式验证):不变式测试的视角完全不同。它关注合约状态在函数调用序列下的守恒。Framework 随机生成函数调用序列(可能是任意公开函数的任意组合顺序),并在每次调用后检查预定义的不变量。如果某个序列打破了不变量,Foundry 会反向裁剪这个序列,找到触发问题的最短调用链。
sequenceDiagram participant Fuzz as Fuzzer 引擎 participant Target as 被测试合约 participant Invariant as 不变式检查器 loop 256 runs Fuzz->>Target: 生成随机参数 f(args) Target-->>Fuzz: 返回结果 Fuzz->>Fuzz: assert 单次调用结果 end Note over Fuzz,Invariant: 不变式测试更进一步 loop 任意函数序列 Fuzz->>Target: 随机选择函数 + 参数 Target-->>Fuzz: 状态变更 Fuzz->>Invariant: 检查所有不变量 Invariant-->>Fuzz: 通过 / 失败 alt 不变量被打破 Fuzz->>Fuzz: 裁剪调用序列 end end两者的关系是互补的:Fuzzing 覆盖单函数边界行为,Invariant Testing 覆盖跨函数的状态一致性。一个典型的例子是代币合约:Fuzzing 确保transfer在各种参数下不报错;Invariant Testing 确保所有transfer+mint+burn操作序列结束后,totalSupply依然等于所有地址余额之和。
三、生产级代码实践
3.1 ERC20 代币的完整 Fuzzing 测试
// test/Token.fuzz.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {Test, console} from "forge-std/Test.sol"; import {MyToken} from "../src/MyToken.sol"; /** * @title TokenFuzzTest * @notice 对 ERC20 代币进行 Fuzzing 测试 * 设计考量: * - 使用 forge-std 的 bound() 限制输入范围,避免 fuzzer 生成极端值导致无意义的 revert * - 每个 fuzz 函数独立,确保状态隔离 —— Foundry 会在每次 fuzz 调用前自动 fork 状态 * - 为 fuzzer 提供结构化的断言集,而非单个 assert,提高 bug 发现密度 */ contract TokenFuzzTest is Test { MyToken public token; address public owner; address public alice; address public bob; // Fuzzer 每次执行的 runs 数量,默认 256 // 设置为更高的值可以在 CI 中以 --fuzz-runs 参数覆盖 function setUp() public { owner = makeAddr("owner"); alice = makeAddr("alice"); bob = makeAddr("bob"); vm.startPrank(owner); token = new MyToken("TestToken", "TST"); token.mint(alice, 1_000_000 ether); token.mint(bob, 1_000_000 ether); vm.stopPrank(); } /** * Fuzz 测试:transfer 不应在合法输入下 revert * bound(amount, 1, max) 避免 amount=0 或溢出 */ function testFuzz_TransferShouldNotRevert(uint256 amount) public { amount = bound(amount, 1, token.balanceOf(alice)); vm.prank(alice); token.transfer(bob, amount); // 静默通过即表示无 revert } /** * Fuzz 测试:转账后余额守恒 * 这是核心业务不变量的 fuzz 化验证 */ function testFuzz_TransferPreservesTotalSupply(uint256 amount) public { amount = bound(amount, 1, token.balanceOf(alice)); uint256 aliceBefore = token.balanceOf(alice); uint256 bobBefore = token.balanceOf(bob); vm.prank(alice); token.transfer(bob, amount); assertEq(token.balanceOf(alice), aliceBefore - amount); assertEq(token.balanceOf(bob), bobBefore + amount); } /** * Fuzz 测试:approve 后 allowance 必须精确等于设定值 * 很多 ERC20 实现会因为 approve 非零值再改为零值时存在 race condition */ function testFuzz_ApproveSetsCorrectAllowance(uint256 amount) public { vm.prank(alice); token.approve(bob, amount); assertEq(token.allowance(alice, bob), amount); } /** * Fuzz 测试:transferFrom 的权限边界 * 如果 allowance 不足,必须 revert */ function testFuzz_TransferFromRevertsWhenAllowanceTooLow( uint256 approveAmount, uint256 transferAmount ) public { approveAmount = bound(approveAmount, 0, type(uint256).max - 1); // 确保 transferAmount > approveAmount transferAmount = bound(transferAmount, approveAmount + 1, type(uint256).max); vm.prank(alice); token.approve(bob, approveAmount); vm.prank(bob); vm.expectRevert(); token.transferFrom(alice, bob, transferAmount); } }3.2 不变式测试:代币总供给守恒
// test/Token.invariant.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {Test, console} from "forge-std/Test.sol"; import {StdInvariant} from "forge-std/StdInvariant.sol"; import {MyToken} from "../src/MyToken.sol"; /** * @title TokenInvariantTest * @notice 验证 ERC20 代币在任意操作序列下的不变式 * 设计考量: * - 继承 StdInvariant 获取 targetContract / targetSelector 等辅助方法 * - 使用 excludeArtifacts 排除不需要的合约路径 * - ghost 变量用于跨调用追踪状态,避免 EVM storage slot 混淆 */ contract TokenInvariantTest is StdInvariant, Test { MyToken public token; address public alice; address public bob; function setUp() public { alice = makeAddr("alice"); bob = makeAddr("bob"); token = new MyToken("TestToken", "TST"); token.mint(alice, 1_000_000 ether); token.mint(bob, 1_000_000 ether); // 将合约注册为 fuzzer 目标 targetContract(address(token)); // 排除 view 函数和非公开函数,避免无意义的 fuzzer 调用 bytes4[] memory excludedSelectors = new bytes4[](3); excludedSelectors[0] = bytes4(keccak256("name()")); excludedSelectors[1] = bytes4(keccak256("symbol()")); excludedSelectors[2] = bytes4(keccak256("decimals()")); targetSelector(FuzzSelector({ addr: address(token), selectors: excludedSelectors }).exclude(); } /** * 不变式 1:总供给在所有操作后等于所有持币地址余额之和 * 这是 ERC20 最根本的不变式 */ function invariant_TotalSupplyEqualsSumOfBalances() public { assertEq( token.totalSupply(), token.balanceOf(alice) + token.balanceOf(bob) ); } /** * 不变式 2:任意地址的余额不能超过总供给 */ function invariant_BalanceNeverExceedsTotalSupply() public { assertLe(token.balanceOf(alice), token.totalSupply()); assertLe(token.balanceOf(bob), token.totalSupply()); } /** * 不变式 3:allowance 不影响余额,仅影响 transferFrom 的授权额度 */ function invariant_AllowanceDoesNotAffectBalance() public { // 无需显式断言 —— 如果 allowance 实现有 bug 影响了余额 // 前两个 invariants 必然会捕获 } }3.3 不变式 Handler:控制 fuzzer 的行为边界
对于更复杂的协议,直接 targetContract 会导致 fuzzer 生成不真实的操作序列。比如一个借贷协议,fuzzer 可能反复调用deposit而不调用borrow,这样的序列找不到真实 bug。通过 Handler 合约,可以引导 fuzzer 的行为模式:
// test/handlers/LendingHandler.sol contract LendingHandler is Test { LendingPool public pool; MockERC20 public token; constructor(LendingPool _pool, MockERC20 _token) { pool = _pool; token = _token; } /** * Handler 方法:deposit * 每次被 fuzzer 随机选中时,使用 ghost 变量记录 deposit 总量 * 这种设计让 fuzzer 的调用更接近真实用户行为 */ function deposit(uint256 amount) public { amount = bound(amount, 1, token.balanceOf(address(this))); token.approve(address(pool), amount); pool.deposit(amount); } function borrow(uint256 amount) public { // 限制借款量不超过可用流动性的 80%,避免 fuzzer 反复触发无意义的 revert uint256 available = pool.getAvailableLiquidity(); amount = bound(amount, 1, (available * 80) / 100); pool.borrow(amount); } function repay(uint256 amount) public { uint256 debt = pool.getDebt(address(this)); amount = bound(amount, 1, debt); token.approve(address(pool), amount); pool.repay(amount); } }3.4 Foundry 配置调优
# foundry.toml [profile.default] src = "src" out = "out" libs = ["lib"] # Fuzzing 配置 fuzz = { runs = 5000, max_test_rejects = 500_000 } # runs: 每个 fuzz 测试执行的随机输入组数,5000 是复杂 DeFi 协议的合理起点 # max_test_rejects: 允许 fuzzer 抛弃的无效输入数,默认 65536 # 提高这个值是必要的:如果 bound() 频繁排除了大量输入范围,fuzzer 需要更多重试才能找到有效输入 invariant = { runs = 256, depth = 15, fail_on_revert = false } # runs: 不变式测试的跑数 # depth: 函数调用序列的最大长度,15 表示 fuzzer 会生成 1-15 个随机函数调用组成的序列 # fail_on_revert = false: 不变式测试中允许函数调用 revert(正常业务逻辑) # 设置为 true 会让 fuzzer 把所有 revert 视为失败,适用于"任何操作都不应该 revert"的严格场景 [profile.ci] fuzz = { runs = 10000, max_test_rejects = 1_000_000 } invariant = { runs = 512, depth = 20 } # CI 环境下提高运行次数,利用 CI Runner 的高性能 CPU 做深度搜索四、边界分析
Fuzzing 的覆盖率不等于合约安全性。Foundry fuzzer 基于随机输入生成,它不能保证覆盖所有代码路径。一个只在block.timestamp == 某个特定值时触发的 bug,fuzzer 几乎不可能命中。覆盖率报告应该作为最低标准而不是完美目标。
不变式设计的质量决定了测试价值。如果你定义的不变式本身就有漏洞(比如"总借款量不超过存款量"忽略利息累积),那么测试无论跑多少轮都是自我欺骗。不变式的推导需要与合约的数学规范对齐。
Gas 消耗不可忽视。在 CI 中运行 10000 次 fuzz 测试,对于包含多个复杂操作的合约,单次运行可能消耗大量 RPC 调用和 CPU 时间。建议将高 runs 值的配置放在 nightly CI 中,日常 PR 使用较低的 runs 值做快速门禁。
不适用场景:
- 合约依赖外部 oracle 价格:fuzzer 无法模拟链下价格波动的分布
- 需要跨区块状态(如时间锁):Foundry fuzzer 默认在单区块内运行
- 依赖复杂 MEV 场景:需要搭配
forge-std的vm.roll()和vm.warp()手动构造
五、总结
| 维度 | 要点 |
|---|---|
| Fuzz Test | 随机生成函数参数,覆盖单函数边界行为;通过bound()控制有效输入范围 |
| Invariant Test | 随机函数调用序列 + 不变式断言;通过 Handler 合约引导 fuzzer 行为模式 |
| 配置调优 | 日常 CI 低 runs 做快速门禁,nightly CI 高 runs + 大 depth 做深度搜索 |
| Handler 模式 | 控制 fuzzer 的操作序列,使其符合真实用户行为,提高 bug 发现效率 |
| 局限认知 | 无法覆盖时间/区块依赖的 bug,不变式质量决定测试价值 |
| 工程边界 | 单区块测试假设,不覆盖跨区块状态与 MEV 场景 |