ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Foundry测试框架入门:用纯Solidity写合约测试与模糊测试

Foundry测试框架入门:用纯Solidity写合约测试与模糊测试 开门见山说一个观察如果你最近在 GitHub 上翻 Solidity 项目会发现一个非常明显的趋势——越来越多的仓库把测试目录从.js/.ts文件换成了.t.sol后缀的纯 Solidity 文件CI 里跑测试的命令也从npx hardhat test变成了forge test。这就是 Foundry 在 Web3 开发工具链里撕开的一条口子它用一个以 Rust 为核心的 Solidity 测试框架重新定义了“写合约测试”这件事。我自己的感受是从 Truffle 时代一路走过来第一次跑通 Foundry 测试的时候最大的震撼不是它快而是“原来测试可以直接写 Solidity 本身”。所以这篇入门教程我会从完全零基础的角度带你装好 Foundry、建一个项目、写一个能跑的真实测试用例然后再深入作弊码、模糊测试和调试技巧。不管你是刚入门 Solidity 的新手还是已经用 Hardhat 写过一段时间测试、想体验纯 Solidity 写测试的开发者这篇内容都适合你。读完你应该能独立上手 Foundry并且理解它背后的设计逻辑。1. Foundry 解决了什么问题凭什么值得学1.1 传统 JavaScript 测试栈的痛点慢、绕、割裂在 Foundry 流行之前Hardhat 几乎是写 Solidity 测试的默认选择。这套工作流是用 JavaScript 编写测试逻辑通过 ethers.js 或 web3.js 和合约交互跑测试时启动一个本地链把每个测试用例打包成交易广播出去。这套方案本身没毛病但在实际项目中我有三个非常真实的痛点。第一个痛点是慢。项目一复杂每次跑测试前都要对全量合约做编译再加上本地节点启动和交易等待的延迟一个不算太大的项目跑完整套测试往往要几分钟。第二个痛点是逻辑割裂。测试逻辑和合约逻辑是两种语言ABI 编解码、交易收据解析、事件监听这些繁琐工作在 JS 层写起来非常碍事排查问题时要不停地在两套栈之间来回切换。第三个痛点是 mock 成本高。想模拟某个外部合约返回特定数据得先写一个伪造合约部署上去再手动注入依赖整个过程繁琐而且容易出错。这些痛点在项目初期还能忍一旦合约数量增多写测试就变成了一件让人抗拒的事。1.2 Foundry 的答案Rust 底层 直接调用合约 全家桶Foundry 等于把这些痛点全部重做了一遍。它的测试用例不是 JS 脚本而是 Solidity 合约里的函数。因为测试本身写在 EVM 环境里合约内部状态、外部调用、事件捕获都变成了一等公民你不需要再做 AB I 序列化和反序列化直接在测试里调用合约方法然后写断言就行。编译和执行层由 Rust 实现实测下来一个中等规模项目的测试速度是秒级完成比传统方案快了一个量级。这套工具链其实不只是一个测试框架而是一个全家桶forge负责初始化项目、编译、运行测试、部署合约anvil是一个本地节点可以理解成更轻量、更快的 Ganachecast是命令行交互工具可以直接查链上状态、发交易chisel是一个 Solidity 交互式 REPL用来快速验证某段逻辑非常顺手。四者的关系我习惯用一句话概括forge 管开发和测试anvil 管本地链cast 管链上交互chisel 管随手验证。所以你会发现Foundry 本质上想覆盖的不只是“测试”这一环而是一整条开发工作流。对新手来说这些工具带来的学习成本确实比 Hardhat 高一点点但只要先把forge test跑明白后面自然就能享受到整套工具链的效率。2. 环境准备与项目初始化2.1 安装foundryup 一条命令搞定Foundry 的安装方式和其他区块链工具不太一样官方提供的是一个叫foundryup的版本管理工具。如果你在 macOS 或 Linux 上打开终端执行curl -L https://foundry.paradigm.xyz | bash这条命令会把foundryup安装到你的 home 目录下的.foundry/bin里。之后再执行foundryup它会自动从官方渠道下载当前版本的forge、cast、anvil、chisel四个二进制文件。安装完成后用forge --version验证一下即可。如果你用的是 Windows建议直接装一个 WSL在 Ubuntu 环境里操作最省心Windows 原生环境下会遇到不少原生依赖问题。之后每次想升级 Foundry也只需要重新跑一次foundryup。这一点在 Foundry 早期版本迭代非常快的阶段尤其重要有时候你遇到某个奇怪的报错跑完foundryup后莫名其妙就消失了——大概率是旧版本的 bug 已经被顺手修掉了。2.2 创建项目并理解生成目录装好之后初始化项目。新建一个目录进入后执行forge init simple-token-demo这个命令会创建一个最小可运行的项目骨架核心是src、test、script三个目录和foundry.toml配置文件。src放合约源码test放 Solidity 测试文件script是部署脚本的位置foundry.toml则是项目级配置。默认生成的src/Counter.sol和test/Counter.t.sol是一个示例后面我们会直接替换掉。打开foundry.toml你会看到这样一段[profile.default] src src out out libs [lib] solc_version 0.8.23 optimizer true optimizer_runs 200solc_version指定编译版本注意要和合约里的 pragma 匹配。optimizer和optimizer_runs影响合约体积和 gas实测大多数项目开启优化之后部署成本会有可感知的降低。libs是依赖库的目录默认指向lib。初始化时通常还会生成一个lib目录里面放着依赖库集合。Foundry 社区最常用的依赖就是这个教程里几乎离不开的forge-std它是官方维护的标准库Test合约、作弊码类型、console日志都从里面引用。安装命令是forge install foundry-rs/forge-std2.3 remappings新手最容易卡住的配置项remappings可能是很多新手第一次接触 Foundry 时觉得莫名其妙的地方。它的作用是把源码里的 import 地址映射到lib目录里的实际位置。项目里通常会有一个remappings.txt文件内容类似forge-std/lib/forge-std/src/有了它测试文件里写import {Test} from forge-std/Test.sol;编译器才知道去哪个目录找文件。很多情况下你打开别人仓库的代码感觉 import 路径很奇怪其实答案全都在这个文件里。如果你发现某个依赖库的路径变了比如换了一个 lib 版本重新跑一次forge install或者手动更新remappings.txt通常能解决。这里有一个我在实际项目中踩过的坑公司内网环境下初始化项目时拉取forge-std的git submodule经常超时。初始化本身会失败但项目目录已经生成了这时候不需要重新初始化只要手动补一条forge install foundry-rs/forge-std就行。记住这个可以省下很多折腾时间。3. 从零写第一个 Solidity 测试3.1 一个能被测试的简单合约SimpleToken为了演示我准备了一个非常简化的 Token 合约。选它作为例子是因为一个 Token 合约天然覆盖了状态存储、事件、revert 逻辑这些测试框架最常见的使用点后面所有测试技巧都能在它身上用上// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; contract SimpleToken { string public name SimpleToken; string public symbol STK; uint8 public decimals 18; uint256 public totalSupply; mapping(address uint256) public balanceOf; event Transfer(address indexed from, address indexed to, uint256 value); constructor(uint256 _initialSupply) { totalSupply _initialSupply; balanceOf[msg.sender] _initialSupply; } function transfer(address to, uint256 amount) external returns (bool) { require(balanceOf[msg.sender] amount, insufficient balance); balanceOf[msg.sender] - amount; balanceOf[to] amount; emit Transfer(msg.sender, to, amount); return true; } }这个合约的逻辑非常简单构造函数给部署者发代币transfer校验余额后转账并抛事件。它没有处理to address(0)这种情况也没有对msg.sender to做优化但作为一个教学例子完全够用你甚至可以拿它来练习“发现边界问题”。把它放在src/SimpleToken.sol里然后把默认的Counter.sol删掉。3.2 测试骨架与 setUp 的执行时机接下来在test目录下创建一个测试文件命名为SimpleToken.t.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Test} from forge-std/Test.sol; import {SimpleToken} from ../src/SimpleToken.sol; contract SimpleTokenTest is Test { SimpleToken internal token; address internal alice address(0xA11CE); address internal bob address(0xB0B); function setUp() public { token new SimpleToken(1_000_000 ether); token.transfer(alice, 100 ether); } function testInitialSupply() public view { assertEq(token.totalSupply(), 1_000_000 ether); } function testTransfer() public { uint256 aliceBefore token.balanceOf(alice); uint256 bobBefore token.balanceOf(bob); vm.prank(alice); bool ok token.transfer(bob, 10 ether); assertTrue(ok); assertEq(token.balanceOf(alice), aliceBefore - 10 ether); assertEq(token.balanceOf(bob), bobBefore 10 ether); } }这里最需要理解的概念是setUp。它会在每个测试函数执行前都运行一次这是 Foundry 保证测试隔离的核心机制。也就是说你有几个测试函数setUp就会被重跑几次每次都产生一个全新的SimpleToken实例。很多初学者会把初始化状态写在构造函数里或者试图用某个 beforeAll 的钩子去共享状态这在 Foundry 里行不通也不建议这样做因为测试与测试之间的状态隔离是防止“假阳性”的基础。还有一个新手几乎都会踩的认知误区测试合约里的msg.sender默认是测试合约地址本身而不是某个本地钱包地址。在setUp里执行new SimpleToken(...)代币全部分配给了测试合约再执行token.transfer(alice, 100 ether)其实是从测试合约地址转给 alice。后面想模拟用户调用就得靠vm.prank这样的作弊码来完成。3.3 运行 forge test 并读懂输出在项目根目录执行forge test如果一切正常你会看到类似下面的输出[PASS] testInitialSupply() (gas: 12241) [PASS] testTransfer() (gas: 36120) Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 8.42ms注意这里有两个信息测试是否通过以及每个测试消耗的 gas。gas 数据在合约优化阶段非常有用后面我会提到--gas-report的用法。如果测试失败可以把-v的数量往上加forge test -vv会显示事件日志forge test -vvvv会带上作弊码调用堆栈和更详细的执行信息。排查问题的时候我习惯直接-vvvv信息量最大虽然输出也会很吵。跑完第一个测试后你会感受到一个核心设计理念每个 test 函数在概念上就是一笔独立的链上交易它直接调用合约方法、直接读取合约状态、直接断言返回值整个过程不需要经过任何 RPC 服务器是真正的即时执行。4. 写测试时真正高频使用的功能4.1 高频作弊码与适用场景作弊码cheatcode是 Foundry 测试的灵魂它们以vm前缀暴露vm是Test基类提供的一个接口实例。下面这六个是我日常项目里最常用的给你列成了一张表作弊码作用典型场景vm.prank(address)下一次调用伪装成某地址模拟普通用户调用合约vm.startPrank(address)后续所有调用都伪装成该地址多步操作中简化代码vm.deal(address, uint256)给指定地址充 ETH测试需要支付 value 或 gasvm.expectRevert(bytes)断言下一次调用 revert验证权限、余额校验等vm.warp(uint256)设置区块时间戳测试锁仓、拍卖时间逻辑vm.roll(uint256)设置区块高度测试基于区块号的逻辑prank和startPrank的区别很好理解prank只对下一次调用生效用完即止startPrank会一直伪装直到你调用vm.stopPrank()。写多步操作时用startPrank更省事但一定要记得关闭否则会影响后面的测试逻辑。expectRevert是替代testFail的最佳实践。很多旧教程会教你在函数名上加上testFail前缀来表示期望失败比如function testFailTransferWithoutBalance() public { vm.prank(bob); token.transfer(alice, 1); }我的建议是新项目别再用这种写法。因为testFail只能告诉你“失败了”却拿不到失败的具体还原原因如果合约因为算术溢出而不是余额不足 revert测试照样通过你的防御体系会出现盲区。更推荐这样的写法function testTransferRevertsWhenInsufficientBalance() public { vm.prank(bob); vm.expectRevert(insufficient balance); token.transfer(alice, 1); }这里有一个容易忽略的顺序问题expectRevert必须紧贴着目标调用中间如果插入其他外部调用断言可能失效。我自己因为这个踩过好几次坑后来凡是遇到expectRevert不生效第一反应就是检查中间是不是多了一次外部调用。vm.deal则是给测试地址充 ETH 的快捷方式。很多合约函数会接收msg.value如果你不给地址余额调用会直接因为余额不足 revert调试半天发现是测试环境没配钱这事在 DeFi 合约测试里非常常见。4.2 模糊测试让 fuzzer 帮你找输入边界写普通测试时你给的输入是固定的写模糊测试时输入的随机性交给 fuzzer。Foundry 的模糊测试实现得非常自然只要测试函数带有参数它就会自动把参数当成随机输入每次运行都会生成一组新的数据。默认runs是 256也就是会跑 256 组随机输入。举个例子我想验证transfer在任何合法输入下都不会破坏代币总量这个不变量。可以写一个模糊测试function testFuzz_TransferPreservesInvariant(uint256 amountSeed, address to) public { address from alice; uint256 amount bound(amountSeed, 0, token.balanceOf(from)); vm.assume(to ! from); uint256 fromBefore token.balanceOf(from); uint256 toBefore token.balanceOf(to); vm.prank(from); token.transfer(to, amount); assertEq(token.balanceOf(from), fromBefore - amount); assertEq(token.balanceOf(to), toBefore amount); }这里有两个关键函数bound和vm.assume。bound是 forge-std 提供的工具函数它能把一个任意随机数安全地映射到一个区间内保证amount不超过 alice 的余额vm.assume则用来过滤掉没有意义的输入比如这里要求to ! from否则交易双方是同一个地址时这个断言等式虽然不会错但也没有任何测试价值。关于 fuzzer 的采样策略你不需要太深入也能用好它默认的 fuzzer 不是纯等概率随机撒点它会在边界值附近做更多采样0、type(uint256).max这类极端值非常容易被测试到。所以模糊测试特别适合用来抓算术溢出、除以零、边界判断错误这类问题。如果某一组随机输入让测试失败了Foundry 会直接打印出那组输入的具体值你可以把它当作一个可复现的 bug 报告。4.3 事件断言与 Gas 报告除了状态和返回值事件也是 Solidity 合约的重要输出。Foundry 里可以用vm.expectEmit来断言某个事件是否被正确触发写法如下function testTransferEmitsEvent() public { uint256 amount 10 ether; vm.expectEmit(true, true, true, true); emit SimpleToken.Transfer(alice, bob, amount); vm.prank(alice); token.transfer(bob, amount); }expectEmit的四个布尔参数分别控制事件的四个 topic 校验开关。前三个对应 indexed 参数第四个对应事件的数据部分。如果你不关心某些字段把对应位置设为false即可。这里也藏着一个顺序要求先调用expectEmit再写一遍你想看到的事件最后才是真正触发事件的合约调用。很多第一次用的人容易把顺序写反然后发现断言怎么都不生效。另外我更推荐你在日常开发中养成跑forge test --gas-report的习惯。它会在测试结束之后生成一份完整的 gas 报告显示每个函数的调用消耗和平均成本。这个功能对合约优化特别香因为你可以快速对比两个版本的 gas 差异不用部署到节点上去数。配合forge snapshot还能在 CI 里做 gas 回归测试合约性能一有波动就能立刻发现问题这在做 DeFi 项目时是很重要的防线。5. 实战中的调试经验与典型坑位5.1 别只靠 assertconsole.log 和 chisel 配合使用写 Foundry 测试时的调试体验比传统 JS 栈舒服不少。你可以在测试里直接打日志不需要额外启动节点import {console} from forge-std/console2.sol; function testDebugTransfer() public { uint256 aliceBefore token.balanceOf(alice); console.log(alice before:, aliceBefore); vm.prank(alice); token.transfer(bob, 1 ether); console.log(alice after:, token.balanceOf(alice)); }很多从 Hardhat 过来的朋友一开始不知道这个遇到问题只会用 assert 把状态打出来然后对着屏幕干分析。console.log在 Foundry 里也支持格式化输出结构体和数组也能打印强烈建议早点学会。如果你的问题不是出在整个测试流程里而是单纯想看看某个逻辑片段的结果我更推荐直接用chisel。进入chisel后你可以像在控制台里写 Solidity 一样输入表达式例如uint256 x 1 ether; x / 1e18它会立刻返回结果。这个工具对验证计算逻辑、快速试一下某个 API 的返回值非常方便尤其是你还没想好要不要把它写进正式测试的时候。我在写时间相关的合约时很喜欢先用chisel跑一遍block.timestamp 30 days这种表达式确认数据边界没问题再写进测试。5.2 我在真实项目里踩过/见过的坑下面这几个坑是实际项目里高频出现的有些是我自己踩的有些是帮别人 review 代码时看到的每一个都值得记下来。第一个坑就是上面提到的testFail。它能掩盖问题因为失败原因不确定你根本不知道合约是因为预期的require失败还是因为某个你没预料到的 bug 而 revert。用vm.expectRevert替代它可以让测试更精确。第二个坑是测试隔离没做到位。Foundry 默认每个测试函数独立执行setUp但这个隔离不是绝对的。比如你调了vm.startPrank之后忘了vm.stopPrank后续测试里msg.sender可能仍然是被伪装的地址一个测试通过、另一个测试莫名失败的情况往往就是这种泄漏导致的。遇到奇怪的现象先检查有没有未关闭的 prank。第三个坑是依赖和 remappings 配置。如果你 clone 一个别人的项目里面 import 的路径在本地找不到第一反应应该是看remappings.txt或者重新跑一次forge install。我在 macOS 上遇到过 git submodule 路径大小写敏感导致的怪问题最后就是重新安装依赖解决的。第四个坑是 fuzz runs 设置不合理。默认的 256 次 runs 在项目体量变大后会拖慢 CI但也不要为了快把它调到个位数否则模糊测试几乎没有意义。相反在关键合约上可以把 runs 调到 1000 以上提升随机覆盖概率。找到适合项目的 fuzz runs 需要一点现场测试的耐心。第五个坑是关于分叉测试的forge test --fork-url rpc可以让测试直接从主网状态开始但不要因此把分叉测试当作正式环境。状态变更不会写回主网你只是得到一个接近主网的模拟环境很多 DeFi 项目用它来做外部协议的集成测试但它和真正的线上部署是两码事。5.3 后续可以扩展的方向把这套基础玩熟之后Foundry 还有几块非常值得深入的功能。第一个是 invariant testing也就是不变量测试它通过持续随机调用合约的多个函数检查你设定的不变量是否在任何调用序列下都被破坏用来找闪电贷攻击这类复合漏洞特别有效。第二个是 fork testing直接从主网加载状态下放测试很多 DeFi 协议会用它做与外部协议的集成测试。第三个是 CI 集成把forge test、forge fmt --check、forge build --sizes写进 GitHub Actions可以搭出一套相当完整的合约质量流水线。但别一上来就铺开。我见过不少朋友第一天装好 Foundry 就在研究 invariant 和分叉测试结果基础断言都没写明白。先把普通测试写顺、把测试隔离机制吃透再逐步上高级工具这是一个比较稳妥的路径。如果让我给一个最实在的建议那就是别再只在 Remix 里点点算了用 Foundry 把第一份测试跑起来。我从 Hardhat 迁移到 Foundry 的头一个月最大的变化不是测试速度变快了而是我开始把测试本身当成一等公民——遇到问题第一反应是写一个可复现的测试用例而不是去链上反复试错。你会发现很多合约 bug 其实在测试阶段就完全可以暴露出来根本不用等到审计或者上线之后才被挖出来。这篇基础入门就先写到这里。后面我会继续整理 invariant 测试和 fork 测试的实战案例。如果你按上面的例子跑的时候遇到具体报错记得带上forge --version的输出和完整错误信息这种信息量足够的问题一般都能很快定位。
返回列表