Foundry 实战:如何运行 .sol 智能合约测试用例

发布时间:2026/10/18 6:02:35
Foundry 实战:如何运行 .sol 智能合约测试用例
目录测试文件长什么样运行测试forge test 的基本用法调试失败的测试-vvv 和 -vvvv验证错误和事件cheatcodes 的用法时间与身份操控prank 和 warp不变量测试让函数调用序列随机化共享配置setUp 和继承foundry.toml 的关键配置几个容易踩的坑小结Foundry 和 Hardhat 最大的区别在于测试用 Solidity 写不用 JavaScript/TypeScript。这不只是“换种语言”那么简单——它意味着测试和合约运行在完全相同的 EVM 环境里没有跨语言调用带来的抽象泄漏测试跑得也快得多。这篇文章从“写一个测试”开始逐步覆盖运行命令、调试工具和进阶测试策略把forge test这条链路完整走通。测试文件长什么样Foundry 项目的测试文件放在test/目录下以.t.sol结尾。测试合约需要继承forge-std/Test.sol测试函数以test_开头。假设有一个最简单的计数器合约// src/Counter.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; contract Counter { uint256 public number; function setNumber(uint256 newNumber) public { number newNumber; } function increment() public { number; } }对应的测试文件// test/Counter.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; import {Test} from forge-std/Test.sol; import {Counter} from ../src/Counter.sol; contract CounterTest is Test { Counter counter; function setUp() public { counter new Counter(); } function test_Increment() public { counter.increment(); assertEq(counter.number(), 1); } function test_SetNumber() public { counter.setNumber(42); assertEq(counter.number(), 42); } function testFuzz_SetNumber(uint256 x) public { counter.setNumber(x); assertEq(counter.number(), x); } }这里有几个关键约定测试文件必须以.t.sol结尾测试合约必须继承forge-std/Test.sol测试函数以test_或test开头setUp()函数在每个测试函数执行前运行一次。testFuzz_开头的函数是模糊测试——Forge 会自动生成大量随机输入来调用它默认跑 256 次。上面的testFuzz_SetNumber会传入各种uint256值验证setNumber在任何输入下都能正确工作。运行测试forge test 的基本用法最基础的命令就一个forge test运行后会输出每个测试的结果、Gas 消耗和执行时间Ran 3 tests for test/Counter.t.sol:CounterTest [PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 50747, ~: 52565) [PASS] test_Increment() (gas: 51847) [PASS] test_SetNumber() (gas: 28783) Suite result: ok. 3 passed; 0 failed; 0 skipped按名称过滤测试。当测试文件多了之后每次都跑全量测试会很慢。可以用过滤器只跑关心的部分# 只跑某个合约的测试 forge test --match-contract CounterTest # 只跑某个测试函数 forge test --match-test test_Increment # 按文件路径匹配 forge test --match-path test/Counter.t.sol--match-contract和--match-test都支持正则表达式比如--match-test testFuzz会跑所有模糊测试函数。调整模糊测试的运行次数。默认 256 次在本地开发时可能太慢可以在foundry.toml里调低[fuzz] runs 64或者在命令行动态指定forge test --fuzz-runs 128调试失败的测试-vvv 和 -vvvv测试失败时默认输出只告诉你“失败了”不告诉你“为什么失败”。加上-vvv可以看到完整的调用栈追踪forge test -vvv输出会显示每个调用的层级、参数和返回值[FAIL: Unauthorized()] test_WithdrawAsNotOwner() (gas: 8418) Traces: [8418] VaultTest::test_WithdrawAsNotOwner() ├─ [0] VM::prank(ECRecover: [0x0000...0001]) │ └─ ← [Return] ├─ [191] Vault::withdraw() [staticcall] │ └─ ← [Revert] Unauthorized() └─ ← [Revert] Unauthorized()这个追踪树清楚地显示了执行顺序先vm.prank切换调用者然后Vault::withdraw回滚了最后整个测试回滚。-vvvv更进一步连通过的测试也会显示调用追踪。这在验证调用顺序、检查 Gas 消耗时特别有用forge test -vvvv输出会包含每个调用步骤的 Gas 消耗和返回值方便你定位“哪个操作特别贵”。验证错误和事件cheatcodes 的用法Foundry 最强大的功能之一是 cheatcodes——通过vm对象直接操控 EVM 状态。vm实例来自forge-std/Test.sol可以直接在测试里调用。验证 revert。用vm.expectRevert断言下一行调用必须回滚function test_RevertWhen_CallerIsNotOwner() public { vm.expectRevert(Unauthorized.selector); vm.prank(address(0)); upOnly.increment(); }vm.prank(address(0))让下一次调用以address(0)的身份执行这样就能模拟“非 owner 调用”的场景。验证事件。用vm.expectEmit断言特定事件会被发出event Transfer(address indexed from, address indexed to, uint256 amount); function test_ExpectEmit() public { vm.expectEmit(true, true, false, true); emit Transfer(address(this), address(1337), 1337); emitter.t(); }vm.expectEmit的四个布尔参数分别控制是否检查 topic1、topic2、topic3 和 data。这里检查了 topic1、topic2 和 data跳过了 topic3因为Transfer只有两个 indexed 参数。时间与身份操控prank 和 warp除了expectRevert和expectEmit还有几个高频 cheatcodes 值得掌握vm.warp修改block.timestamp测试时间相关的逻辑function test_DeadlineExpired() public { vm.warp(block.timestamp 8 days); vm.expectRevert(Deadline passed); contract.execute(); }vm.roll修改block.number测试区块高度相关的逻辑。vm.deal给地址分配 ETH 余额function test_Withdraw() public { address alice makeAddr(alice); vm.deal(alice, 100 ether); vm.prank(alice); vault.deposit{value: 10 ether}(); assertEq(vault.balanceOf(alice), 10 ether); }makeAddr是forge-std提供的工具函数根据字符串生成一个确定性地址。不变量测试让函数调用序列随机化不变量测试Invariant Testing是 Foundry 的高级功能。它验证的是“无论执行什么操作序列某个属性都应该始终成立”。contract VaultInvariantTest is Test { Vault vault; function setUp() public { vault new Vault(); targetContract(address(vault)); } function invariant_SolvencyCheck() public view { assertGe(address(vault).balance, vault.totalDeposits()); } }Forge 会随机生成vault上各种函数调用的序列每次调用后检查invariant_函数是否仍然成立。如果某次调用后不变量被破坏就找到了一个反例。运行不变量测试forge test --match-contract VaultInvariantTest这个测试的威力在于它会尝试大量“人想不到”的操作组合——比如先存后取、先取后存、连续存多次然后一次取空——只要不变量被破坏你就能得到一个可复现的失败案例。共享配置setUp 和继承如果多个测试合约需要相同的初始化逻辑可以写一个共享的基类abstract contract TestBase is Test { Counter counter; address alice; function setUp() public virtual { counter new Counter(); alice makeAddr(alice); vm.deal(alice, 100 ether); } } contract CounterTest is TestBase { function test_Increment() public { counter.increment(); assertEq(counter.number(), 1); } }Foundry 支持abstract合约作为共享基类setUp()标virtual让子类可以覆盖。foundry.toml 的关键配置foundry.toml是项目的配置文件几个和测试相关的常用项[profile.default] src src test test out out solc 0.8.24 [fuzz] runs 256 max_test_rejects 65536 [invariant] runs 256 depth 500 fail_on_revert falsefuzz.runs控制模糊测试的运行次数invariant.depth控制不变量测试中每个调用序列的最大深度。几个容易踩的坑测试函数必须是public或external。如果是internalForge 不会运行它。setUp()必须是public或internal。如果标了privateForge 会忽略它测试会因为没有初始化而失败。模糊测试的输入需要bound。如果测试逻辑对输入有范围要求用bound约束function testFuzz_Deposit(uint256 amount) public { amount bound(amount, 1, 100 ether); vm.prank(alice); vault.deposit{value: amount}(); assertEq(vault.balanceOf(alice), amount); }不用bound的话传入type(uint256).max可能导致溢出或 Gas 耗尽测试会以“失败”告终但失败原因是“输入不合法”而非“逻辑有 bug”。测试隔离的影响。Forge 默认启用调用隔离call isolation每个顶层外部调用在独立的交易上下文中执行。这意味着连续调用同一个合约第二次调用时账户和存储槽可能会被重新计为“冷”状态Gas 消耗和真实交易中连续调用的结果不同。如果测试依赖热状态延续可以用--no-isolate关闭隔离。小结Foundry 的测试链路可以总结为写 Solidity 测试文件.t.sol→forge test运行 →-vvv/-vvvv调试 → cheatcodes 操控状态 → 不变量测试验证全局约束。核心命令就几个forge test— 跑所有测试forge test --match-contract X— 只跑指定合约forge test -vvv— 失败时看调用栈forge test --fuzz-runs 128— 调整模糊测试次数和 Hardhat 相比Foundry 的优势不在于“功能更多”而在于测试和合约在同一个执行环境里。没有 JS 和 Solidity 之间的桥接没有异步调用的心智负担调试信息直接来自 EVM 的调用追踪。写过一遍之后很难再回到 Hardhat 的测试模式。