Foundry 合约测试实战:forge test、模糊测试与不变式测试

系统讲解 Foundry 合约测试体系:forge test 断言、作弊码、模糊测试、分叉测试与不变式测试,并覆盖覆盖率、gas 快照与 CI 集成实践。

Foundry 是以 Rust 编写、以 Solidity 作为唯一测试语言的以太坊开发工具链。它把编译、测试、模糊、分叉与部署脚本统一收敛到 forge、cast、anvil、chisel 四个二进制中,测试直接跑在 EVM 上,没有 JavaScript 中间层带来的序列化开销。

与传统框架相比,Foundry 的核心优势在于速度与表达力:测试本身就是 Solidity 合约,可以任意操纵链上状态、时间戳与调用身份。本文从工具链结构出发,逐层拆解 forge test 的断言、作弊码、模糊、分叉与不变式测试,并给出 CI 集成与迁移路径。

前置:合约工程化基础、常见漏洞与审计视角。


目录


1. Foundry 工具链与项目结构

1.1 安装与目录布局

通过 foundryup 安装后,forge init 会生成标准骨架:src/ 放合约、test/ 放测试、script/ 放部署脚本、lib/ 放依赖。测试文件命名约定为 *.t.sol,与 src/ 中的 *.sol 区分编译目标。

forge init my-protocol
cd my-protocol
tree -L 2
# .
# ├── foundry.toml
# ├── lib/forge-std
# ├── script/
# ├── src/
# └── test/

foundry.toml 是全局配置入口,控制 solc 版本、优化器、模糊测试轮次与分叉 RPC:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
fuzz = { runs = 512 }
invariant = { runs = 256, depth = 32, fail_on_revert = false }

