你可能已经见过不少项目把 Solidity 测试写在 JavaScript/TypeScript 里,通过 Hardhat 配 Mocha、Waffle 或 ethers 来跑,这套组合在生态里成熟、资料多,但也确实有让人头疼的地方:测试代码是另一种语言,部署脚本要写 async/await,断言库要额外装,跑了半天还可能因为某个 RPC 节点超时挂掉。
Foundry 的到来几乎是在说:既然合约是 Solidity 写的,为什么测试不能用 Solidity 写?它把测试、部署、脚本、查链工具全部塞进一套以 Solidity 为中心的工具链里,核心就是forge test一条命令跑完整个测试流程。这篇文章我会从零开始,带你装好 Foundry、初始化项目、写第一组 Solidity 测试,然后逐步深入到 cheatcode、fork 测试、模糊测试这些实际开发中真正高频使用的功能。无论你是刚接触 Solidity 测试的新手,还是想从 Hardhat 迁移过来的 Web3 开发者,这套流程都能直接照着做。
1. 为什么选 Foundry 写 Solidity 测试:和 Hardhat 那套有什么本质不同
1.1 “测试即代码”这件事,真的不是噱头
先说一个最直观的区别。在 Hardhat 生态里,测试代码长这样:
const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("Bank", function () { it("should deposit", async function () { const Bank = await ethers.getContractFactory("Bank"); const bank = await Bank.deploy(); await bank.deposit({ value: 100 }); expect(await bank.balanceOf(owner.address)).to.equal(100); }); });而在 Foundry 里,同样一个测试长这样:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test, console2} from "forge-std/Test.sol"; import {Bank} from "../src/Bank.sol"; contract BankTest is Test { Bank bank; function setUp() public { bank = new Bank(); } function test_Deposit() public { bank.deposit{value: 100}(); assertEq(bank.balanceOf(address(this)), 100); } }测试合约本身就是一个 Solidity 合约,setUp()函数在每条测试用例前执行,test开头的函数会被自动识别为测试用例。你不用再去记 Chai 的断言语法,也不用纠结await什么时候该加,assertEq、assertTrue、assertGt这些断言都是 forge-std 提供的内置函数,直接写、直接跑。
这套设计带来的第一个实际好处是速度。Foundry 编译完合约之后,测试直接发生在原生的 EVM 环境里,没有复杂的 RPC 通信和 JSON-RPC 序列化过程。我自己的体验是,跑一个几百个用例的项目,Hardhat 可能要一两分钟,Foundry 通常是几秒到十几秒。对于需要频繁验证逻辑的迭代阶段,这个差异会直接改变你的开发习惯——你不再怕跑测试费时间,于是会更愿意跑。
第二个好处是状态控制能力。Foundry 提供了一套 cheatcode,也就是测试环境里对 EVM 的特殊操作接口,比如修改msg.sender、修改任意地址余额、调整区块时间。这些操作在 Hardhat 里往往要借助evm_mine之类的外部方法或者依赖插件,而在 Foundry 里就是一行vm.prank(...),直观又强大。
第三个容易被忽略的好处是共识。测试代码和业务代码用同一种语言,团队里的 Solidity 开发者可以直接看懂测试逻辑,而不用在 JS 和 Solidity 两套思维里反复横跳。新人上手时,TestCase 读起来就像一份可执行的合约行为文档。
1.2 这套方案适合什么项目,又有哪些前提
Foundry 并不是要取代所有 Web3 开发工具。它格外适合以下几类场景:
- 核心是合约逻辑、需要大量单元测试和边界验证的项目,比如 DeFi 协议、借贷合约、AMM、治理模块。
- 需要做高覆盖率测试、模糊测试和复杂权限控制的合约,Foundry 的 fuzz 和 invariant 测试支持得非常好。
- 依赖链上现有协议的集成项目,可以用 fork 模式直接测试与 Uniswap、Compound 这类协议的交互,不用先在本地部署一堆 mock。
但如果你只是写一个简单的 NFT 合约,并且前端团队需要 JS 测试脚本做整体联调,Hardhat 可能更顺手。Foundry 也不适合完全不懂 Solidity 的人直接入门——你至少得知道构造函数、状态变量、外部调用这些基础概念,否则测试代码里全是看不懂的语法。
另外,Foundry 对运行环境有一定要求:建议在 macOS 或 Linux 环境下使用,Windows 用户最好通过 WSL2 跑,需要系统装有git和curl。这些工具本身不复杂,但确实是开始前的硬前提。
2. 环境搭建与项目初始化:从装工具到跑通第一个 build
2.1 四个核心命令工具:forge、cast、anvil、chisel
Foundry 不是单一程序,而是一套工具链,安装时一次会装上四个主要命令:
forge:核心武器,负责项目初始化、编译、测试、部署脚本执行、覆盖率分析等。cast:链上交互工具,可以查余额、查交易、调用合约、解码 calldata,开发调试的时候非常有用。anvil:本地开发节点,相当于 Hardhat 里的本地链,可以在测试环境快速起一个链。chisel:Solidity 交互式 REPL,可以在命令行里写 Solidity 代码试运行,适合快速验证小段逻辑。
安装方法在官方文档里写得很简单,就两条命令:
curl -L https://foundry.paradigm.xyz | bash执行之后,会在你的 home 目录下安装foundryup命令。注意curl | bash这种方式在实际生产环境里容易被安全审计问罪,但 Foundry 官方一贯采用这种方式,你在自己本地开发机执行没问题。装完foundryup后,再执行:
foundryup这一步会拉取预编译好的工具链并安装到~/.foundry/bin目录。安装结束以后,建议先source ~/.bashrc或source ~/.zshrc刷新环境变量,再检查版本:
forge --version如果输出类似forge 1.0.0 (abcdef1 2025-01-01T00:00:00.000000000Z)这样的版本信息,就说明环境已经就绪。以后想更新版本,再跑一次foundryup就行,它会自动下载更新版本并覆盖,这是这套工具链里最省心的一点。
2.2 forge init 之后,项目目录里到底放了些什么
创建一个新项目很简单:
forge init my-web3-test cd my-web3-test forge buildforge init会拉一个默认模板,包含一个最小合约和一个对应的测试文件。执行完以后,项目里会生成这样一些目录和文件:
my-web3-test/ ├── foundry.toml ├── remappings.txt ├── script/ │ └── Counter.s.sol ├── src/ │ └── Counter.sol ├── test/ │ └── Counter.t.sol └── lib/ └── forge-std/这些目录的命名和用途需要理清楚,因为它们会影响你后续放文件的位置:
src/:存放合约源码。默认模板里的Counter.sol就是个简单的计数器合约,功能和说明文档一致。test/:存放测试合约。默认文件名Counter.t.sol用.t.sol后缀标记这是一个测试文件,方便工具识别。script/:存放部署和操作脚本,Sol文件的命名习惯是Counter.s.sol。lib/:存放外部依赖库,默认已经装好了forge-std,这是 Foundry 的标准测试库。foundry.toml:核心配置文件,编译参数、EVM 版本、优化器开关都在这里。remappings.txt:路径映射文件,解决 import 时库的路径对应问题。
这时候你执行forge build,如果能看到类似Compiling 5 files with 0.8.23这样的日志输出,并且最终出现Success,说明基础项目已经可用了。默认模板里自带的Counter合约和测试可以直接用forge test跑通,这可以帮你在改动任何代码之前先确认工具链没有毛病。
2.3 remappings 和 foundry.toml 里的关键配置
用 Foundry 写测试,大概率会碰到import {Test} from "forge-std/Test.sol"这样的导入语句。这个forge-std为什么不用写相对路径?靠的就是 remappings。
安装依赖用:
forge install foundry-rs/forge-std安装完成后,项目里会出现lib/forge-std目录,同时remappings.txt里会自动添加一行:
forge-std/=lib/forge-std/src/它的意思是:所有以forge-std/开头的 import 路径,都去lib/forge-std/src/目录下找真实文件。你也可以在foundry.toml里用remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]这样的写法声明映射,两种方式效果一样。装完依赖、写了新引入后,如果 IDE 还是报找不到文件,多半是 remappings 没更新,跑一下forge remappings看输出,如果跟你预期不一致,手动改一下remappings.txt即可。
foundry.toml里最常用的配置项这些:
[profile.default] src = "src" out = "out" libs = ["lib"] solc_version = "0.8.23" optimizer = true optimizer_runs = 200 evm_version = "cancun" ffi = false其中solc_version建议固定成项目实际使用的版本,防止拿不同编译器版本编译出现意外差异。evm_version跟编译器版本和部署目标链都有关系,如果用到比较新的 opcode,比如 Cancun 升级引入的TSTORE、MCOPY,就必须设置成cancun。后面在问题排查部分我还会具体讲这个坑。
3. 从零开始编写第一组测试:一个简单 Bank 合约的全流程
3.1 写一个带存取款功能的合约,搭建测试骨架
为了不浪费时间在模板的 Counter 上,我们直接写一个稍微有点业务逻辑的合约。假设我们有一个Bank合约,核心功能是存款和取款:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Bank { mapping(address => uint256) private _balances; address public owner; event Deposited(address indexed account, uint256 amount); event Withdrawn(address indexed account, uint256 amount); constructor() { owner = msg.sender; } function deposit() external payable { require(msg.value > 0, "deposit amount must be > 0"); _balances[msg.sender] += msg.value; emit Deposited(msg.sender, msg.value); } function withdraw(uint256 amount) external { require(amount <= _balances[msg.sender], "insufficient balance"); _balances[msg.sender] -= amount; payable(msg.sender).transfer(amount); emit Withdrawn(msg.sender, amount); } function balanceOf(address account) external view returns (uint256) { return _balances[account]; } }这个合约虽然简单,但已经覆盖了测试中最重要的三类行为:
- 状态变更:存款后余额增加。
- 条件校验:取款超过余额会 revert。
- 事件输出:存取款都会触发事件。
接下来在test/目录下新建文件Bank.t.sol,写测试骨架:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test, console2} from "forge-std/Test.sol"; import {Bank} from "../src/Bank.sol"; contract BankTest is Test { Bank bank; address alice = address(0x1111); address bob = address(0x2222); function setUp() public { bank = new Bank(); vm.deal(alice, 100 ether); vm.deal(bob, 100 ether); } }这里有个关键点:setUp()会在每一条测试用例执行前运行,所以每个测试函数都会拿到一个全新的bank合约实例。测试合约本身会作为默认的调用者,也就是说bank.deposit{value: ...}()的msg.sender是这个BankTest合约地址,而不是外部 EOA 地址。后面需要用alice、bob这类角色做权限隔离时,就要用到vm.prank这类 cheatcode。
3.2 断言、事件和 revert 一起上,把行为锁死
先写最基础的存款测试:
function test_Deposit_IncreasesBalance() public { bank.deposit{value: 1 ether}(); assertEq(bank.balanceOf(address(this)), 1 ether); } function test_Deposit_RejectZeroValue() public { vm.expectRevert(bytes("deposit amount must be > 0")); bank.deposit{value: 0}(); }这两条用例分别覆盖了正面行为和负面行为。assertEq是 forge-std 里最常用的断言函数,支持uint256、address、bytes32、string等多种类型。vm.expectRevert则用来声明下一条调用应该 revert,如果预期错误信息和实际 revert 原因不一致,测试会失败。
再看事件断言。Foundry 里常用vm.expectEmit做事件断言,被很多人忽略,但它是确保合约事件输出稳定的关键手段。写法是:先用emit声明预期事件,再执行业务调用:
function test_Deposit_EmitsEvent() public { vm.expectEmit(true, true, true, true); emit Deposited(address(this), 5 ether); bank.deposit{value: 5 ether}(); }expectEmit的四个 bool 参数分别表示from、to、topics、data是否需要严格比对。对 indexed 参数的匹配和topic0的处理,建议先按文档的默认用法来,等需要精确定位某个 topic 时再逐个调整。实际开发中,我更喜欢把事件校验和状态断言同时写在同一个测试函数里,这样一次调用能验证两件事,测试数量不会过于庞大。
再补一个取款相关的用例:
function test_Withdraw_DecreasesBalance() public { bank.deposit{value: 3 ether}(); bank.withdraw(2 ether); assertEq(bank.balanceOf(address(this)), 1 ether); } function test_Withdraw_FailsWhenExceedingBalance() public { bank.deposit{value: 1 ether}(); vm.expectRevert(bytes("insufficient balance")); bank.withdraw(2 ether); }注意这里测试函数名里的下划线,是我故意保留的。Foundry 只要求函数以test开头,下划线分隔能起到“行为 + 期望结果”的作用,比如test_Withdraw_DecreasesBalance读起来就是“取款减少余额”,一目了然。早期我不会刻意维护这套命名规范,后来项目大了才意识到,测试函数名本身就是最好的行为文档。
3.3 运行测试与读懂输出:不只会跑,还要会看日志
运行测试只需要:
forge test如果一切正常,你会看到类似这样的输出:
Running 5 tests for test/Bank.t.sol:BankTest [PASS] test_Deposit_EmitsEvent() (gas: 98541) [PASS] test_Deposit_IncreasesBalance() (gas: 97824) [PASS] test_Deposit_RejectZeroValue() (gas: 83456) [PASS] test_Withdraw_DecreasesBalance() (gas: 78542) [PASS] test_Withdraw_FailsWhenExceedingBalance() (gas: 78901) Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 12.80ms[PASS]后面的括号里是本次调用的 gas 消耗,这个值对合约优化有直接参考价值。如果某些用例失败,你会看到[FAIL. Reason: ...]和具体到行号的错误信息,同时测试套件会给出失败统计。
forge test支持很多输出级别,用-vv、-vvv、-vvvv控制日志详细程度:
-vv:显示测试函数内的 console 日志。-vvv:显示每个调用栈的 trace。-vvvv:把存储变量、调用数据、revert 原因全量打印。
实际调 bug 或排查 revert 原因时,-vvvv基本是标配。另外,如果只想跑某个测试文件或某个特定测试函数,不用--match-path和--match-test两个参数:
forge test --match-path test/Bank.t.sol forge test --match-test test_Withdraw_DecreasesBalance这两个参数支持正则表达式,可以快速筛出你关心的用例,省掉大量无效输出。
4. 测试进阶:用 cheatcode 模拟真实链上环境,覆盖复杂场景
4.1 prank、deal、warp 三个高频 cheatcode 的实战用法
Foundry 的 cheatcode 是测试框架中价值密度最高的部分,所以值得单独拆开讲。最常用的三个是vm.prank、vm.deal、vm.warp。
vm.prank(address)的作用是把下一次调用的msg.sender设置为指定地址,执行完成后立即恢复。这是测试权限控制的核心工具,比如我们要验证只有 owner 才能调某个函数,可以这样:
function test_OnlyOwnerCanChangeConfig() public { vm.prank(bob); vm.expectRevert("not owner"); bank.setOwner(bob); }如果需要在连续多次调用中都保持某个调用者身份,可以用vm.startPrank和vm.stopPrank包裹一段代码块。这里有个坑:如果你startPrank之后忘了stopPrank,后续这条测试里所有调用都会带上这个伪造身份,很容易导致莫名其妙的失败。我的习惯是,只伪造一次调用就用prank,需要连续伪造再用startPrank/stopPrank配对,并且尽量把业务逻辑放在一个带缩进的代码块里,保持肉眼可定位。
vm.deal(address, uint256)直接给指定地址设置余额,不需要真的转一笔 ETH。上面的测试骨架里我用它给alice和bob各发了 100 ether,这样就能以他们的身份跑存取款逻辑。它比在 JS 环境里手动转账、再等确认要快得多。
vm.warp(uint256)可以调整当前环境的时间戳,vm.roll(uint256)可以调整区块高度。时间相关合约(比如质押、锁仓)一定要配上这两个函数来测。比如一个锁仓合约,锁定 30 天,测试时不需要真的等 30 天,直接在setUp里vm.warp(block.timestamp + 30 days),然后验证取回逻辑即可。要注意的是,vm.warp之后block.timestamp会变成你设置的值,但block.number不会自动增加,需要时间推进配合区块高度变化时,手动同时设置vm.roll。
4.2 fork 主网测试:在测试里直接跟真实协议交互
有些合约需要和链上已经存在的协议交互,比如你的合约要调用 Uniswap 的 swap 接口。这种情况下,本地重新部署一个 Uniswap 全套几乎不可能。Foundry 支持 fork 测试,也就是说,本地测试环境可以挂载到某个 RPC 节点上,直接把主网或测试网的状态拉进来。
常用方式是在foundry.toml里配置:
[rpc_endpoints] mainnet = "https://eth.llamarpc.com"然后在跑测试时指定 fork URL:
forge test --fork-url mainnet也可以写成环境变量方式:
forge test --fork-url $MAINNET_RPC_URLfork 模式下的关键点在于:你在测试中做的任何写入操作都不会真的发到链上,而是发生在一个基于fork状态的临时 EVM 里;只有读取状态是来自真实链的。这就像你拿到一份线上数据的快照,在本地做了一个“假设改动”,一次验证多个分支。
实际使用中有几个小技巧。第一,fork 后地址的私钥需要用vm.addr(uint256)从私钥推导,不要写死地址但拿不到私钥,容易在后续调用里因为msg.sender权限不足而失败。第二,刚好有依赖多链合约的项目,建议把测试用的 RPC URL 配置到独立环境中统一管理,不要硬编码在yml或脚本里。第三,fork 模式下单次测试速度会比本地模式慢不少,毕竟需要从 RPC 拉取状态,尽量在测试文件里做好分层,把需要 fork 的用例单独拎到一个目录、用--match-path控制。
4.3 模糊测试与不变量测试:让机器替你“找茬”
模糊测试(Fuzz Testing)是 Foundry 最让人兴奋的能力之一。普通的测试函数只能固定一组参数,而 fuzz 测试函数可以声明参数,Foundry 会自动生成大量随机值来跑测试。比如:
function testFuzz_Deposit_AlwaysIncreasesBalance(uint256 amount) public { amount = bound(amount, 1, 100 ether); bank.deposit{value: amount}(); assertEq(bank.balanceOf(address(this)), amount); }注意这里用bound函数把amount限制在合理范围内,防止随机值过大导致算术溢出或 gas 耗尽。bound是 forge-std 提供的便捷函数,底层是通过%取模,比vm.assume更可控、更不容易因为样本被丢弃而影响整体覆盖率。
模糊测试的价值在于,它会自动寻找那些你可能没考虑到的边界输入。比如你写了一个uint256参数但忘记处理0值,可能跑上百个固定用例都不会踩到,但 fuzz 跑几秒钟就能把它暴露出来。实际项目中对算术逻辑、奖励计算、汇率换算这类合约,我基本都会配一两个 fuzz 测试。
不变量测试(Invariant Testing)是更进一步的验证方式。它是指合约在任意随机操作序列下,某个状态属性始终不变。比如“用户总存款等于每个用户余额之和”,无论你怎么存取款,这个等式都必须成立。
Foundry 里的不变量测试写法稍微特殊,一般约定函数名以invariant_开头,并且要实现targetContract来告诉测试框架要随机调用哪个合约:
contract BankInvariantTest is Test { Bank bank; function setUp() public { bank = new Bank(); targetContract(address(bank)); } function invariant_TotalSupplyMatchesSum() public view { uint256 total = bank.totalBalance(); assertEq(total, bank.balanceOf(address(this)) + bank.balanceOf(alice) + bank.balanceOf(bob)); } }不变量测试特别适合做底层协议的安全验证,比如 AMM 里的恒定乘积公式、借贷协议里的抵押率下限。不过它需要合约能接受随机调用,如果合约有大量onlyOwner之类的权限限制,记得在setUp里通过vm.startPrank设置白名单或用excludeContract排除掉无关合约,否则测试框架随机调用时会被权限卡住,生成一堆假阳性。
5. 工具链协同:覆盖率、快照与调试,把测试数据用起来
5.1 用 forge coverage 找到你的测试盲区
光知道测试全过还不够,还得知道你到底测到了多少代码。Foundry 内置覆盖统计,直接跑:
forge coverage它会输出合约各文件的覆盖情况,包括行覆盖率、分支覆盖率。比如某个分支只有在amount == 0时才会触发,而你从没测过,覆盖率报告会把它标红并提示你。
这个报告最直观的用法是检查新增合约的测试压力。每次写完一个合约,我一般先跑一下forge coverage,看看初始覆盖情况,然后回去补测试。行覆盖率到 90% 以上是比较健康的水平,如果某件事特别难测,比如依赖大量外部状态,至少要保证核心逻辑覆盖到。分支覆盖率比行覆盖率更重要,因为它能抓到“某个 if 分支从来没被踩过”的问题。
如果想生成更详细的 HTML 报告,可以设置forge coverage --report lcov生成 LCOV 格式,再配合 VS Code 插件或genhtml工具可视化。这对团队做测试门槛检查非常有效,建议 CI 里直接加一条“覆盖率低于阈值就不允许合并”的检查。
5.2 用 forge snapshot 看住 gas 成本,防止优化随时间退化
合约开发者经常会忽略 gas 成本的长期变化,但有时候一次无意的代码改动,会让核心函数的 gas 消耗飙升几万。Foundry 的 snapshot 功能就是用来盯住这条线的。
跑一次:
forge snapshot它会在项目根目录生成一个.gas-snapshot文件,内容是每个测试函数的 gas 消耗快照。之后当你改了代码,再跑一次forge snapshot --diff,它就能对比当前 gas 与上次快照的差异,直接告诉你哪些函数成本上涨了。
这个能力在重构合约时尤其好用。我经常在重构前生成一份 snapshot,重构后再 diff 一次,用数据判断这次重构是变好了还是变差了。CI 里也可以加“gas diff 超过阈值则失败”的检查,这样项目组的公共合约不至于默默膨胀。
需要注意的是,.gas-snapshot是文本文件,应该纳入版本控制,否则 diff 就失去基准。如果你希望某类用例不参与 snapshot,可以在测试函数声明里不写test前缀,或者用forge snapshot --skip参数。这个细节很容易被忽略,但影响很大。
5.3 快速定位失败用例:从-vvvv到cast的调试武器库
测试失败的排查,是实际开发里最常花时间的部分。我先说一个最基础却最有效的办法:forge test --match-test 失败函数名 -vvvv。这会输出完整的调用 trace,包括每个地址的调用关系、传递的 calldata、产生的日志、以及 revert 的具体位置。很多失败其实一眼就能从 trace 里看出来,比如某个地址不是合约,或者某个调用者权限不对。
如果需要看合约内部某个变量在中间步骤的值,别用 Solidity 的字符串拼接那一套。forge-std 提供了 console 合约:
import {console2} from "forge-std/console2.sol"; function test_Debug() public { console2.log("balance before:", bank.balanceOf(alice)); // ... }console2会经过编译器优化处理,注入更精确的日志信息,支持打印各种基本类型和数组。调试完记得把 console2 的 import 和日志代码删掉,否则这些日志会留在合约代码里,增加部署字节码体积。
还有一种情况是测试通过了,但你不确定链上真实状态是否符合预期。这时候可以用cast单独查链上数据。比如:
cast call 0x合约地址 "balanceOf(address)(uint256)" 0x钱包地址 --rpc-url mainnetcast还能解码交易、查 block、算 selector,日常排查问题的时候,它的地位完全不亚于forge。记住一个原则:forge负责本地开发循环,cast负责链上真相核对,两者配合使用才能快速判断问题是出在合约逻辑还是测试环境模拟不对。
6. 常见问题与排查技巧实录:这些坑我基本每个都踩过
6.1 编译失败、版本冲突和 EVM 版本设置
最常见的项目刚克隆下来,forge test直接报编译错误,错误信息里经常出现Compiler version not found。根本原因是foundry.toml没指定具体的solc_version,而 Foundry 默认会自行选择或从远端下载一个版本。解决办法是:
solc_version = "0.8.23"固定版本后,项目内所有依赖都用同一个编译器,能大幅减少偶发的不兼容问题。如果你的合约依赖 OpenZeppelin 之类的库,也要确认库要求的版本和solc_version兼容。依赖库和源码版本不一致时,编译错误可能五花八门,比如找不到库符号、函数签名不匹配。
另一个经常被忽视的问题是 EVM 版本。如果合约里用了比较新的类型或操作码,比如自定义错误、beacon相关操作、mcopy等,而你配置的evm_version太老,编译会直接失败。一般建议跟随主流链更新,比如以太坊主网已经支持 Cancun 相关操作码,那么evm_version = "cancun"是合理选择。但要注意,如果你部署的目标链还没升级到对应版本,即使本地编译通过,部署后调用也会崩溃,所以 EVM 版本要从实际部署链出发,而不是只为了编译通过。
6.2 测试写着写着跑不通?地址与存储的边界条件
刚开始写 Foundry 测试的人,最容易踩的一个坑是:测试合约地址本身也是合约地址,它和外部用户地址不同。在Bank测试里,address(this)是BankTest合约地址,如果你在setUp里没有给它发 ETH,那么任何deposit{value: ...}都可能因为余额不足而失败。很多奇怪的“回滚原因说不清”其实都是这个引起的。
另外一个高发问题出现在vm.prank的时序上:
vm.prank(alice); bank.deposit{value: 1 ether}(); bank.withdraw(1 ether); // 这里仍然以为是 alice 在调用,但实际是测试合约地址vm.prank只影响紧接着的这一次调用。如果你在它后面又写了好几行业务逻辑,后面的调用者身份是address(this),并不是你预想的alice。这种问题跑起来就是权限不足,查 trace 才能看出来。解决办法是:需要连续改变调用者时,用vm.startPrank(alice)开头,所有逻辑执行完后再vm.stopPrank(),或者在每一段关键调用前面重新设置一次vm.prank。
还有一类存储相关的问题是,Foundry 测试之间状态是隔离的,每条测试用例在执行前都会重新执行setUp()。有些新手会在setUp里给测试合约打 ETH,结果某条用例里改了状态,下一条用例里没更新预期,导致结果对不上。不要指望一个测试函数里改的状态能传导到另一个测试函数,这种隐性耦合一定要主动避免。
6.3 经验清单:按这套方式来写,测试才不容易返工
我在喂了几年测试框架的苦头之后,总结了一套自己的测试编写习惯,这里分享给你。
第一,测试函数命名尽量带行为和预期结果。不要写test1、test2这种名字,而是用test_Deposit_ShouldRevert_WhenAmountIsZero这种风格。等你有 200 个测试函数后,会发现这个名字直接决定了定位问题的速度。
第二,一个测试函数只验证一个核心行为。有些人喜欢一个大函数里连续做十个断言,失败的时候根本不知道是第几个断言的锅。建议把场景拆分,比如“存款成功后余额增加”和“存款成功后触发事件”是两个不同关注点,拆开写。
第三,先写测试,再写合约。Foundry 的 TDD 体验非常好,先定义好测试里期望的行为,比如函数名、参数、revert 条件,再去src里实现,实现完了直接跑测试验证。这么做的好处是你会从“这个合约应该怎样被使用”的视角出发,而不是“我要怎么把当前的烂代码补个测试”。
第四,善用 fuzz 和不变量测试,但不要无脑加。游戏规则是:逻辑复杂或算术运算多的地方,优先考虑 fuzz;协议核心不变量,必须加 invariant;普通的分支判断,用固定参数就好。测试过多反而会拖慢整个 CI 流水线,所以要有取舍。
第五,把测试当成合约行为的补充文档。当我接手一个新项目时,第一件事不是读源码,而是读它的测试文件。测试怎么写的,基本就代表了这个合约有哪些外部行为、有哪些限制条件。写测试时多花点功夫,后面所有人都会感谢你。
最后分享一个我自己的习惯:每写完一个合约,我会先写一个“基础冒烟测试”,只验证最核心场景能不能跑通,再在它基础上逐步补充异常分支和边界条件。这样做的好处是,即使后面测试越写越多,最核心的保障始终在最前面,任何一个改动都能第一时间发现基础功能是否被破坏。坚持这套节奏两周后,你会发现写合约的底气和之前完全不一样。