引言
在智能合约开发中,测试是保障资金安全和逻辑正确的生命线。与传统软件不同,区块链上的代码一旦部署便难以修改,任何漏洞都可能导致灾难性后果。在众多开发框架中,Foundry 凭借其闪电般的速度、原生 Solidity 测试体验和强大的内置工具,正迅速成为开发者的首选。
Foundry 的核心优势在于:
- 用 Solidity 写测试:无需在 JavaScript/Solidity 之间切换上下文,尤其适合纯合约开发者。
- 极致的性能:直接编译 Solidity,绕过了 Node.js 和 Ethers.js 的开销,测试速度极快。
- 强大的 Fuzzing 能力:内置自动化模糊测试,能发现边缘案例。
- 丰富的作弊码:提供了对区块链状态的完全控制,方便模拟各种场景。
本文将带你从零开始,全面掌握使用 Foundry 进行智能合约测试的艺术。
第一部分:编写你的第一个测试
让我们为一个简单的计数器合约编写测试。
1.1 创建合约
在 src/Counter.sol 中:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Counter {
uint256 public count;
function increment() public {
count++;
}
function decrement() public {
require(count > 0, "Counter: decrement overflow");
count--;
}
function reset() public {
count = 0;
}
}
1.2 编写基础测试
在 test/Counter.t.sol 中:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol"; // 导入 Foundry 测试标准库
import "../src/Counter.sol";
contract CounterTest is Test { // 测试合约必须继承 `Test`
Counter public counter;
// 在每个测试函数前运行
function setUp() public {
counter = new Counter();
}
// 测试:初始计数应为 0
function test_InitialCount() public {
assertEq(counter.count(), 0);
}
// 测试:increment 函数应增加计数
function test_Increment() public {
counter.increment();
assertEq(counter.count(), 1);
}
// 测试:decrement 函数应减少计数
function test_Decrement() public {
counter.increment(); // 先增加到 1
counter.decrement();
assertEq(counter.count(), 0);
}
// 测试:decrement 在 count 为 0 时应回滚
function test_DecrementRevert() public {
vm.expectRevert("Counter: decrement overflow");
counter.decrement(); // 此时 count 为 0,应该回滚
}
// 测试:reset 函数应将计数归零
function test_Reset() public {
counter.increment();
counter.increment();
counter.reset();
assertEq(counter.count(), 0);
}
}
1.3 运行测试
执行所有测试:
bash
forge test
执行单个测试文件:
bash
forge test --match-path test/Counter.t.sol
执行特定测试函数:
bash
forge test --match-test test_Increment
带详细日志输出:
bash
forge test -vvv
• --match-test 仅运⾏与指定的正则表达式模式匹配的测试函数 [别名: mt]
• --no-match-test 仅运⾏不符合指定正则表达式模式的测试函数 [别名: nmt]
• --match-contract 仅运⾏与指定正则表达式模式匹配的合约中的测试 [别名: mc]
• --no-match-contract 仅运⾏不符合指定正则表达式模式的合约中的测试 [别名: nmc]
第二部分:掌握核心测试工具
2.1 日志打印
• Console.log 最多⽀持 4 个参数、 4 个类型: uint、 string、 bool、 address
• 还有⼀些变种函数:
- console.logInt(int i)
- console.logString(string memory s)
- console.logBytes1()、 console.logBytes2(bytes2 b) ...
• ⽀持打印格式化内容: %s, %d
• console.log("Changing owner from %s to %s", currentOwner, newOwner)
• 在测试⽹、主⽹上执⾏时, ⽆效,但会消耗 gas
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract TestConsoleLog is Test {
address user1 = address(0x2);
address user2 = address(0x3);
function setUp() public {
}
function test_LogUsage() public view{
uint amount = 220;
string memory userName = "zhangsan";
bool activeFlag = true;
address userAddr = address(user1);
bytes4 sel = bytes4(keccak256("getCallData(address,uint256)"));
//最多传4个参数
console.log("[amount] ", amount ," [userName]", userName);
//支持打印格式化内容: %s, %d
console.log("This is a four-parameter log:[uint] %d, [string] %s, [bool] %s", amount, userName, activeFlag);
//变种函数
console.logUint(amount);
console.logBool(activeFlag);
console.logString(userName);
console.logAddress(userAddr);
console.logBytes4(sel);
}
}
2.2 断言函数
Foundry 提供了丰富的断言函数,全部在 stdAssertions.sol 中:
solidity
// 相等性
assertEq(uint256 a, uint256 b);
assertEq(string memory a, string memory b);
assertEq(address a, address b);
// 不等性
assertNotEq(uint256 a, uint256 b);
// 大于/小于
assertGt(uint256 a, uint256 b);
assertLt(uint256 a, uint256 b);
// 布尔条件
assertTrue(bool condition);
assertFalse(bool condition);
// 地址余额
assertEq(address(this).balance, uint256(1 ether));
// 字节和数组
assertEq(bytes memory a, bytes memory b);
assertEq32(bytes32 a, bytes32 b);
2.3 作弊码 - 区块链状态控制
作弊码是 Foundry 测试的灵魂,通过 vm 实例调用。它们让你能操纵区块链环境。
Foundry 在 vm 合约中提供了⼀组作弊码,已在Test 合约中定义 vm 成员供使⽤,⽤于在测试中模拟各种场景和条件,作弊码分以下⼏
类:
- Environment(环境):改变 EVM 状态的作弊码。
- Assertions(断⾔):断⾔作弊码。
- Fuzzer(模糊测试器):配置模糊测试器的作弊码。
- External(外部):与外部状态(⽂件、命令等)交互的作弊码。
- Utilities(实⽤⼯具):实⽤⼯具作弊码。
- Forking(分叉):分叉模式的作弊码。
- Snapshots(快照):快照作弊码。
- RPC:与 RPC 相关的作弊码。
- File(⽂件):处理⽂件的作弊码。
常用的作弊码:
- vm.roll(uint256 blockNumber):模拟区块号的变更。
- vm.warp(uint256 timestamp):改变区块时间戳。
- vm.prank(address sender):更改下⼀个调⽤的发送者(msg.sender)。
- vm.deal(address to, uint256 amount):重置ETH余额到指定地址。
- deal(address token, address to, uint256 amount):重置ERC20代币余额
常用作弊码示例:
solidity
function test_WithCheatCodes() public {
// 1. 改变 msg.sender
address alice = makeAddr("alice");
vm.prank(alice);
counter.increment(); // 现在是从 alice 的地址调用
// 2. 改变区块时间戳
vm.warp(1641070800); // 设置 block.timestamp
// 3. 改变区块号
vm.roll(1000); // 设置 block.number
// 4. 给地址充值 ETH
hoax(alice, 1 ether); // 等同于 prank + deal
// deal(alice, 1 ether); // 直接给 alice 1 ETH
// 5. 模拟期望回滚
vm.expectRevert("Counter: decrement overflow");
counter.decrement();
// 6. 模拟特定地址调用另一个合约
vm.mockCall(
address(0xSomeContract),
abi.encodeWithSignature("someFunction()"),
abi.encode(true)
);
// 7. 记录事件 (下一节详述)
vm.expectEmit(true, true, true, true);
emit SomeEvent(alice, 100);
someContract.triggerEvent(alice, 100);
// 8. 快照和回滚状态(用于在单个测试中隔离变化)
uint256 snapshot = vm.snapshot();
counter.increment();
vm.revertTo(snapshot); // count 又回到了 snapshot 之前的状态
assertEq(counter.count(), 0);
}
2.4 事件测试
确保合约发出了正确的事件:
solidity
event Incremented(address indexed sender, uint256 newCount);
function test_EventEmission() public {
address sender = address(this);
// 告诉 VM 我们期望下一个调用会发出一个事件
// 参数:检查 emitter, topic1, topic2, topic3, data
vm.expectEmit(true, true, false, true, address(counter));
// 发出我们期望的事件
emit Incremented(sender, 1);
// 执行实际会触发事件的调用
counter.increment();
}
第三部分:高级测试策略
3.1 Fuzzing / 模糊测试
模糊测试是 Foundry 最强大的功能之一,它能自动生成大量随机输入来测试你的函数。
- 随机输⼊ to 和 about 测试 转账的健壮性
- assume 设置条件, bound 设置取值范围
solidity
// 一个简单的模糊测试:传入随机数值 n
function testFuzz_IncrementBy(uint256 n) public {
// 为了避免溢出,我们可以约束 n 的范围 (可选)
n = bound(n, 0, type(uint256).max - counter.count() - 1);
uint256 initialCount = counter.count();
// 注意:这里不能直接调用,因为我们的 Counter 没有 increment(uint256) 函数。
// 假设我们有一个 `incrementBy(uint256)` 函数:
// counter.incrementBy(n);
// assertEq(counter.count(), initialCount + n);
}
// 更实际的例子:测试转账功能
function testFuzz_Transfer(address sender, uint256 amount) public {
// 约束条件:sender 不能是零地址,amount 不能超过 sender 的余额
vm.assume(sender != address(0));
amount = bound(amount, 0, sender.balance);
uint256 initialBalanceSender = sender.balance;
uint256 initialBalanceReceiver = address(this).balance;
vm.prank(sender);
(bool success, ) = address(this).call{value: amount}("");
require(success, "Transfer failed");
assertEq(sender.balance, initialBalanceSender - amount);
assertEq(address(this).balance, initialBalanceReceiver + amount);
}
运行模糊测试并指定迭代次数:
bash
forge test --match-test testFuzz_IncrementBy --fuzz-runs 10000
3.2 Fork Testing - 分叉测试
Foundry 允许你在本地分叉一个主网(或测试网)状态,从而在与真实环境完全一致的状态下测试你的合约。
solidity
// 在 Anvil(Foundry 的本地节点)上分叉主网
function test_ForkMainnet() public {
// 创建一条分叉链 (通常写在 setUp 中)
uint256 mainnetFork = vm.createFork("https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY");
// 选择该分叉
vm.selectFork(mainnetFork);
// 现在你可以与主网上的真实合约交互
address dai = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address holder = 0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643;
// 读取主网状态
uint256 balance = IERC20(dai).balanceOf(holder);
assertGt(balance, 0);
}
// 在特定区块分叉
function test_ForkAtBlock() public {
uint256 forkBlock = 15_000_000;
uint256 mainnetFork = vm.createFork("https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY", forkBlock);
vm.selectFork(mainnetFork);
// 现在你处在区块 15,000,000 的状态
assertEq(block.number, forkBlock);
}
运行分叉测试:
bash
forge test --match-test test_ForkMainnet --fork-url https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY
分叉测试示例和效果:
solidity
//分叉测试
function test_Fork() public{
vm.selectFork(sepoliaForkId);
assertEq(vm.activeFork(), sepoliaForkId);
SuccessTokenWithCallback sstToken = SuccessTokenWithCallback(0x80317e662c0799B5f5656F5207F883fB1d75Ccc9);
uint amount = sstToken.balanceOf(0x2D6cD17981dB1eFB171d6cEB997844798C68b9CF);
uint total = sstToken.totalSupply();
console.log("current token amount:", amount);
console.log("sst token totalSupply:", total);
assertLe(amount, total, "amount should be less or equal totalSupply");
}

