在 Web3 / Solidity 开发中,智能合约测试非常重要。
传统 JavaScript 项目通常使用 Jest、Vitest 等测试框架,而 Solidity 生态中比较流行的测试工具之一就是 Foundry。
Foundry 是一个基于 Rust 的 Ethereum 开发工具链,其中最核心的测试工具是:
- Forge:编译、测试、部署 Solidity 合约
- Anvil:本地 Ethereum 节点
- Cast:命令行与区块链交互
- Chisel:Solidity 交互式 REPL
其中,本文重点介绍 Forge 测试。
一、安装 Foundry
Foundry 官方提供 foundryup 安装方式:
arduino
curl -L https://foundry.paradigm.xyz | bash
然后执行:
foundryup
安装完成后,可以检查:
css
forge --version
anvil --version
cast --version
如果能够正常输出版本号,说明安装成功。
Foundry 官方目前推荐的核心工具就是 Forge、Anvil、Cast 和 Chisel。(foundry - Ethereum Development Framework)
二、创建 Foundry 项目
使用:
csharp
forge init hello-foundry
进入项目:
bash
cd hello-foundry
一个典型的 Foundry 项目结构:
bash
hello-foundry/
├── src/
│ └── Counter.sol
├── test/
│ └── Counter.t.sol
├── script/
│ └── Counter.s.sol
├── lib/
│ └── forge-std/
├── foundry.toml
└── README.md
其中:
css
src/
存放 Solidity 合约。
bash
test/
存放测试代码。
script/
存放部署脚本。
vbnet
lib/
存放 Solidity 依赖。
而:
foundry.toml
就是 Foundry 项目的配置文件。
三、第一个 Solidity 测试
假设我们有一个简单的计数器:
typescript
// src/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
接下来写测试:
scss
// test/Counter.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
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_SetNumber() public {
counter.setNumber(100);
assertEq(counter.number(), 100);
}
function test_Increment() public {
counter.setNumber(100);
counter.increment();
assertEq(counter.number(), 101);
}
}
运行:
bash
forge test
如果测试成功,可以看到类似:
scss
[PASS] test_SetNumber()
[PASS] test_Increment()
2 passed
Forge 会自动识别测试函数。
通常测试文件使用:
.t.sol
结尾,而测试函数使用:
bash
test
作为前缀。(foundry - Ethereum Development Framework)
四、setUp 是干什么的?
测试中经常会看到:
csharp
function setUp() public {
counter = new Counter();
}
它类似于 JavaScript 测试中的:
scss
beforeEach()
也就是说:
scss
setUp()
↓
test_SetNumber()
setUp()
↓
test_Increment()
setUp()
↓
其他 test
每一个测试执行之前都会重新执行 setUp()。
这样可以保证不同测试之间相互隔离。
五、assert 断言
Foundry 最常见的测试方式就是断言。
例如:
perl
assertEq(counter.number(), 100);
表示:
实际值 == 100
常见断言包括:
scss
assertEq(a, b);
assertTrue(condition);
assertFalse(condition);
assertGt(a, b);
assertLt(a, b);
assertGe(a, b);
assertLe(a, b);
例如:
scss
function test_Number() public {
counter.setNumber(100);
assertEq(counter.number(), 100);
assertGt(counter.number(), 50);
assertLt(counter.number(), 200);
}
六、测试 Revert
智能合约测试不仅要测试「正常情况」,还要测试「错误情况」。
例如:
ini
contract Bank {
uint256 public balance;
function withdraw(uint256 amount) public {
require(amount <= balance, "insufficient balance");
balance -= amount;
}
}
我们希望测试:
余额只有 100
↓
取出 200
↓
应该 Revert
Foundry 可以使用:
ini
vm.expectRevert();
例如:
scss
function test_RevertWhen_WithdrawTooMuch() public {
Bank bank = new Bank();
vm.expectRevert();
bank.withdraw(200);
}
expectRevert 是 Foundry Cheatcode 的典型用法。(foundry - Ethereum Development Framework)
七、什么是 Cheatcode?
这是 Foundry 非常重要的一个概念。
现实中的区块链环境很多东西是不能随便修改的:
scss
block.timestamp
msg.sender
block.number
ETH balance
storage
但是测试的时候,我们经常需要模拟各种环境。
例如:
模拟不同用户
模拟时间变化
模拟区块变化
给账户充值 ETH
修改 Storage
模拟交易失败
Foundry 提供了一套特殊能力:
Cheatcodes
最常用的入口就是:
vm
例如:
ini
vm.prank(alice);
表示下一次调用模拟成:
alice
发送。
八、模拟不同用户
假设:
ini
address alice = makeAddr("alice");
address bob = makeAddr("bob");
然后:
ini
vm.prank(alice);
counter.increment();
此时合约看到的:
msg.sender
就是:
alice
而不是测试合约本身。
如果需要连续模拟 Alice:
ini
vm.startPrank(alice);
counter.increment();
counter.increment();
vm.stopPrank();
这在测试:
sql
Owner
Admin
User
Attacker
等不同角色时非常有用。
九、模拟时间
智能合约中经常存在:
scss
block.timestamp
例如:
scss
require(block.timestamp >= startTime);
测试时不可能真的等待几个小时。
可以使用:
ini
vm.warp(block.timestamp + 1 days);
模拟时间向后移动一天。
例如:
scss
function test_AfterOneDay() public {
uint256 start = block.timestamp;
vm.warp(start + 1 days);
assertEq(block.timestamp, start + 1 days);
}
这也是 Cheatcode 非常重要的价值:
不需要真的等待区块链环境发生变化,而是直接模拟变化。
十、Fuzz Testing:Foundry 很值得学习的功能
普通测试:
perl
function test_SetNumber() public {
counter.setNumber(100);
assertEq(counter.number(), 100);
}
只测试:
100
但是我们实际上可能希望测试:
yaml
0
1
10
100
1000
1000000
......
这时候可以使用 Fuzz Test。
例如:
perl
function testFuzz_SetNumber(uint256 number) public {
counter.setNumber(number);
assertEq(counter.number(), number);
}
我们没有指定具体的:
ini
number = 100
而是把它交给 Foundry 自动生成。
运行:
bash
forge test
Foundry 会自动执行大量不同输入。
这类测试特别适合发现:
边界条件
整数溢出
特殊输入
极端参数
状态异常
十一、Fuzz 测试为什么重要?
假设有这样一个函数:
ini
function withdraw(uint256 amount) public {
balance -= amount;
}
你可能测试:
100 - 10
100 - 20
100 - 50
都没有问题。
但真正的问题可能是:
100 - 101
甚至:
lua
100 - type(uint256).max
普通测试很难覆盖所有情况。
Fuzz Testing 的思想就是:
markdown
人工设计少量测试
↓
Foundry 自动生成大量输入
↓
寻找失败案例
↓
记录失败输入
↓
方便复现 Bug
Foundry 还会持久化 Fuzz 失败案例,用于后续测试运行时重新检查,避免回归。(foundry - Ethereum Development Framework)
十二、测试事件 Event
假设合约:
scss
event NumberChanged(uint256 number);
function setNumber(uint256 newNumber) public {
number = newNumber;
emit NumberChanged(newNumber);
}
测试事件:
csharp
function test_SetNumberEmitEvent() public {
vm.expectEmit(true, true, true, true);
emit NumberChanged(100);
counter.setNumber(100);
}
这里的核心思想是:
scss
expectEmit()
↓
告诉 Foundry:接下来应该产生一个事件
↓
执行合约
↓
检查 Event
所以测试智能合约时,不应该只测试:
状态有没有改变
还应该测试:
事件有没有正确触发
十三、Anvil:本地 Ethereum 节点
Foundry 不只是一个测试框架。
它还提供:
anvil
启动本地 Ethereum 节点。
执行:
anvil
会启动一个本地 RPC:
arduino
http://127.0.0.1:8545
并提供多个预置账户和测试 ETH。
这意味着我们可以搭建:
前端
↓
Wallet
↓
Anvil
↓
Solidity Contract
完整模拟一个 Web3 开发环境。
官方文档也将 Anvil 定义为兼容 Ethereum JSON-RPC 的本地开发节点。(foundry - Ethereum Development Framework)
十四、Fork Testing:直接 Fork 主网
这是 Foundry 非常实用的能力。
例如:
css
anvil --fork-url <RPC_URL>
可以把 Ethereum 主网状态 Fork 到本地。
这样测试环境中就可以看到真实链上的:
Token
Pool
NFT
DEX
账户余额
合约状态
例如你要测试:
Uniswap
Aave
某个 DeFi 协议
不一定需要自己重新部署整个生态。
可以直接:
markdown
Ethereum Mainnet
↓
Fork
↓
Local Anvil
↓
Forge Test
Foundry 官方也把 Fork Testing 作为其重要能力之一,用于基于真实链状态测试合约。(foundry - Ethereum Development Framework)
十五、测试失败了怎么办?
Forge 提供不同级别的日志。
最简单:
bash
forge test
查看更详细的日志:
bash
forge test -vv
查看失败测试的调用栈:
bash
forge test -vvv
查看更加完整的执行 Trace:
bash
forge test -vvvv
所以平时可以记住:
bash
forge test
forge test -vv
forge test -vvv
forge test -vvvv
出现合约测试失败时,-vvvv 往往非常有帮助。(foundry - Ethereum Development Framework)
十六、Debug Test
Foundry 还提供交互式 Debugger。
例如:
bash
forge test --debug --match-test "test_Increment"
可以直接 Debug 某一个测试。
这对于排查复杂的:
调用链
Storage
DelegateCall
Reentrancy
DeFi
问题非常有帮助。(foundry - Ethereum Development Framework)
十七、常用命令总结
把 Foundry 最常用的命令整理一下:
bash
# 创建项目
forge init my-project
# 编译
forge build
# 测试
forge test
# 查看详细日志
forge test -vvv
# 查看完整 Trace
forge test -vvvv
# 指定测试
forge test --match-test test_Increment
# 按合约过滤
forge test --match-contract CounterTest
# Watch 模式
forge test --watch
# 启动本地节点
anvil
# 与链交互
cast ...
Forge 负责开发和测试,Anvil 负责本地链,Cast 负责命令行交互。(foundry - Ethereum Development Framework)
十八、Foundry 测试的核心思维
学习 Foundry 时,不建议一开始就记大量 Cheatcode。
可以先掌握下面这条路线:
bash
Solidity
↓
Forge
↓
setUp
↓
test
↓
assert
↓
expectRevert
↓
Cheatcode
↓
Fuzz Testing
↓
Anvil
↓
Fork Testing
真正需要建立的是:
智能合约测试,本质上是在不同区块链状态、不同用户、不同输入下验证合约是否满足预期。
例如:
markdown
正常用户
↓
正常输入
↓
正常结果
攻击者
↓
异常输入
↓
应该 Revert
时间变化
↓
合约状态
↓
结果是否正确
大量随机输入
↓
Fuzz
↓
寻找边界 Bug
真实主网状态
↓
Fork
↓
验证复杂协议交互
这比单纯记住:
scss
vm.prank()
vm.warp()
vm.expectRevert()
更加重要。
十九、最后总结
如果你之前主要接触的是前端、Go 或传统后端开发,可以把 Foundry 理解成:
markdown
Jest / Vitest
↓
JavaScript
Foundry / Forge
↓
Solidity
但 Foundry 不仅仅是测试框架,它实际上是一整套 Ethereum 开发工具链:
markdown
Foundry
│
┌───────────┼───────────┐
↓ ↓ ↓
Forge Anvil Cast
│ │ │
测试 本地链 链交互
│
↓
Cheatcode
│
↓
Fuzz Testing
│
↓
Fork Testing
如果是学习 Solidity / Web3 开发,我建议优先把 Forge + Cheatcode + Fuzz Testing + Anvil + Fork Testing 这几个部分掌握。
它们基本构成了 Foundry 测试体系的核心。