导语:测试合约,就要像测试普通程序一样
智能合约一旦部署不可升级,出 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. 总结
- 同语言测试:Solidity 写测试,与生产代码零隔阂
- 作弊码:时间/余额/身份/存储全部可编程
- 分叉测试:主网状态即测试数据
- 模糊 + 不变式:从参数边界到系统守恒的测试纵深
- 工程化:Gas 报告、覆盖率、CI 让质量可衡量
- 调试:console2 与 forked 环境让问题快速定位
延伸阅读:
- Hardhat 与 Foundry 开发工具链 — Foundry 在整个工具链中的位置
- 智能合约安全审计与常见漏洞 — 测试是审计的第一道防线
- Solidity 智能合约开发入门 — 被测语言本身
- 区块链-web3 专题 — 部署脚本与 DApp 集成
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。