[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"

1.2 依赖管理与编译

forge install openzeppelin/openzeppelin-contracts 以 git submodule 形式拉取依赖,随后通过 remappings.txt 或 foundry.toml 的 remappings 声明导入别名。forge build --sizes 会列出每个合约的字节码大小,便于在部署前发现触及 24KB 上限的合约。

forge install foundry-rs/forge-std
forge remappings > remappings.txt
forge build --sizes

2. forge test 基础与断言体系

2.1 测试合约骨架

所有测试继承 forge-std/Test.sol,setUp() 在每个测试函数执行前运行一次,用于部署夹具。以 test 为前缀的函数被识别为测试用例,testFuzz 与 invariant 另有约定。

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

contract VaultTest is Test {
    Vault vault;
    address alice = address(0xA11CE);

    function setUp() public {
        vault = new Vault();
        vm.deal(alice, 10 ether);
    }

    function test_DepositUpdatesBalance() public {
        vm.prank(alice);
        vault.deposit{value: 1 ether}();
        assertEq(vault.balanceOf(alice), 1 ether);
    }
}

2.2 断言与失败诊断

forge-std 提供 assertEq、assertTrue、assertLt 等系列断言,以及 assertEq 对 bytes、string 的重载。期望回滚使用 vm.expectRevert,可精确匹配 error 选择器或字符串。失败时用 -vvv 打印完整调用追踪。

function test_RevertWhen_ZeroAmount() public {
    vm.expectRevert(Vault.ZeroAmount.selector);
    vault.deposit{value: 0}();
}

function testFuzz_Assertions(uint256 a, uint256 b) public {
    vm.assume(a < b);
    assertLt(a, b, "ordering violated");
}

3. 作弊码 Cheatcodes 实战

3.1 身份与时间控制

vm.prank(addr) 让下一次调用以指定地址发出,startPrank 则持续生效直到 stopPrank。vm.warp 修改 block.timestamp,vm.roll 修改区块高度,vm.deal 直接注入 ETH 余额,这三者组合可以精确构造锁仓到期、投票快照等时间敏感场景。

function test_UnlockAfterOneYear() public {
    vm.prank(alice);
    vault.deposit{value: 1 ether}();

    vm.warp(block.timestamp + 365 days);
    vm.prank(alice);
    vault.withdraw(1 ether);
    assertEq(alice.balance, 10 ether);
}

3.2 状态操纵与模拟调用

vm.load 读取任意槽位,vm.store 直接写入存储,用于构造诸如「巨鲸持仓」的初始状态而无需真实转账。vm.mockCall 让对某个合约特定选择器的调用返回伪造数据,是隔离外部依赖的关键手段。vm.expectEmit 可断言事件主题与数据。

function test_MockedOraclePrice() public {
    vm.mockCall(
        address(oracle),
        abi.encodeWithSelector(oracle.latestAnswer.selector),
        abi.encode(int256(2000e8))
    );
    assertEq(lending.healthFactor(alice), 2e18);

    bytes32 slot = keccak256(abi.encode(alice, uint256(0)));
    vm.store(address(token), slot, bytes32(uint256(1e24)));
}

4. 模糊测试 Fuzz Testing

4.1 参数化模糊与 bound

给测试函数添加参数即自动成为模糊测试,Foundry 会用随机输入运行 fuzz.runs 次。bound(x, min, max) 把任意输入映射到有效区间,避免大量输入因前置校验被丢弃而降低覆盖率。

function testFuzz_DepositWithdraw(uint96 amount) public {
    amount = uint96(bound(amount, 1, 100 ether));
    vm.deal(alice, amount);

    vm.startPrank(alice);
    vault.deposit{value: amount}();
    vault.withdraw(amount);
    vm.stopPrank();

    assertEq(vault.totalAssets(), 0);
    assertEq(alice.balance, amount);
}

4.2 结构化模糊与假设过滤

对于结构体或数组输入,可用 vm.assume 过滤不满足的假设,但过度使用会拖慢收敛。更好的做法是把不变量前置为 bound,让随机输入始终落在有意义的域内。失败用例会打印 [FAIL: ...] 并附最小化后的反例种子。

forge test --match-test testFuzz_DepositWithdraw -vvv
# [PASS] testFuzz_DepositWithdraw(uint96) (runs: 512, μ: 72104, ~: 71892)

5. 分叉测试 Fork Testing

5.1 分叉配置与固定区块

forge test --fork-url $MAINNET_RPC_URL 会在本地 anvil 中重放主网状态。为保证可复现,务必用 --fork-block-number 钉住区块高度。vm.createFork 与 vm.selectFork 支持在单个测试内切换多条链,适合跨链桥的端到端验证。

forge test --fork-url $MAINNET_RPC_URL \
  --fork-block-number 19000000 \
  --match-contract ForkTest -vvv

5.2 对真实链状态断言

分叉测试的价值在于对真实协议的实时状态断言。下面的例子直接与主网 USDC 交互,验证金库的存款路径在真实代币行为(如黑名单、费用)下依然成立:

contract ForkTest is Test {
    IERC20 constant USDC = IERC20(0xA0b8...eB48);

    function setUp() public {
        vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_000_000);
    }

    function test_DepositRealUSDC() public {
        deal(address(USDC), alice, 1_000e6);
        vm.startPrank(alice);
        USDC.approve(address(vault), 1_000e6);
        vault.deposit(1_000e6);
        vm.stopPrank();
        assertEq(vault.balanceOf(alice), 1_000e6);
    }
}

6. 不变式测试 Invariant Testing

6.1 不变式与 handler 模式

不变式测试声明「任何操作序列后都必须成立」的性质,函数以 invariant_ 前缀命名。为避免模糊器随机调用任意函数导致状态爆炸,标准做法是编写 handler 合约,把目标合约的调用封装为受约束的动作,再用 targetContract 注册。

contract VaultHandler is Test {
    Vault vault;
    uint256 public ghost_deposits;

    constructor(Vault _vault) { vault = _vault; }

    function deposit(uint96 amount) public {
        amount = uint96(bound(amount, 1, 10 ether));
        vm.deal(address(this), amount);
        vault.deposit{value: amount}();
        ghost_deposits += amount;
    }
}

contract VaultInvariantTest is Test {
    Vault vault;
    VaultHandler handler;

    function setUp() public {
        vault = new Vault();
        handler = new VaultHandler(vault);
        targetContract(address(handler));
    }

    function invariant_SolvencyMatchesGhost() public {
        assertEq(address(vault).balance, handler.ghost_deposits());
    }
}

6.2 状态空间探索与配置

不变式测试的强度由 runs(序列条数)与 depth(每条序列的调用步数)共同决定,总调用次数约为二者乘积。fail_on_revert = false 允许模糊器跳过回滚调用以探索更广路径;targetSenders 与 targetSelectors 可进一步收窄探索空间。

[profile.default.invariant]
runs = 256
depth = 64
fail_on_revert = false
call_override = false

7. 覆盖率、gas 快照与调试

7.1 forge coverage 与报告

