Foundry 是以 Rust 编写、以 Solidity 作为唯一测试语言的以太坊开发工具链。它把编译、测试、模糊、分叉与部署脚本统一收敛到 forge、cast、anvil、chisel 四个二进制中,测试直接跑在 EVM 上,没有 JavaScript 中间层带来的序列化开销。
与传统框架相比,Foundry 的核心优势在于速度与表达力:测试本身就是 Solidity 合约,可以任意操纵链上状态、时间戳与调用身份。本文从工具链结构出发,逐层拆解 forge test 的断言、作弊码、模糊、分叉与不变式测试,并给出 CI 集成与迁移路径。
目录
- 1. Foundry 工具链与项目结构
- 2. forge test 基础与断言体系
- 3. 作弊码 Cheatcodes 实战
- 4. 模糊测试 Fuzz Testing
- 5. 分叉测试 Fork Testing
- 6. 不变式测试 Invariant Testing
- 7. 覆盖率、gas 快照与调试
- 8. 与 CI 集成及脚本化部署
- 9. 测试策略与常见陷阱
- 10. 从 Hardhat 迁移与工具对比
- 延伸阅读
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 钉住区块 |
一句话记忆:单元测试保正确,模糊测试找边界,不变式测试守性质,分叉测试验集成,四者缺一不可。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。