news 2026/9/15 17:03:42

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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是一个本地节点,可以理解成更轻量、更快的 Ganache;cast是命令行交互工具,可以直接查链上状态、发交易;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

它会自动从官方渠道下载当前版本的forgecastanvilchisel四个二进制文件。安装完成后用forge --version验证一下即可。如果你用的是 Windows,建议直接装一个 WSL,在 Ubuntu 环境里操作最省心,Windows 原生环境下会遇到不少原生依赖问题。

之后每次想升级 Foundry,也只需要重新跑一次foundryup。这一点在 Foundry 早期版本迭代非常快的阶段尤其重要,有时候你遇到某个奇怪的报错,跑完foundryup后莫名其妙就消失了——大概率是旧版本的 bug 已经被顺手修掉了。

2.2 创建项目并理解生成目录

装好之后初始化项目。新建一个目录,进入后执行:

forge init simple-token-demo

这个命令会创建一个最小可运行的项目骨架,核心是srctestscript三个目录和foundry.toml配置文件。src放合约源码,test放 Solidity 测试文件,script是部署脚本的位置,foundry.toml则是项目级配置。默认生成的src/Counter.soltest/Counter.t.sol是一个示例,后面我们会直接替换掉。

打开foundry.toml,你会看到这样一段:

[profile.default] src = "src" out = "out" libs = ["lib"] solc_version = "0.8.23" optimizer = true optimizer_runs = 200

solc_version指定编译版本,注意要和合约里的 pragma 匹配。optimizeroptimizer_runs影响合约体积和 gas,实测大多数项目开启优化之后部署成本会有可感知的降低。libs是依赖库的目录,默认指向lib

初始化时通常还会生成一个lib目录,里面放着依赖库集合。Foundry 社区最常用的依赖就是这个教程里几乎离不开的forge-std,它是官方维护的标准库,Test合约、作弊码类型、console日志都从里面引用。安装命令是:

forge install foundry-rs/forge-std

2.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-stdgit 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前缀暴露,vmTest基类提供的一个接口实例。下面这六个是我日常项目里最常用的,给你列成了一张表:

作弊码作用典型场景
vm.prank(address)下一次调用伪装成某地址模拟普通用户调用合约
vm.startPrank(address)后续所有调用都伪装成该地址多步操作中简化代码
vm.deal(address, uint256)给指定地址充 ETH测试需要支付 value 或 gas
vm.expectRevert(bytes)断言下一次调用 revert验证权限、余额校验等
vm.warp(uint256)设置区块时间戳测试锁仓、拍卖时间逻辑
vm.roll(uint256)设置区块高度测试基于区块号的逻辑

prankstartPrank的区别很好理解: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); }

这里有两个关键函数:boundvm.assumebound是 forge-std 提供的工具函数,它能把一个任意随机数安全地映射到一个区间内,保证amount不超过 alice 的余额;vm.assume则用来过滤掉没有意义的输入,比如这里要求to != from,否则交易双方是同一个地址时,这个断言等式虽然不会错,但也没有任何测试价值。

关于 fuzzer 的采样策略,你不需要太深入也能用好它:默认的 fuzzer 不是纯等概率随机撒点,它会在边界值附近做更多采样,0type(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 别只靠 assert:console.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 testforge fmt --checkforge build --sizes写进 GitHub Actions,可以搭出一套相当完整的合约质量流水线。

但别一上来就铺开。我见过不少朋友第一天装好 Foundry 就在研究 invariant 和分叉测试,结果基础断言都没写明白。先把普通测试写顺、把测试隔离机制吃透,再逐步上高级工具,这是一个比较稳妥的路径。

如果让我给一个最实在的建议,那就是别再只在 Remix 里点点算了,用 Foundry 把第一份测试跑起来。我从 Hardhat 迁移到 Foundry 的头一个月,最大的变化不是测试速度变快了,而是我开始把测试本身当成一等公民——遇到问题第一反应是写一个可复现的测试用例,而不是去链上反复试错。你会发现,很多合约 bug 其实在测试阶段就完全可以暴露出来,根本不用等到审计或者上线之后才被挖出来。

这篇基础入门就先写到这里。后面我会继续整理 invariant 测试和 fork 测试的实战案例。如果你按上面的例子跑的时候遇到具体报错,记得带上forge --version的输出和完整错误信息,这种信息量足够的问题一般都能很快定位。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 17:03:06

特斯拉TBONE漏洞深度解析:ConnMan栈溢出与车载系统安全实践

1. 项目概述&#xff1a;这不是一次“远程开车”&#xff0c;而是一次对车载通信协议栈的精准外科手术“特斯拉TBONE漏洞分析”——看到这个标题&#xff0c;很多人第一反应是“又一个能黑进特斯拉的高危漏洞&#xff1f;”但实际情况远比这复杂也更值得深挖。TBONE不是特斯拉官…

作者头像 李华
网站建设 2026/9/15 17:01:03

蓝牙Mesh协议入门:从泛洪、配网到模型,一篇讲透组网原理

干物联网这行&#xff0c;蓝牙Mesh这几个字我听得耳朵起茧。但每次跟人聊到蓝牙Mesh协议&#xff0c;总发现不少人被“泛洪”“代理”“配网”“模型”这些词劝退&#xff0c;要么照着SDK跑通了demo却不知道底层在干什么&#xff0c;要么被老板问一句“这跟Wi-Fi Mesh有什么区别…

作者头像 李华
网站建设 2026/9/15 17:00:50

Jane Street冠军方案解析:MCC指标、匿名特征与模型融合实战

Jane Street Market Prediction 这个比赛&#xff0c;在 Kaggle 圈子里一直有个说法&#xff1a;这不是一场“比谁模型更大”的比赛&#xff0c;而是一场“比谁更懂数据在说什么”的比赛。组织方是纽约做市商巨头 Jane Street&#xff0c;数据来自真实交易信号&#xff0c;所有…

作者头像 李华
网站建设 2026/9/15 16:59:44

KMP网络层实战:Android端OkHttp跨平台统一请求方案

1. 项目概述&#xff1a;这不是又一个 Retrofit 封装&#xff0c;而是 Android 多平台网络层的重新定义“AndroidKMP之网络请求”——这七个字背后藏着的&#xff0c;不是简单的“在 Kotlin Multiplatform 中写个 HTTP 客户端”&#xff0c;而是一场从架构根部开始的重构。我带…

作者头像 李华
网站建设 2026/9/15 16:58:53

车联网安全全景:从攻击面到防护体系与合规落地

做车联网安全这几年&#xff0c;最常被问的一句话是&#xff1a;“车联网安全到底难在哪&#xff1f;”每次我都想反问一句&#xff1a;你平时手机上那些App的漏洞&#xff0c;可能只是丢点数据。但车上随便一个漏洞&#xff0c;轻则被远程锁门、恶意刹车&#xff0c;重则开着一…

作者头像 李华