forge coverage 基于 EVM 插桩统计行、分支与函数覆盖,输出 lcov 后可接入 Codecov。它比源码级插桩慢,建议在 CI 中单独作为一条可选流水线,并设置覆盖率阈值门禁。

forge coverage --report lcov --report summary
forge coverage --ir-minimum   # 绕过 viaIR 栈深度限制

7.2 gas 快照与调试追踪

forge snapshot 记录每个测试的 gas 消耗到 .gas-snapshot,提交后可对比 git diff 发现 gas 回归。调试时 -vvvv 打印完整 trace,forge debug <tx> 进入交互式单步,cast run 可重放链上真实交易。

forge snapshot --diff .gas-snapshot
forge test --gas-report
forge test -vvvv --match-test test_RevertWhen_ZeroAmount

8. 与 CI 集成及脚本化部署

8.1 GitHub Actions 流水线

CI 中固定 Foundry 版本、缓存 out/ 与 lib/、注入 RPC 密钥即可。分叉测试依赖网络,建议只在主分支或定时任务运行,PR 中仅跑单元与模糊测试以控制耗时。

- name: Run tests
  run: forge test --no-match-contract Fork
- name: Fork tests
  if: github.ref == 'refs/heads/main'
  run: forge test --match-contract Fork
  env:
    MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}

8.2 forge script 与部署校验

forge script 把部署写成带 run() 的 Solidity 脚本,--broadcast 广播交易、--verify 自动提交区块浏览器验证。部署脚本本身也可断言,例如校验初始化参数与所有权转移是否按预期完成。

forge script script/Deploy.s.sol:Deploy \
  --rpc-url $SEPOLIA_RPC_URL \
  --broadcast --verify -vvvv

9. 测试策略与常见陷阱

9.1 测试分层与优先级

合理的分层是:单元测试覆盖纯逻辑分支,模糊测试覆盖算术与边界,不变式测试覆盖跨函数的状态机性质,分叉测试覆盖与真实协议的集成。安全关键模块应至少有一条不变式测试,因为攻击往往来自开发者未曾设想的调用序列。

9.2 常见陷阱

第一,分叉测试未钉住区块高度,导致历史状态漂移而随机失败。第二,vm.prank 只对下一次调用生效,链式调用需改用 startPrank。第三,模糊测试中滥用 vm.assume 会让有效样本比例过低。第四,不变式测试若允许任意合约被调用,容易触发无关回滚掩盖真实问题,应始终使用 handler 收窄入口。

// 错误:prank 只作用于 approve,deposit 仍以测试合约身份调用
vm.prank(alice);
token.approve(address(vault), 1 ether);
vault.deposit(1 ether);

10. 从 Hardhat 迁移与工具对比

10.1 迁移路径

迁移可增量进行:先保留 Hardhat 处理部署与插件生态,把单元测试逐步改写为 Solidity 测试。ABI 通过 forge build 产物共享,前端与脚本可继续用 ethers。测试夹具从 beforeEach 映射到 setUp,ethers.provider.send("evm_increaseTime") 映射到 vm.warp。

npx hardhat compile && forge build
# 两套工具共享 artifacts/ 与 out/,互不干扰

10.2 能力对比

Hardhat 强在插件生态、TypeScript 类型与调试体验,Foundry 强在测试速度、原生模糊与不变式测试、以及对 EVM 状态的直接操纵能力。多数成熟团队采用混合方案:Foundry 承担测试主力,Hardhat 保留部署脚本与依赖插件的任务。

# 性能对比:同一测试集
forge test           # 约 1~3 秒
npx hardhat test     # 约 15~40 秒

10.3 速查表与一句话记忆

命令用途关键参数
forge test运行全部测试-vvvv、--match-test、--match-contract
forge test –gas-report输出函数级 gas 报告与快照对比定位回归
forge snapshot记录 gas 基线--diff 对比历史
forge coverage生成覆盖率报告--report lcov、--ir-minimum
forge script部署与广播--broadcast、--verify、--rpc-url
forge test –fork-url分叉主网状态--fork-block-number 钉住区块

一句话记忆:单元测试保正确,模糊测试找边界,不变式测试守性质,分叉测试验集成,四者缺一不可。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「区块链 Web3」更多文章

  1. DeFi 风险管理与清算:抵押率、清算机制与坏账处置
  2. MPC 钱包与密钥管理:门限签名、2-of-3 架构与攻击面分析
  3. 形式化验证实战:Certora、Halmos 与 Echidna 的规约、边界与 CI 集成