3.3 invariant Testing - 不变性测试
不变性测试通过随机调用合约的一系列函数,并始终保持某些"不变式"为真,来发现复杂系统中的逻辑错误。
• 在函数名称前加上 invariant 来表示不变量测试
• 使⽤ targetContract 和 targetSelector 来指定要测试的合约和函数
在 test/Invariant.t.sol 中:
solidity
import "forge-std/Test.sol";
import "../src/Counter.sol";
contract InvariantCounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
targetContract(address(counter)); // 告诉 Foundry 对哪个合约进行不变性测试
}
// 这个不变式应该始终成立:count 永远不会超过 type(uint256).max
// 虽然看似简单,但通过随机调用 increment(),可能会发现潜在的溢出问题(虽然这里没有)
function invariant_countNeverOverflows() public {
assertLt(counter.count(), type(uint256).max);
}
// 另一个不变式:count 只能通过合约的函数改变
// 注意:这个例子比较简单,实际中可能需要更复杂的不变式
}
运行不变性测试:
bash
forge test --match-test InvariantCounterTest --invariant
第四部分:工作流与最佳实践
4.1 测试组织与 Gas 报告
组织测试:
- 使用
setUp进行初始化。 - 为不同的功能模块创建不同的测试文件。
- 使用
_testSomething_Success()和_testSomething_Revert()的命名约定。
生成 Gas 报告:
bash
forge test --gas-report
这能帮助你识别和优化合约中消耗 Gas 较多的函数。
为Gas消耗生成⼀个快照文件:
bash
forge snapshot --snap <FILE_NAME>
代码修改后,不同的 .gas-snapshot 对比 gas 消耗:
bash
# 当前运⾏的snap 与 v1.gas-snap 对⽐
forge snapshot --diff v1.gas-snap
# 对比并显示不同
forge snapshot --diff v1.gas-snap
4.2 持续集成
在你的项目根目录创建 .github/workflows/test.yml:
yaml
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
- name: Run Tests
run: forge test -vvv
4.3 代码覆盖率
生成测试覆盖率报告,查看哪些代码行被测试覆盖:
bash
# 生成覆盖率报告
forge coverage
# 生成更详细的 lcov 报告
forge coverage --report lcov
然后可以用 genhtml(需要安装 lcov)打开 HTML 报告:
bash
genhtml lcov.info -o coverage-report && open coverage-report/index.html
结论
Foundry 以其现代化的设计、卓越的性能和强大的工具链,彻底改变了智能合约的测试方式。通过本指南,你应当已经掌握了:
- 基础:搭建环境、编写和运行基础测试。
- 核心:使用断言、作弊码来控制并验证合约状态。
- 进阶:利用模糊测试、分叉测试和不变性测试来构建坚如磐石的防御体系。
- 工程化:将测试集成到你的开发工作流和 CI/CD 中。
测试不是一项繁琐的任务,而是一种投资。在 Foundry 的帮助下,你可以用更少的代码、更快的速度,构建出更安全、更可靠的智能合约。现在,就去为你的下一个项目编写全面的测试吧!
进一步学习:
- Foundry Book - 官方文档
- Solidity by Example - 学习 Solidity 模式
- Cyfrin Updrafts - 智能合约安全与最佳实践