Foundry 智能合约测试流程讲解指南
本文系统阐述如何编写高质量、高覆盖率、高鲁棒性的 Solidity 测试。适用于使用 Forge 进行智能合约开发的团队与个人开发者。
作者:RainWeb3
更新时间:2025年11月8日
目标读者:智能合约开发者、区块链工程师、DevOps 工程师
- 新Github-国外访问:rainweb4.github.io/
- 新Github-国内访问:github.com/Rainweb4/ra...
目录(可点击跳转)
-
[Foundry 支持的六大主流测试方式详解](#Foundry 支持的六大主流测试方式详解 "#1-foundry-%E6%94%AF%E6%8C%81%E7%9A%84%E5%85%AD%E5%A4%A7%E4%B8%BB%E6%B5%81%E6%B5%8B%E8%AF%95%E6%96%B9%E5%BC%8F%E8%AF%A6%E8%A7%A3")
1.1 [基础单元测试(Unit Test)](#基础单元测试(Unit Test) "#11-%E5%9F%BA%E7%A1%80%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95unit-test")
1.2 [模糊测试(Fuzzing Test)](#模糊测试(Fuzzing Test) "#12-%E6%A8%A1%E7%B3%8A%E6%B5%8B%E8%AF%95fuzzing-test")
1.3 [不变量测试(Invariant Test / Stateful Fuzzing)](#不变量测试(Invariant Test / Stateful Fuzzing) "#13-%E4%B8%8D%E5%8F%98%E9%87%8F%E6%B5%8B%E8%AF%95invariant-test--stateful-fuzzing")
1.4 [分叉测试(Forking Test)](#分叉测试(Forking Test) "#14-%E5%88%86%E5%8F%89%E6%B5%8B%E8%AF%95forking-test")
1.5 [参数化测试(Parameterized Test)](#参数化测试(Parameterized Test) "#15-%E5%8F%82%E6%95%B0%E5%8C%96%E6%B5%8B%E8%AF%95parameterized-test")
1.6 [快照测试(Snapshot Test)](#快照测试(Snapshot Test) "#16-%E5%BF%AB%E7%85%A7%E6%B5%8B%E8%AF%95snapshot-test")
-
2.1 命名约定的作用
2.2 [组合测试示例(如 testForkFuzz_RevertWhen_)](#组合测试示例(如 testForkFuzz_RevertWhen_) "#22-%E7%BB%84%E5%90%88%E6%B5%8B%E8%AF%95%E7%A4%BA%E4%BE%8B%E5%A6%82-testforkfuzz_revertwhen_")
-
3.1 使用描述性断言消息
3.2 事件测试的最佳实践
-
[分叉测试 vs Mock:生产环境中的权衡与选择](#分叉测试 vs Mock:生产环境中的权衡与选择 "#4-%E5%88%86%E5%8F%89%E6%B5%8B%E8%AF%95-vs-mock%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E4%B8%AD%E7%9A%84%E6%9D%83%E8%A1%A1%E4%B8%8E%E9%80%89%E6%8B%A9")
4.1 [生产中 Mock 的使用比重](#生产中 Mock 的使用比重 "#41-%E7%94%9F%E4%BA%A7%E4%B8%AD-mock-%E7%9A%84%E4%BD%BF%E7%94%A8%E6%AF%94%E9%87%8D")
4.2 [Mock 的额外价值](#Mock 的额外价值 "#42-mock-%E7%9A%84%E9%A2%9D%E5%A4%96%E4%BB%B7%E5%80%BC")
4.3 [能否完全不用 Mock?影响与风险分析](#能否完全不用 Mock?影响与风险分析 "#43-%E8%83%BD%E5%90%A6%E5%AE%8C%E5%85%A8%E4%B8%8D%E7%94%A8-mock%E5%BD%B1%E5%93%8D%E4%B8%8E%E9%A3%8E%E9%99%A9%E5%88%86%E6%9E%90")
-
[测试 internal/private 函数:Harness 模式详解](#测试 internal/private 函数:Harness 模式详解 "#5-%E6%B5%8B%E8%AF%95-internalprivate-%E5%87%BD%E6%95%B0harness-%E6%A8%A1%E5%BC%8F%E8%AF%A6%E8%A7%A3")
5.1 [测试 internal 函数的标准流程](#测试 internal 函数的标准流程 "#51-%E6%B5%8B%E8%AF%95-internal-%E5%87%BD%E6%95%B0%E7%9A%84%E6%A0%87%E5%87%86%E6%B5%81%E7%A8%8B")
5.2 [private 函数的替代方案](#private 函数的替代方案 "#52-private-%E5%87%BD%E6%95%B0%E7%9A%84%E6%9B%BF%E4%BB%A3%E6%96%B9%E6%A1%88")
5.3 完整示例
-
["变通函数"(Workaround Functions)实战指南](#“变通函数”(Workaround Functions)实战指南 "#6-%E5%8F%98%E9%80%9A%E5%87%BD%E6%95%B0workaround-functions%E5%AE%9E%E6%88%98%E6%8C%87%E5%8D%97")
6.1 [为何需要 workaround?](#为何需要 workaround? "#61-%E4%B8%BA%E4%BD%95%E9%9C%80%E8%A6%81-workaround")
6.2 实现方式与命名规范
6.3 [高级用法:幽灵变量(Ghost Variables)](#高级用法:幽灵变量(Ghost Variables) "#63-%E9%AB%98%E7%BA%A7%E7%94%A8%E6%B3%95%E5%B9%BD%E7%81%B5%E5%8F%98%E9%87%8Fghost-variables")
-
[不变式测试(Invariant Tests):保障系统鲁棒性的核心手段](#不变式测试(Invariant Tests):保障系统鲁棒性的核心手段 "#7-%E4%B8%8D%E5%8F%98%E5%BC%8F%E6%B5%8B%E8%AF%95invariant-tests%E4%BF%9D%E9%9A%9C%E7%B3%BB%E7%BB%9F%E9%B2%81%E6%A3%92%E6%80%A7%E7%9A%84%E6%A0%B8%E5%BF%83%E6%89%8B%E6%AE%B5")
7.1 什么是鲁棒性?
7.2 不变式测试在生产中的地位
7.3 [与 Mock、分叉测试的协同使用策略](#与 Mock、分叉测试的协同使用策略 "#73-%E4%B8%8E-mock%E5%88%86%E5%8F%89%E6%B5%8B%E8%AF%95%E7%9A%84%E5%8D%8F%E5%90%8C%E4%BD%BF%E7%94%A8%E7%AD%96%E7%95%A5")
1. Foundry 支持的六大主流测试方式详解
1.1 基础单元测试(Unit Test)
✅ 特点
- 最常见形式
- 函数名以
test开头 - 每个测试独立(自动
setUp())
✅ 适用场景
验证单个函数的正确性、边界、revert 行为等。
✅ 示例:test/Counter.t.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "forge-std/Test.sol";
contract Counter {
uint256 public count;
function inc() public returns (uint256) { return ++count; }
function dec() public { require(count > 0, "Underflow"); count--; }
}
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
}
function testInc() public {
assertEq(counter.inc(), 1);
assertEq(counter.count(), 1);
}
function testDecRevertsOnZero() public {
vm.expectRevert("Underflow");
counter.dec();
}
}
▶️ 运行
bash
forge test -vv
1.2 模糊测试(Fuzzing Test)
✅ 特点
- 函数参数带
uint256 x等 - Forge 自动用数百个随机值运行
- 发现边界/异常输入 bug
✅ 适用场景
验证函数对任意输入的鲁棒性(如数学运算、数组索引等)
✅ 示例:test/Math.t.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "forge-std/Test.sol";
contract Math {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // Solidity 0.8+ 自动防溢出
}
}
contract MathTest is Test {
Math math = new Math();
// Forge 会自动用 ~256 个随机 a, b 运行此测试
function testFuzz_AddNoOverflow(uint256 a, uint256 b) public {
// 可选:过滤无效输入
vm.assume(a + b >= a); // 避免溢出(其实 0.8+ 不会溢出,仅为演示)
uint256 result = math.add(a, b);
assertEq(result, a + b);
}
}
▶️ 运行
bash
forge test --mc MathTest -vv
💡 vm.assume(condition):跳过不满足条件的输入
1.3 不变量测试(Invariant Test / Stateful Fuzzing)
✅ 特点
- 使用
forge-std/Vm的 handler 模式 - 多个操作随机组合
- 验证"无论怎么操作,某些性质始终成立"
✅ 适用场景
复杂状态机、DeFi 协议(如"总供应量 = 所有用户余额之和")
✅ 示例:test/CounterInvariant.t.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "forge-std/Test.sol";
import "./Counter.sol"; // 假设 Counter 在 src/Counter.sol
contract CounterHandler is Test {
Counter counter;
uint256 public countSnapshot;
constructor(Counter _counter) {
counter = _counter;
}
function inc() public {
counter.inc();
}
function dec() public {
if (counter.count() > 0) {
counter.dec();
}
}
// 每次操作后,Forge 会调用此函数检查不变量
function check_count_non_negative() public {
assertGe(counter.count(), 0); // 始终 >= 0
}
}
contract CounterInvariantTest is Test {
Counter counter;
CounterHandler handler;
function setUp() public {
counter = new Counter();
handler = new CounterHandler(counter);
// 注册 handler 到模糊引擎
bytes4[] memory sigs = new bytes4[](2);
sigs[0] = handler.inc.selector;
sigs[1] = handler.dec.selector;
vm.setHandler(address(handler), sigs, true);
}
// 运行 100 轮随机 inc/dec 序列
function invariant_counter_never_negative() public {
// 不需要写逻辑,Forge 自动调用 handler + check
}
}
▶️ 运行
bash
forge test --mc CounterInvariantTest -vv
⚠️ 注意:需在 setUp 中注册 handler,且测试函数名以 invariant_ 开头(非强制,但惯例)
1.4 分叉测试(Forking Test)
✅ 特点
- 从真实主网/测试网分叉状态
- 测试与现有协议交互(如 Uniswap、Aave)
✅ 适用场景
集成测试、闪电贷模拟、真实资产交互
✅ 示例:test/Fork.t.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "forge-std/Test.sol";
contract ForkTest is Test {
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
function testFork_DaiTotalSupply() public {
// 分叉 Ethereum 主网最新区块
vm.createFork("mainnet");
vm.selectFork(vm.activeFork());
// 调用真实 DAI 合约
(bool success, bytes memory data) = DAI.call(
abi.encodeWithSignature("totalSupply()")
);
uint256 totalSupply = abi.decode(data, (uint256));
assertTrue(success);
console.log("DAI Total Supply:", totalSupply);
assertTrue(totalSupply > 0);
}
}
▶️ 运行(需配置 RPC)
bash
# 在 .env 中设置:MAINNET_RPC_URL=https://...
source .env
forge test --mc ForkTest -vv --fork-url $MAINNET_RPC_URL
1.5 参数化测试(Parameterized Test)
✅ 特点
- 手动提供多组输入
- 类似 fuzzing,但输入可控
✅ 适用场景
验证特定边界值(如 0, 1, max, min)
✅ 示例:test/Param.t.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "forge-std/Test.sol";
contract Math {
function square(uint256 x) public pure returns (uint256) {
return x * x;
}
}
contract ParamTest is Test {
Math math = new Math();
function testParam_Square() public {
uint256[4] memory inputs = [0, 1, 10, 100];
uint256[4] memory expected = [0, 1, 100, 10000];
for (uint i = 0; i < inputs.length; i++) {
assertEq(math.square(inputs[i]), expected[i]);
}
}
}
▶️ 运行
bash
forge test --mc ParamTest -vv
💡 也可结合 fuzzing + vm.assume 实现类似效果,但此方式更直观。
1.6 快照测试(Snapshot Test)------ 通过 gas / logs 验证行为
✅ 特点
- 不只验证结果,还验证 gas 消耗、事件日志
- 用于性能回归、事件完整性测试
✅ 示例:test/Snapshot.t.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console2} from "forge-std/Test.sol";
contract EventEmitter {
event Incremented(uint256 newValue);
uint256 public count;
function inc() public {
count++;
emit Incremented(count);
}
}
contract SnapshotTest is Test {
EventEmitter emitter;
function setUp() public {
emitter = new EventEmitter();
}
function testInc_EmitsEventAndGasBound() public {
vm.expectEmit(true, true, true, true);
emit emitter.Incremented(1);
uint256 gasStart = gasleft();
emitter.inc();
uint256 gasUsed = gasStart - gasleft();
// 验证 gas 消耗不超过阈值(防性能退化)
assertLe(gasUsed, 50000, "Gas used too high");
// 验证状态
assertEq(emitter.count(), 1);
}
}
▶️ 运行
bash
forge test --mc SnapshotTest -vv
2. 测试类型命名规范与组合策略
2.1 命名约定的作用
Foundry 通过命名约定区分测试类型,不仅提升可读性,更支持工具链精准筛选(如 forge test -m "Revert")。以下是五类核心测试:
| 测试类型 | 命名前缀 | 工具支持 |
|---|---|---|
| 普通单元测试 | test_ |
默认运行 |
| 模糊测试 | testFuzz_ |
自动 fuzz |
| 回滚测试 | test_RevertWhen_ / test_RevertIf_ |
可通过 -m "Revert" 筛选 |
| 分叉测试 | testFork_ |
可通过 -m "Fork" 筛选 |
| 不变量测试 | invariant_ |
通常配合 handler |
✅ 关键原则:命名不仅是风格问题,更是自动化测试基础设施的一部分。
2.2 组合测试示例(如 testForkFuzz_RevertWhen_)
作用 :融合多种测试维度,覆盖最复杂的现实场景。
适用场景:DeFi 协议交互、跨合约调用、经济模型验证等。
solidity
function testForkFuzz_RevertWhen_InsufficientBalance(uint256 amount) public {
vm.assume(amount > userBalance);
vm.expectRevert("Insufficient balance");
token.transfer(bob, amount);
}
3. 断言与调试:提升可读性与故障定位效率
3.1 使用描述性断言消息
Forge 默认不显示失败行号,因此应利用 assertEq 的第三个参数添加简短标识:
solidity
function test_add() public {
assertEq(calc.add(2, 3), 5, "1"); // 第一个断言
assertEq(calc.add(-1, 1), 0, "2"); // 第二个断言
}
若失败,输出 "Error: Assertion failed: 2",立即定位问题。
更语义化写法:
solidity
assertEq(totalSupply, sumBalances, "total_supply_mismatch");
3.2 事件测试的最佳实践
始终使用 vm.expectEmit(true, true, true, true):
solidity
function test_transfer_EmitsTransferEvent() public {
vm.expectEmit(true, true, true, true);
emit Transfer(address(this), alice, 100);
token.transfer(alice, 100);
}
✅ 优势:
- 自动验证所有
indexed字段; - 新增字段时测试自动失败,防止遗漏。
4. 分叉测试 vs Mock:生产环境中的权衡与选择
4.1 生产中 Mock 的使用比重
- 大型 DeFi 协议(如 Compound、Lido):< 10%,优先使用 fork 测试。
- 小型项目或内部模块:可能达 20--30%,用于简化未部署依赖。
- 趋势:随着 Anvil 和 RPC 缓存优化,Mock 使用比例持续下降。
4.2 Mock 的额外价值
除了"方便",Mock 还有:
- 隔离性:仅测试自身逻辑,不受外部协议变更干扰;
- 极端状态模拟 :如构造
balance = type(uint).max; - 确定性控制:避免主网状态波动影响 CI 稳定性。
4.3 能否完全不用 Mock?影响与风险分析
可以,且强烈推荐尽量不用,原因如下:
- 区块链是开放的:所有已部署代码均可本地运行,无需像 Web2 那样 mock 闭源 API。
- Mock 易出错:可能遗漏 modifier、gas 行为、重入防护等细节,导致"测试通过但主网失败"。
⚠️ 不用 Mock 的潜在挑战:
| 挑战 | 解决方案 |
|---|---|
| 首次 fork 测试慢 | 固定 block number + RPC 缓存(后续 < 0.5s) |
| 依赖 RPC 稳定性 | 使用 Alchemy/Infura 免费 archive 节点;CI 中缓存响应 |
| 特定历史状态难复现 | 用 anvil_setStorageAt 手动篡改状态 |
✅ 最佳实践:优先 fork 测试;仅当外部合约未部署或需极端控制时才 mock。
5. 测试 internal/private 函数:Harness 模式详解
5.1 测试 internal 函数的标准流程
Solidity 的 internal 函数可被子合约访问,因此可通过继承+暴露的方式测试。
5.2 private 函数的替代方案
private 函数无法被继承访问,建议:
- 改为
internal(最推荐); - 或将核心逻辑提取到
internalhelper 函数中。
5.3 完整示例
被测合约(src/MyVault.sol)
solidity
contract MyVault {
function _calculateFee(uint amount) internal pure returns (uint) {
return amount * 5 / 100; // 5%
}
}
测试文件(test/MyVault.t.sol)
solidity
import {Test} from "forge-std/Test.sol";
import {MyVault} from "src/MyVault.sol";
// Harness 合约:继承并暴露 internal 函数
contract MyVaultHarness is MyVault {
function exposed_calculateFee(uint amount) external pure returns (uint) {
return _calculateFee(amount); // 调用 internal 函数
}
}
contract MyVaultTest is Test {
MyVaultHarness vault;
function setUp() public {
vault = new MyVaultHarness();
}
function test_exposed_calculateFee_ReturnsCorrectValue() public {
assertEq(vault.exposed_calculateFee(1000), 50, "fee_calc");
}
}
📌 命名规范 :暴露函数使用 exposed_<originalName> 前缀,表明其为测试专用。
6. "变通函数"(Workaround Functions)实战指南
6.1 为何需要 workaround?
当合约内部状态(如 private 数组长度)没有 public getter,但测试需要访问时。
6.2 实现方式与命名规范
通过 harness 合约访问私有成员,并提供 external 接口:
solidity
contract TaskQueue {
address[] private tasks;
function addTask(address task) external { tasks.push(task); }
// 无 getLength()!
}
contract TaskQueueHarness is TaskQueue {
function workaround_getTaskCount() external view returns (uint) {
return tasks.length; // 访问 private 成员
}
}
6.3 高级用法:幽灵变量(Ghost Variables)
用于不变式测试,记录生产环境不需要但测试必需的信息:
solidity
contract TokenHarness is Token {
mapping(address => bool) public _isHolder; // ghost variable
function _mint(address to, uint amount) internal override {
if (balanceOf(to) == 0 && amount > 0) _isHolder[to] = true;
super._mint(to, amount);
}
}
随后可高效验证:"所有持币人余额之和 = totalSupply"。
📌 命名规范 :使用 workaround_<purpose> 前缀。
7. 不变式测试(Invariant Tests):保障系统鲁棒性的核心手段
7.1 什么是鲁棒性?
鲁棒性(robustness)是英文音译,意即程序或系统的健壮性------指其在面对异常输入、错误操作或环境扰动时仍能稳定运行或优雅降级的能力。
一个鲁棒性强的系统不会因畸形数据、边界值或资源异常而崩溃,而是能妥善处理或给出明确错误提示。
在 Web3 领域尤为重要:智能合约一旦部署便不可更改,任何未考虑到的边界情况都可能导致严重漏洞甚至资金损失。
因此,开发者常通过模糊测试(Fuzzing)等手段提升代码鲁棒性。
简言之,鲁棒性 = 抗压 + 容错 + 可靠,是高质量软件尤其是高风险系统(如区块链)不可或缺的特性。
7.2 不变式测试在生产中的地位
- DeFi 协议(借贷、AMM、衍生品):核心测试手段,审计必查项。
- NFT 或简单合约:较少使用。
- 使用频率:在复杂协议中,invariant tests 占测试总量 30% 以上。
7.3 与 Mock、分叉测试的协同使用策略
三者互补而非互斥:
| 测试类型 | 目标 | 场景 |
|---|---|---|
| Unit Tests | 验证单个函数正确性 | 开发初期、PR 检查 |
| Invariant Tests | 验证全局属性永不破坏 | 模糊测试、状态机验证 |
| Fork Tests | 验证真实世界交互安全 | 上线前、重大升级 |
✅ 典型生产实践(组合使用):
solidity
function invariant_totalSupplyEqualsSumOfBalances() public {
uint total = token.totalSupply();
uint sum = 0;
for (address holder : allHolders) {
sum += token.balanceOf(holder);
}
assertEq(total, sum, "Invariant violated: totalSupply != sum of balances");
}
💡 关键:Invariant tests 通常配合 fuzzing + stateful testing(如使用 handler 模式随机调用多个函数),模拟用户各种操作组合,是提升系统鲁棒性的终极武器。
8. 总结:构建高可靠智能合约测试体系
📊 Foundry 六大测试方式对比
| 类型 | 关键词 | 用途 | 是否自动运行 |
|---|---|---|---|
| 单元测试 | testXxx() |
基础功能验证 | ✅ |
| 模糊测试 | testFuzz_Xxx(type x) |
随机输入鲁棒性 | ✅(自动 fuzz) |
| 不变量测试 | invariant_xxx() + handler |
状态机正确性 | ✅(自动组合) |
| 分叉测试 | vm.createFork() |
真实链交互 | ✅(需 RPC) |
| 参数化测试 | 手动数组循环 | 特定边界值 | ✅ |
| 快照测试 | vm.expectEmit, gasleft() |
事件/gas 验证 | ✅ |
🎯 推荐实践对照表
| 场景 | 推荐做法 |
|---|---|
| 正常逻辑验证 | test_ + 描述性断言 |
| 边界/异常覆盖 | testFuzz_ + vm.assume() |
| 外部协议交互 | 优先 fork 测试,少用 mock |
| internal 函数测试 | exposed_ harness 模式 |
| 私有状态访问 | workaround_ 函数 |
| 全局安全性保障 | 不变式测试 + 模糊测试 |
遵循本指南,你将能够:
- 编写出结构清晰、易于维护的测试代码;
- 实现高覆盖率、高鲁棒性的合约验证;
- 显著降低主网事故与资金损失风险。
在 Web3 世界,测试不是可选项,而是生存必需品。用好 Foundry,让你的代码坚如磐石。