Foundry Web3 测试框架入门:从 0 开始写 Solidity 测试

在 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 测试体系的核心。

相关推荐
大勇前进1 小时前
Java 线程创建的 4 种方式,优缺点对比,开发推荐写法
后端
万少1 小时前
等不到 Apple 的折叠 iPhone,我用 DeepV4.1Flash + workBuddy 一句话自己造了一台
前端·javascript·后端
卷无止境1 小时前
AI Agent编程中,重构节奏与安全检查的门道
后端·python
卷无止境1 小时前
AI编程时代,代码复杂性正在悄悄失控
后端·python
EatFan1 小时前
【实战经验】uni-app使用 SSE 踩坑,EventSource不支持怎么办?
android·后端·ios·uni-app
考虑考虑2 小时前
elasticSearch中的element_type
运维·后端·elasticsearch
掘金者阿豪2 小时前
飞牛部署 Wallos:把长期订阅和周期支出放进自己的 NAS 管理
后端
ly76892 小时前
Spring 异步编程的隐藏风险:@Async 线程池耗尽与异常处理详解
java·后端·spring·异常处理·线程池·任务拒绝
用户8181870627462 小时前
第23章 热点Key问题排查与解决:大促场景经典坑
java·后端