Foundry 测试与模拟:Forge、作弊码与模糊测试实战

Foundry 是当前最强大的 Solidity 测试框架:用 Solidity 本身写测试、内置作弊码模拟任意链上状态、支持 fork 主网与模糊测试。覆盖 Forge 测试基础(断言/事件/预期 revert)、vm.prank/vm.deal/vm.warp 等作弊码全解、主网分叉测试、模糊测试(fuzz)与不变式测试、Gas 报告与单测优化、CI 集成与覆盖率。

导语:测试合约,就要像测试普通程序一样

智能合约一旦部署不可升级,出 bug 的成本动辄数百万美元。Foundry 把"合约测试"从"脚本式祈祷"变成"完整的测试工程"。

Foundry 的核心哲学:用 Solidity 写测试、用 Solidity 模拟链上环境、用 Solidity 做断言。测试与生产代码同语言、同工具链,无需 JS/TS 桥接层。

一句话总结:Forge 让 Solidity 开发者拥有与后端工程师同级的测试体验——断言、mock、fuzz、分叉主网一把梭,而且全部跑在本地 EVM 上、毫秒级完成。


1. Foundry 项目骨架与配置

1.1 初始化

forge init my-contract
# 生成 src/ test/ script/ lib/ 四个标准目录
cd my-contract
forge build      # 编译
forge test       # 跑测试

1.2 标准目录约定

目录用途
src/合约源码
test/测试文件(*.t.sol)
script/部署与操作脚本(*.s.sol)
lib/依赖(forge install 拉取)

1.3 基础测试结构

// test/Counter.t.sol
import {Test} from "forge-std/Test.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();  // 每个测试前运行
    }

    function test_increment() public {
        counter.increment();
        assertEq(counter.number(), 1);
    }

    function testFuzz_setNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.number(), x);  // 随机输入断言
    }
}

2. 断言、事件与预期 revert

2.1 断言 API

assertEq(uint256 a, uint256 b);        // 相等
assertGt(uint256 a, uint256 b);        // 大于
assertLt(a, b);                        // 小于
assertApproxEq(a, b, tolerance);       // 近似
assertEq(address a, address b);        // 地址比较
assertEq(string memory a, string memory b);
assertTrue(bool condition);
assertFalse(bool condition);

2.2 断言事件

// 断言合约发出指定事件
vm.expectEmit(true, true, true, true);
emit Transfer(address(this), to, amount);
token.transfer(to, amount);

2.3 预期 revert

// 断言 revert 及错误信息
vm.expectRevert("Insufficient balance");
token.transfer(address(0), 1);

// 断言自定义错误类型(Solidity 0.8.4+)
vm.expectRevert(abi.encodeWithSelector(Token.InsufficientBalance.selector));

3. 作弊码(Cheatcodes):在测试中控制链上世界

作弊码是 Foundry 的灵魂——它在测试期间重新设定 EVM 状态,让任何链上场景都可模拟。

3.1 核心作弊码速查

作弊码作用
vm.prank(addr)下一个调用以 addr 身份发起(不修改 storage)
vm.startPrank(addr)以 addr 身份持续调用
vm.deal(addr, amt)给地址设置 ETH 余额
vm.warp(uint)跳转区块时间戳
vm.roll(uint)跳转区块高度
vm.mockCall拦截并伪造外部调用返回值
vm.store直接写入合约存储槽
vm.label给地址打标签便于调试

3.2 身份与余额模拟

function test_ownerCanWithdraw() public {
    vm.prank(owner);          // 以下调用以 owner 身份
    vault.withdraw(100);

    vm.deal(alice, 100 ether);  // 给 alice 塞 ETH
    vm.prank(alice);
    vault.deposit{value: 50 ether}();
}

function test_transferOwnership() public {
    vm.startPrank(owner);      // 多步以 owner 身份
    token.transferOwnership(alice);
    token.mint(alice, 1000);
    vm.stopPrank();
}

3.3 时间与区块控制

function test_vesting_afterDeadline() public {
    vm.warp(block.timestamp + 30 days);   // 快进 30 天
    vm.roll(block.number + 1000);         // 快进 1000 区块

    // 此时可测试时间依赖逻辑
    vesting.claim();
}

3.4 Mock 外部依赖

