先说个现象。最近刷技术社区,发现一个挺普遍的现状:学 Solidity 的人不少,会写合约测试的人不多。尤其是刚接触 web3 的开发者,合约写完了,测试文件基本是空的,问起来就是一句"我本地能跑通"。这种习惯放在普通后端项目里最多线上报错,放在链上合约里,一旦部署,代码就几乎不可改了,出问题意味着真金白银的损失。这也是为什么我特别想把 Foundry 这个测试框架讲透——它不是可有可无的工具,而是我见过的最适合 Solidity 项目的测试方案。
如果你之前用 Hardhat 写过测试,大概率体会过那种在 JavaScript 和 Solidity 之间反复横跳的割裂感:部署合约要写一遍部署脚本,调用合约要等交易回执,拿到数值还要做类型转换,测一个回滚场景得翻半天文档。Foundry 的思路完全不同,它让你直接用 Solidity 写测试,测试代码和业务代码用同一种语言表达,配合 cheatcodes 可以在 EVM 层面模拟时间、身份、区块高度这些链上环境。这篇文章就从零开始,带你把 Solidit 测试这件事完整跑通,包括环境搭建、基础断言、cheatcodes 进阶、fuzz 测试和调试技巧,适合正在从 Hardhat 迁移过来的开发者,也适合刚写完第一个合约、想认真补上测试的入门者。
1. 为什么是 Foundry:JS 测试方案里最难受的几件事
1.1 合约测试和 Web2 测试的逻辑完全不同
后端接口测试考量的通常是"逻辑对不对",但合约测试考量的维度要多一层:任何人、在任何时间、用任何参数调用你的合约,都不能产生危害。合约一旦部署就无法热修复,没有灰度发布,没有回滚版本,出问题就是永久性的。所以在 Foundry 的语境下,测试不叫"验证",叫"证明"——证明合约在各种极端输入下依然保持你要的状态。
传统 Web2 项目里,我们习惯围绕接口边界写用例:正常参数、空参数、非法参数、超长参数。但在链上场景,边界条件里还掺进了很多链特有的变量,比如调用者地址、时间戳、区块高度、代币授权额度、重入攻击、整数溢出,这些变量在普通后端项目里根本不需要考虑,但在合约测试里每一样都可能是漏洞入口。
1.2 Hardhat 时代我在测试里最痛苦的三件事
不是要踩 Hardhat,它到今天依然是个优秀的框架,但它的测试体验确实有硬伤。
首先是语言割裂。合约用 Solidity 写,测试用 JavaScript 写。这是最常见的摩擦点——你在写一个transfer测试时,心里要想两套类型系统、两套命名规范、两套异步模型。取一个合约变量要await token.balanceOf(addr),拿到结果发现是 BigNumber,还得调用.toString()才能跟普通数字对比。说白了,简单场景能忍,一旦涉及复杂的权限校验、时间锁、多重继承,两边代码来回切换非常崩溃。
其次是链上环境模拟特别费劲。想模拟一个攻击者调用你的合约?要么在hardhat.config里配置一堆账户,要么引入额外插件。想跳到未来某个时间点?得手写evm_increaseTime的 RPC 调用。这些操作在 Foundry 里本质上都收敛成了一个vm.prank()或vm.warp(),一行代码的事。
第三是速度。每次启动 Hardhat 网络、部署合约、跑测试,等待的时间足够泡一杯咖啡。Foundry 因为是 Rust 原生实现,编译快、执行快、并行跑测试也是默认行为,这个体验差异在测试量大之后会变得非常明显。
1.3 Foundry 的设计核心:让测试成为 Solidity 的一部分
Foundry 这套工具链由三部分组成:forge是构建和测试框架,cast是链上交互命令行工具,anvil是本地节点。日常写测试用到最多的就是forge test,而它的核心亮点可以总结成三点:
- 测试文件和业务合约使用同一种语言 Solidity,消除了心智切换。
- cheatcodes(
vm开头的函数)提供了一整套 EVM 层操作能力,直接模拟链环境。 - 测试执行速度快、输出信息量大,支持 fuzz 和 invariant 等高级测试模式。
这套设计让测试不只是"验证代码",更接近"描述合约应该有的行为"。你写出来的测试代码,读起来就像一份可执行的规范文档。下面我从环境搭建开始,带你一步步把整条链路搭起来。
2. 环境搭建与项目初始化:从零到第一个测试用例
2.1 安装 Foundry 工具链
Foundry 的安装非常简洁,通过foundryup这个管理脚本即可。
curl -L https://foundry.paradigm.xyz | bash foundryup装完之后验证一下版本:
forge --version cast --version anvil --version如果你看到forge输出了版本号,说明安装成功。foundryup会下载预编译好的二进制,基本不需要额外处理 Rust 环境,对新手友好。唯一要注意的是,国内网络环境下首次下载可能会稍慢,耐心等一会儿就行。
2.2 forge init 生成的脚手架结构
在空目录里初始化一个项目:
mkdir foundry-demo && cd foundry-demo forge init初始化完成后,目录结构长这样:
. ├── foundry.toml ├── lib/ │ └── forge-std/ ├── script/ │ └── Counter.s.sol ├── src/ │ └── Counter.sol └── test/ └── Counter.t.solsrc/放业务合约,test/放测试文件,script/放部署脚本,lib/放依赖的库。Foundry 约定test/目录下的测试文件以.t.sol结尾,这个命名规范会让forge test自动识别并执行。
跑一下自带的测试,看是否一切正常:
forge test如果输出显示测试通过,说明脚手架没问题。这个时候已经可以动手写自己的合约和测试了。
2.3 foundry.toml 的常用配置项
打开根目录下的foundry.toml,你会发现默认配置很简洁。但真正写项目时,我建议把下面这些字段明确写出来,避免后续踩坑。
[profile.default] src = "src" out = "out" libs = ["lib"] solc_version = "0.8.23" optimizer = true optimizer_runs = 200 evm_version = "paris" gas_reports = ["Counter", "SimpleToken"]几个关键配置的含义:
solc_version:锁定编译器版本,避免不同机器编译行为不一致。optimizer和optimizer_runs:开启优化会降低部署成本,但要注意优化可能改变某些边界行为的字节码,开启前最好对本项目的测试做一次完整回归。evm_version:指定 EVM 版本,如果你部署到的是 Layer 2,可能需要调整为对应的版本(比如cancun)。gas_reports:生成指定合约的 Gas 报告,后面会详细讲它的用法。
理论上forge init生成的配置够跑通默认测试,但把配置项显式写出来,团队协作时每个人拿到的构建行为都是一致的。这是我在真实项目中吃过亏后总结的经验——两个人用的solc版本不同,同样的代码可能跑出不同的测试结果。
2.4 引入 forge-std 依赖
forge-std是 Foundry 官方的标准库,几乎每个测试都要用到,因为Test基类、断言函数、console日志都来自它。
forge install foundry-rs/forge-std安装完成后,项目根目录会生成remappings.txt,内容大致如下:
forge-std/=lib/forge-std/src/这个映射文件的作用是把forge-std这个包名指向实际存放路径,这样在 Solidity 代码里import "forge-std/Test.sol"就能正确找到文件。如果你的项目新增了其他依赖,记得同步更新remappings.txt。
3. 第一个测试文件:setUp、test 与断言三板斧
3.1 一个待测的简单合约
为了让入门的台阶尽量低,先用一个经典计数器合约来做演示。它虽然简单,但足以把 Foundry 测试的基本骨架说清楚。
// src/Counter.sol contract Counter { uint256 public count; function increment() external { count += 1; } function decrement() external { require(count > 0, "Counter: underflow"); count -= 1; } function reset() external { count = 0; } }这个合约有三个状态变更操作,其中decrement带一个require检查,专门用于演示回滚测试。
3.2 setUp 函数在测试里扮演的角色
在test/目录下新建Counter.t.sol,写入测试代码:
// test/Counter.t.sol import "forge-std/Test.sol"; import {Counter} from "../src/Counter.sol"; contract CounterTest is Test { Counter counter; function setUp() public { counter = new Counter(); counter.increment(); counter.increment(); } function test_InitialState() public view { assertEq(counter.count(), 2); } function test_Increment() public { counter.increment(); assertEq(counter.count(), 3); } function test_RevertWhen_DecrementUnderflow() public { counter.reset(); vm.expectRevert(bytes("Counter: underflow")); counter.decrement(); } }setUp()是每个测试函数执行前都会运行的初始化逻辑,相当于给每个测试创建一个全新的环境。注意,setUp()会在每个测试前执行一次,而不是整个测试合约执行一次。这意味着test_InitialState和test_Increment看到的count初始值都是 2,互不影响。这个设计非常重要——它保证了测试之间的隔离性,避免因为执行顺序导致状态污染。
从上面的代码可以看出来,Foundry 的测试函数极其直观:想测什么就直接调用什么,然后用assertEq检查结果是否符合预期。没有 Promise,没有异步等待,代码完全用 Solidity 的视角在思考。
3.3 断言函数的使用与选择
forge-std提供了丰富的断言函数,基本覆盖了所有比较场景:
| 函数 | 作用 |
|---|---|
assertEq(a, b) | 比较两值是否相等,支持uint、int、address、bytes32、string等类型 |
assertTrue(cond) | 判断布尔条件是否为真 |
assertFalse(cond) | 判断布尔条件是否为假 |
assertGt(a, b)/assertGe(a, b) | 判断a > b/a >= b |
assertLt(a, b)/assertLe(a, b) | 判断a < b/a <= b |
assertEqUint(int, uint) | 有符号与无符号整数比较 |
还有一个很实用的细节:字符串比较assertEq(string1, string2)在大多数语言里都是深比较,Solidity 里做不了直接等号比较,但 Foundry 的assertEq把它封装好了。如果你的项目需要比较 bytes、address,也都有对应的重载版本。
3.4 测试命名规范与语义表达
给测试函数起名这件事,看起来很随意,实际影响不小。我一般遵循一个模式:测试名要回答三个问题——测的是什么方法,前置条件是什么,预期结果是什么。
function test_Increment_WhenCalled_ShouldIncreaseCount() public function test_RevertWhen_CountIsZero_DecrementShouldRevert() public这个命名风格在社区里被称为"行为驱动命名",好处是一旦测试失败,错误信息里立即能看到是哪个行为违背了预期,不用点开代码慢慢找。
运行这些测试:
forge test -vvv-v参数控制输出详细程度,-vvv会显示每个测试的事件日志和失败时的具体断言信息,是开发阶段最常用的调试手段。
4. 模拟链上环境:cheatcodes 才是测试的杀手锏
4.1 cheatcodes 到底解决了什么问题
很多人刚开始用 Foundry 会有一个疑惑:既然测试跑在本地 EVM 里,为什么还需要专门的"作弊码"?因为实际合约运行时依赖大量链上外部因素,这些因素在普通测试里无法自然构造。
举几个具体场景:
- 合约里写死了
onlyOwner,你想测试非 owner 调用的情况。 - 合约里有时间锁,你想模拟"一年后"这个时间点。
- 合约需要接收 ETH,你想给某个测试地址塞一笔余额。
- 合约会回滚,你想断言回滚信息是否准确。
这些场景用 Hardhat 也不是不能做,但需要改配置、发 RPC、填构造参数。Foundry 的做法是直接把vm对象注入测试合约,一行代码解决。所有 cheatcodes 都通过vm变量调用,而这个变量来自Test基类。
4.2 身份模拟:prank 与 startPrank
权限测试是合约测试里最频繁出现的场景。以最常见的transfer函数为例:
// src/SimpleToken.sol contract SimpleToken { string public name = "Simple Token"; string public symbol = "ST"; uint8 public decimals = 18; uint256 public totalSupply; mapping(address => uint256) public balanceOf; event Transfer(address indexed from, address indexed to, uint256 amount); constructor(uint256 _initialSupply) { totalSupply = _initialSupply * 10 ** decimals; balanceOf[msg.sender] = totalSupply; } function transfer(address to, uint256 amount) external returns (bool) { require(to != address(0), "SimpleToken: transfer to the zero address"); require(balanceOf[msg.sender] >= amount, "SimpleToken: insufficient balance"); balanceOf[msg.sender] -= amount; balanceOf[to] += amount; emit Transfer(msg.sender, to, amount); return true; } }如果想测试"Alice 转 10 个 token 给 Bob",就得让合约以为调用者是 Alice。直接调用是做不到的,因为测试合约默认的msg.sender就是测试合约自身。
function test_Transfer_FromAliceToBob() public { vm.startPrank(alice); token.transfer(bob, 100); vm.stopPrank(); assertEq(token.balanceOf(alice), totalSupply - 100); assertEq(token.balanceOf(bob), 100); }vm.prank(address)只对下一次调用生效,vm.startPrank(address)则一直生效,直到vm.stopPrank()被调用。如果测试中间只用一次身份变化,用prank就够了;如果连续多次调用都希望以某个身份执行,用startPrank更省心。
这里有个容易踩的坑:如果你在一个地址上startPrank之后忘了stopPrank,后续所有调用都会继续以该地址身份执行。正常情况下没问题,但如果后面突然想切换回测试合约自身发起调用,就必须显式stopPrank。所以写测试时,我的习惯是能prank就不用startPrank,减少状态残留。
4.3 异常断言:expectRevert 的进阶用法
断言某个操作应该回滚是测试里很常见的需求。最基础的写法:
vm.expectRevert(bytes("SimpleToken: insufficient balance")); token.transfer(alice, 1);注意expectRevert必须放在即将触发回滚的调用之前,它"预判"下一次调用会失败。如果下一次调用没有回滚,测试会失败;如果回滚的信息和预设的不一致,测试也会失败。
Foundry 还支持更高级的错误匹配。如果合约使用了自定义 error:
error InsufficientBalance(uint256 available, uint256 required);那么测试里可以这样断言:
vm.expectRevert( abi.encodeWithSignature("InsufficientBalance(uint256,uint256)", 0, 1) ); token.transfer(alice, 1);这样一旦InsufficientBalance的参数不符合预期,测试也会失败,能更精确地捕获错误逻辑。对于使用自定义 error 的项目,建议把 error 签名和参数同时断言,而不是只断言 revert 发生。
4.4 时间与区块模拟
链上合约经常和时间打交道,比如质押、拍卖、锁仓。假设我要测一个带时间锁的提款合约:
// src/TimelockVault.sol contract TimelockVault { address public owner; uint256 public unlockTime; constructor(uint256 _lockDuration) { owner = msg.sender; unlockTime = block.timestamp + _lockDuration; } function withdraw() external { require(block.timestamp >= unlockTime, "TimelockVault: not unlocked yet"); require(msg.sender == owner, "TimelockVault: not owner"); // 实际转账逻辑省略 } }测试"未到时间不能提款"和"到时间可以提款"这两个场景,需要手动调整链上时间:
function test_RevertWhen_WithdrawBeforeUnlockTime() public { vm.expectRevert(bytes("TimelockVault: not unlocked yet")); vault.withdraw(); } function test_WithdrawAfterUnlockTime() public { vm.warp(block.timestamp + 1000); vm.roll(block.number + 10); vault.withdraw(); }vm.warp直接设置block.timestamp,vm.roll设置block.number。这两个操作对时间相关测试特别有用。还有一个常见的vm.deal(address, amount)可以直接给某个地址塞 ETH,在测试需要余额的场景下非常好使:
vm.deal(address(this), 100 ether);4.5 cheatcodes 速查表
下面整理一份我最常用的 cheatcodes,方便日常查阅:
| cheatcode | 作用 |
|---|---|
vm.prank(addr) | 下一次调用将msg.sender设为addr |
vm.startPrank(addr)/vm.stopPrank() | 连续多次调用将msg.sender设为addr |
vm.expectRevert() | 预判下一次调用回滚,可配合错误信息或签名 |
vm.warp(timestamp) | 修改当前区块时间 |
vm.roll(number) | 修改当前区块高度 |
vm.deal(addr, amount) | 给指定地址转入 ETH |
vm.store(addr, slot, value) | 直接覆盖指定合约的某个存储槽位 |
vm.etch(addr, code) | 修改指定地址的合约字节码 |
vm.createSelectFork(url, blockNumber) | 创建并切换到指定链上的分叉测试环境 |
vm.createSelectFork这个能力值得多说一句——它可以把测试跑到真实链的某个高度上,直接读取主网状态。比如我要测试某个 DEX 合约对真实 ETH/USDC 池子的交互,可以 fork 主网后在本地环境跑,不受真实交易影响。这个在集成测试里特别好用,是我们做真实项目联调的重要工具。
5. 参数化测试:fuzz 与 invariant 的进阶玩法
5.1 fuzz 测试为什么比手写边界值强
手写测试用例永远只能覆盖你"想到"的输入,fuzz 测试的思路不同——它让合约自己跑大量的随机输入,帮你找出那些想都没想过的边界情况。
以SimpleToken.transfer为例,手写测试通常只测几个整十的数,但 fuzz 会尝试极小的值、接近uint256最大值的值、随机地址、零地址,等等。很多合约漏洞就是这么被"试"出来的。
更重要的是,fuzz 是 Foundry 内置的原生能力,不需要额外安装任何东西。一个测试函数只要带参数,就会被识别为 fuzz 测试,例如:
function testFuzz_Transfer( address from, address to, uint256 amount ) public { // 前置条件:from 必须拥有足够的余额 vm.assume(from != address(0)); vm.assume(to != address(0)); vm.assume(from != to); // 给 from 分配足够余额 vm.deal(from, 1000); token.mintForTest(from, amount); // 这里调用一个测试专用的 mint 函数 }我在上面示例里用了vm.deal(address, uint256)来给 from 地址分配 ETH,但实际给 from 分配 ERC-20 token 想要让transfer成功,前提是from的余额足够大。这里就涉及一个 fuzz 测试的关键逻辑——输入是随机的,测试代码要保证前置条件成立。
vm.assume(condition)的作用是丢弃不满足条件的输入。Foundry 会持续生成新的输入值,直到条件满足或达到最大尝试次数。在准备SimpleToken的 fuzz 测试时,我的做法是让 offset 以bound方式限定输入范围:
function testFuzz_Transfer_Bounded(address to, uint256 offset) public { vm.assume(to != address(0)); uint256 toTransfer = bound(offset, 1, totalSupply); counter.transfer(to, toTransfer); assertEq(counter.balanceOf(to), toTransfer); }bound()是 forge-std 提供的便捷函数,它把随机输入映射到指定区间,不用写一堆vm.assume。这个函数在 fuzz 测试里出现频率极高,建议记下来。
5.2 fuzz 测试的运行配置
fuzz 的运行次数默认是 256,意味着每个测试函数会被随机输入跑 256 次。如果你希望提高覆盖密度,可以在foundry.toml里调大:
[fuzz] runs = 1000也可以单独给一次命令传递参数:
forge test --fuzz-runs 5000这里有一个性能注意事项:每次 fuzz 都要跑完整的 EVM 执行,runs越大耗时越长。写 fuzz 逻辑时尽量保持前置条件简单,避免在循环里做大量计算,否则一次测试可能要跑很久。
5.3 invariant 测试:验证合约的"恒定真理"
invariant 测试解决的问题比 fuzz 更进一层——它不只测单个函数的输入边界,而是随机组合调用多个函数,验证合约的某个恒定属性始终成立。
举一个最简单的例子:Counter 合约的不变量是count永远不会小于 0。虽然这个合约里已经有require保证了,但真实场景里的不变量通常复杂得多,比如"系统总质押量始终等于所有用户质押量之和",或者"代币的总供应量不变""某地址的授权额度永远不会超过某个上限"。
在 Foundry 中写 invariant 测试:
contract CounterInvariantTest is Test { Counter counter; function setUp() public { counter = new Counter(); } function invariant_CountNeverNegative() public view { assertGe(counter.count(), 0); } }forge test会把invariant_开头的函数当作 invariant 测试执行。Foundry 会随机挑选合约的可调用函数执行任意次数,然后断言不变量是否被破坏。
注意,invariant 测试一般配合handler合约使用,让攻击者等特定角色也参与进来。对于入门阶段,了解它解决的问题和基本写法就够了,等真正需要测试协议级项目时再深入研究不迟。
5.4 用覆盖率评估测试质量
跑完一堆测试,心里还是没底怎么办?看覆盖率。Foundry 自带覆盖率报告:
forge coverage命令执行完会输出行覆盖率、分支覆盖率、函数覆盖率等指标。这个数据虽然不能代表测试的全部价值,但能帮你找到完全没测到的代码路径。
我的工作习惯是:覆盖率作为辅助指标,不盲目追求 100%。有些防御性代码(比如require防止零地址)确实很难通过常规路径覆盖到,硬凑覆盖率反而会让测试变成"为了覆盖而覆盖",失去了测试的意义。真正有价值的是覆盖核心业务逻辑和高风险路径,这些路径往往和资金安全直接相关。
6. 调试输出、gas 报告与真实压测体验
6.1 console2.log 调试
写测试时打印变量是最高频的调试方式。Foundry 提供了console2.sol,用法跟 Solidity 的console.sol一样,但在解析错误信息时表现更好。
import "forge-std/console.sol"; function test_LogDebug() public view { uint256 balance = counter.count(); console2.log("current count:", balance); console2.log("msg.sender:", msg.sender); console2.logInt(-42); }输出会直接显示在测试结果里:
Logs: current count: 2 msg.sender: 0x7cFA...需要注意,console2.log输出只在-vv及以上才会显示完整日志,日常调试建议用-vvv。
6.2 读懂 -vvvv 的调用 trace
当某个测试失败时,你往往需要弄清它为什么失败。单测日志只能告诉你"结果不等于预期",但 trace 能告诉你每一步发生了什么。
forge test --match-test test_Transfer_FromAliceToBob -vvvv-vvvv会输出完整的调用链,包括每一层内部调用、事件日志、错误信息。在排查跨合约调用和复杂逻辑时的实际体验,比在 Remix 里 debugger 一步步点要舒服得多。
6.3 gas 报告:找到合约的燃料大户
Gas 费用在以太坊生态里意味着真实的成本。同一个功能,不同实现的 gas 消耗可能差出几倍。Foundry 可以在foundry.toml里配置 gas 报告:
gas_reports = ["SimpleToken", "Counter"]运行测试后,终端会打印一段表格:
| src/SimpleToken.sol contract | | | | | |------------------------------|--------|-------|--------|--------| | Function Name | min | avg | median | max | | transfer | 46490 | 48201 | 48201 | 49912 |通过这个报告,你能直观看到哪个函数在最坏情况下消耗了多少 gas,并以此决定是否需要优化。比如transfer的max值比min值高很多,说明某些输入路径走了更重的分支。这种数据在手,优化起来就有了明确方向。
6.4 测试隔离与 setUp 的性能成本
此前已提到,Foundry 会为每个测试函数执行一次setUp。这意味着如果你在setUp里部署了 10 个合约,跑 100 个测试就要重复部署 1000 次。合约部署是很重的操作,这会导致整个测试套件变慢。
优化手段是分级初始化。把不需要每个测试都重新部署的常量抽出来用immutable修饰,或者把通用逻辑放在 helper 合约里。如果测试量特别大,还可以考虑使用setUp内部的snapshot和revertTo模式,在初始化状态后打快照,测试内再回滚到快照,省去重复部署的开销。
function setUp() public { counter = new Counter(); snapshot = vm.snapshotState(); } function test_Something() public { counter.increment(); // 这个改动不会影响其他测试 vm.revertTo(snapshot); }不过这个方案会让测试之间产生隐式耦合,建议在测试量真正大到无法接受时才使用。
写在最后的一点建议
我在实际项目中把 Foundry 作为主力测试框架已经有一段时间,最大的感受是:写测试的积极性变高了。因为测试代码和业务代码用同一种语言,原来需要查 ABI、处理类型转换的琐碎事统统消失,我可以把全部注意力放在"这个函数应该表现为什么行为"上。尤其是 fuzz 测试,运行时会自动帮我探索边界条件,很多自己都没想到的输入组合被它试出来了,这种安心感是手写用例给不了的。
如果你正打算给合约项目补测试,我的建议是:不要一上来就写复杂的集成测试,先从单个核心合约下手,用基础断言把常规路径和回滚路径覆盖一遍,再逐步引入 cheatcodes 模拟复杂场景,最后再把 fuzz 和 invariant 加进来补齐边界。这套路径我已经走了很多次,是目前效率最高的入门路线。Foundry 的文档也在持续完善,配合本期实战文章,遇到问题基本都能快速解决。