// 模拟价格预言机返回固定价格
address oracle = address(new MockOracle());
vm.mockCall(
    oracle,
    abi.encodeWithSignature("getPrice()"),
    abi.encode(1000e18)
);

一句话总结:作弊码把"链上时间、余额、调用者、存储"全部变成可编程变量——测试不再是黑盒复现,而是对任意状态的精确构造。


4. 分叉测试:直接在真实主网上调试

分叉测试让测试环境复制真实主网状态,无需部署即可交互真实协议。

// 需要在 foundry.toml 配置 RPC URL
import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ForkTest is Test {
    IERC20 public dai = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);

    function setUp() public {
        vm.createFork(vm.envString("MAINNET_RPC_URL"));  // 创建分叉
        vm.selectFork(vm.activeFork());                   // 激活
    }

    function test_dai_balance() public {
        // 直接读取主网真实余额
        uint256 bal = dai.balanceOf(0x...);
        assertGt(bal, 0);
    }

    function test_forked_interaction() public {
        // 在主网分叉上执行完整交互流程
        vm.prank(whale);
        dai.approve(address(pool), type(uint256).max);
        vm.prank(whale);
        pool.deposit(1000e18);
    }
}

4.1 分叉模式的层级

Fork 模式:
  默认:测试变更只影响内存中的 fork(不污染主网)
  -f/--fork-url:直接使用主网 RPC 快速分叉
  多 fork 并存:vm.createFork / vm.selectFork 切换不同链

一句话总结:分叉测试让"真实协议 + 真实余额 + 真实合约"成为测试材料,大幅降低集成测试成本,也是复现攻击 PoC 的标准手法。


5. 模糊测试:让随机输入替你找 bug

模糊测试(fuzz)用随机输入反复执行测试,寻找边界错误与意外行为。参数化函数即可启用:

function testFuzz_transfer(uint256 amount, address to) public {
    vm.assume(to != address(0));      // 过滤非法输入
    vm.assume(amount <= token.balanceOf(address(this)));

    uint256 before = token.balanceOf(to);
    token.transfer(to, amount);
    assertEq(token.balanceOf(to), before + amount);
}

5.1 配置模糊参数

# foundry.toml
[fuzz]
runs = 10000          # 每个用例的随机次数
max_test_rejects = 65536
seed = "0x1"          # 固定种子可复现

5.2 不变式测试(Invariant Testing)

模糊测试的强化版:持续执行操作序列,断言系统级不变式始终成立。

// 不变式测试:无论怎么操作,协议不能凭空造钱
contract InvariantTest is Test {
    MyPool pool;
    address[] users;

    function invariant_totalLiquidity() public {
        // 所有用户余额之和必须等于池子总额
        uint256 total = pool.totalAssets();
        uint256 sum = 0;
        for (uint i = 0; i < users.length; i++) {
            sum += pool.balanceOf(users[i]);
        }
        assertEq(total, sum);
    }
}
类型适用发现能力
单测明确行为功能正确性
Fuzz参数边界边界/溢出/断言失败
Invariant系统守恒状态机级逻辑漏洞
Fork 测试集成与真实协议的兼容性

一句话总结:fuzz 把「你猜得到的输入」扩展成「所有可能的输入」,invariant 再把「单个操作」提升为「任意操作序列下的系统守恒」——这是找复杂逻辑漏洞的两把钥匙。


6. Gas 报告与优化

6.1 Gas 报告

forge test --gas-report

输出每个函数消耗的 Gas,可对比优化前后。配合快照:

forge snapshot   # 生成 .gas-snapshot
forge snapshot --diff   # 对比前后差异

6.2 常见 Gas 优化检查

// ❌ 高消耗:循环内重复访问存储
function sumBad(uint256[] memory arr) external {
    for (uint i = 0; i < arr.length; i++) {
        total += arr[i];  // storage 读写
    }
}

// ✅ 优化:storage 转 memory 读取
function sumGood(uint256[] memory arr) external {
    uint256 t = total;          // 读一次到 memory
    for (uint i = 0; i < arr.length; i++) {
        t += arr[i];
    }
    total = t;                   // 写回一次
}

其他要点:

- 使用 calldata 代替 memory(外部参数)
- 使用 immutable / constant 存不变值
- 避免重复 SLOAD/SSTORE
- 合理用 unchecked 包无溢出加法
- 错误类型代替 string revert(省 gas)

7. 覆盖率与 CI 集成

7.1 覆盖率

forge coverage
# 输出每行代码覆盖情况 + lcov 格式(可进 CI 报告)

7.2 CI 集成(GitHub Actions)

name: Foundry Test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: foundry-rs/foundry-toolchain@v1
        with:
          version: stable
      - name: Build
        run: forge build --sizes
      - name: Test
        run: forge test -vvv
      - name: Coverage
        run: forge coverage --report lcov

7.3 测试金字塔(合约版)

        少量       不变式测试(系统守恒)
        ↑         分叉集成测试(真实协议)
        ↑         模糊测试(参数边界)
        ↑         单元测试(功能正确性)← 最多

一句话总结:Gas、覆盖率、CI 是合约质量的"工程化三件套"——它们把测试从"能跑"推进到"可衡量、可回归、可上生产"。


8. 实战:测试一个完整合约

8.1 被测合约

// src/Token.sol
contract Token {
    mapping(address => uint256) public balanceOf;
    uint256 public totalSupply;

    event Transfer(address indexed from, address indexed to, uint256 value);

    function mint(address to, uint256 amount) external {
        require(amount > 0, "zero amount");
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function transfer(address to, uint256 amount) external {
        require(balanceOf[msg.sender] >= amount, "insufficient");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
    }
}

8.2 完整测试

// test/Token.t.sol
import {Test} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";

contract TokenTest is Test {
    Token token;
    address alice = address(0xA11CE);
    address bob = address(0xB0B);

    function setUp() public {
        token = new Token();
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    function test_mint_increasesSupply() public {
        vm.prank(address(this));
        token.mint(alice, 100);
        assertEq(token.totalSupply(), 100);
        assertEq(token.balanceOf(alice), 100);
    }

    function test_mint_emitsEvent() public {
        vm.expectEmit(true, true, true, true);
        emit Token.Transfer(address(0), alice, 100);
        token.mint(alice, 100);
    }

    function test_transfer_movesBalance() public {
        vm.prank(alice);
        token.mint(alice, 100);
        vm.prank(alice);
        token.transfer(bob, 30);
        assertEq(token.balanceOf(alice), 70);
        assertEq(token.balanceOf(bob), 30);
    }

    function test_transfer_revertsWhenInsufficient() public {
        vm.prank(alice);
        vm.expectRevert("insufficient");
        token.transfer(bob, 1);
    }

    function testFuzz_transferInvariant(uint256 amount) public {
        vm.prank(alice);
        token.mint(alice, type(uint256).max);
        vm.assume(amount <= token.balanceOf(alice));
        token.transfer(bob, amount);
        assertEq(token.balanceOf(alice) + token.balanceOf(bob), type(uint256).max);
    }
}

9. 常见坑与调试技巧

坑症状解决
vm.prank 只影响下一个调用多步调用身份错乱用 startPrank/stopPrank
测试顺序依赖状态串扰每个测试独立 setUp()
fuzz 未过滤边界大量断言失败噪音用 vm.assume 精确约束
忘记 fork 激活读到空状态vm.selectFork 后再断言
Gas 报告无输出未跑 gas 模式forge test --gas-report
调试输出无 console.log用 console2(forge-std)

调试技巧:

import {console2} from "forge-std/console2.sol";

function test_debug() public {
    console2.log("balance", token.balanceOf(alice));
    console2.logAddress(bob);
    console2.logBytes32(bytes32("hello"));
}

10. 总结

  1. 同语言测试:Solidity 写测试,与生产代码零隔阂
  2. 作弊码:时间/余额/身份/存储全部可编程
  3. 分叉测试:主网状态即测试数据
  4. 模糊 + 不变式:从参数边界到系统守恒的测试纵深
  5. 工程化:Gas 报告、覆盖率、CI 让质量可衡量
  6. 调试:console2 与 forked 环境让问题快速定位

延伸阅读:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「blockchain」更多文章

  1. 链上数据索引:The Graph、Subgraph 与数据查询架构
  2. 钱包与 HD 分层确定性钱包:助记词、派生路径与签名流程
  3. 稳定币与 DEX/AMM 机制:锚定设计、流动性池与无常损失