name: testing description: 使用Foundry进行智能合约测试 —— 单元测试、模糊测试、fork测试、不变性测试。应该测试什么,不应该测试什么,以及大语言模型常犯的错误。
智能合约测试
你可能做错的地方
你测试getter和简单函数。 测试name()返回名称是毫无价值的。测试边缘情况、失败模式和经济不变性 —— 那些当它们出错时会损失金钱的事情。
你不做模糊测试。 forge test找到你想到的bug。模糊测试找到你没想到的。如果你的合约做数学运算,进行模糊测试。如果它处理用户输入,进行模糊测试。如果它转移价值,一定要进行模糊测试。
你不做fork测试。 如果你的合约调用Uniswap、Aave或任何外部协议,使用它们实际部署的合约在分叉上测试。模拟它们会隐藏只在实际状态中出现的集成bug。
你写的测试反映实现。 测试deposit(100)设置balance[user] = 100是重言式 —— 你在测试Solidity赋值是否有效。测试属性:“存款和取款后,用户拿回他们的代币。”测试不变性:“总存款总是等于合约余额。”
对状态化协议跳过不变性测试。 如果你的合约有多个交互函数随时间改变状态(金库、AMM、借贷),你需要不变性测试。单元测试检查一条路径;不变性测试检查在数千个随机序列中属性是否保持。
使用Foundry进行单元测试
测试文件结构
// test/MyContract.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
token = new MyToken("Test", "TST", 1_000_000e18);
// 给alice一些代币进行测试
token.transfer(alice, 10_000e18);
}
function test_TransferUpdatesBalances() public {
vm.prank(alice);
token.transfer(bob, 1_000e18);
assertEq(token.balanceOf(alice), 9_000e18);
assertEq(token.balanceOf(bob), 1_000e18);
}
function test_TransferEmitsEvent() public {
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 500e18);
vm.prank(alice);
token.transfer(bob, 500e18);
}
function test_RevertWhen_TransferExceedsBalance() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(bob, 999_999e18); // 超过alice拥有的
}
function test_RevertWhen_TransferToZeroAddress() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(address(0), 100e18);
}
}
关键断言模式
// 相等性
assertEq(actual, expected);
assertEq(actual, expected, "描述性错误消息");
// 比较
assertGt(a, b); // a > b
assertGe(a, b); // a >= b
assertLt(a, b); // a < b
assertLe(a, b); // a <= b
// 近似相等(对于有舍入的数学运算)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // 以WAD表示(1e18 = 100%)
// 预期恢复
vm.expectRevert(); // 任何恢复
vm.expectRevert("余额不足"); // 特定消息
vm.expectRevert(MyContract.CustomError.selector); // 自定义错误
// 预期事件
vm.expectEmit(true, true, false, true); // (topic1, topic2, topic3, data)
emit MyEvent(expectedArg1, expectedArg2);
实际应该测试什么
// ✅ 测试:损失金钱的边缘情况
function test_TransferZeroAmount() public { /* ... */ }
function test_TransferEntireBalance() public { /* ... */ }
function test_TransferToSelf() public { /* ... */ }
function test_ApproveOverwrite() public { /* ... */ }
function test_TransferFromWithExactAllowance() public { /* ... */ }
// ✅ 测试:访问控制
function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ }
function test_OwnerCanPause() public { /* ... */ }
// ✅ 测试:失败模式
function test_RevertWhen_DepositZero() public { /* ... */ }
function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ }
function test_RevertWhen_ContractPaused() public { /* ... */ }
// ❌ 不要测试:OpenZeppelin内部
// function test_NameReturnsName() — 他们已测试过这个
// function test_SymbolReturnsSymbol() — 浪费时间
// function test_DecimalsReturns18() — 确实如此,信任它
模糊测试
Foundry自动对任何带参数的测试函数进行模糊测试。不是测试一个值,而是测试数百个随机值。
基础模糊测试
// Foundry用随机金额调用这个
function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public {
// 将输入绑定到有效范围
amount = bound(amount, 1, token.balanceOf(alice));
uint256 balanceBefore = token.balanceOf(alice);
vm.startPrank(alice);
token.approve(address(vault), amount);
vault.deposit(amount, alice);
vault.withdraw(vault.balanceOf(alice), alice, alice);
vm.stopPrank();
// 属性:用户拿回他们存入的(减去任何费用)
assertGe(token.balanceOf(alice), balanceBefore - 1); // 允许1 wei舍入
}
绑定输入
// bound()比vm.assume()更优 — bound重塑,assume丢弃
function testFuzz_Fee(uint256 amount, uint256 feeBps) public {
amount = bound(amount, 1e6, 1e30); // 合理的代币金额
feeBps = bound(feeBps, 1, 10_000); // 0.01% 到 100%
uint256 fee = (amount * feeBps) / 10_000;
uint256 afterFee = amount - fee;
// 属性:费用 + 余数总是等于原始金额
assertEq(fee + afterFee, amount);
}
// vm.assume()丢弃输入 — 谨慎使用
function testFuzz_Division(uint256 a, uint256 b) public {
vm.assume(b > 0); // 跳过零(会恢复)
// ...
}
用更多迭代运行
# 默认:256 次运行
forge test
# 更彻底:10,000 次运行
forge test --fuzz-runs 10000
# 在foundry.toml中为CI设置
# [fuzz]
# runs = 1000
Fork测试
在主网分叉上测试你的合约对抗实际部署的协议。这捕获模拟器无法捕获的集成bug。
基础fork测试
contract SwapTest is Test {
// 真实主网地址
address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
address constant WETH = 0xC02aaA39b223FE8D0A0e5d4F533d69895b411153;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
function setUp() public {
// 为可重复性分叉主网到特定区块
vm.createSelectFork("mainnet", 19_000_000);
}
function test_SwapETHForUSDC() public {
address user = makeAddr("user");
vm.deal(user, 1 ether);
vm.startPrank(user);
// 构建交换路径
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: user,
amountIn: 0.1 ether,
amountOutMinimum: 0, // 在生产中,永远不要设置为0
sqrtPriceLimitX96: 0
});
// 执行交换
uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params);
vm.stopPrank();
// 验证我们拿回了USDC
assertGt(amountOut, 0, "应该接收USDC");
assertGt(IERC20(USDC).balanceOf(user), 0);
}
}
何时进行fork测试
- 总是: 任何调用外部协议的合约(Uniswap、Aave、Chainlink)
- 总是: 任何处理有怪癖代币的合约(USDT、手续费转移、重定价)
- 总是: 任何读取预言机价格的合约
- 从不: 没有外部调用的纯逻辑合约 — 使用单元测试
运行fork测试
# 从RPC URL分叉
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
# 在特定区块分叉(可重复)
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000
# 在foundry.toml中设置以避免CLI标志
# [rpc_endpoints]
# mainnet = "${MAINNET_RPC_URL}"
不变性测试
不变性测试验证在数千个随机函数调用序列中属性是否保持。对于状态化协议至关重要。
什么是不变性?
不变性是必须总是为真的属性,无论用户采取什么行动序列:
- “总供应量等于所有余额的总和”(ERC-20)
- “总存款等于总份额乘以份额价格”(金库)
- “每次交换后 x * y >= k”(AMM)
- “用户总是可以取回他们存入的”(托管)
基础不变性测试
contract VaultInvariantTest is Test {
MyVault public vault;
IERC20 public token;
VaultHandler public handler;
function setUp() public {
token = new MockERC20("Test", "TST", 18);
vault = new MyVault(token);
handler = new VaultHandler(vault, token);
// 告诉Foundry随机调用哪个合约
targetContract(address(handler));
}
// 这在每个随机序列后运行
function invariant_TotalAssetsMatchesBalance() public view {
assertEq(
vault.totalAssets(),
token.balanceOf(address(vault)),
"总资产必须等于实际余额"
);
}
function invariant_SharePriceNeverZero() public view {
if (vault.totalSupply() > 0) {
assertGt(vault.convertToAssets(1e18), 0, "份额价格必须永远不会为零");
}
}
}
// 处理器:指导的随机操作
contract VaultHandler is Test {
MyVault public vault;
IERC20 public token;
constructor(MyVault _vault, IERC20 _token) {
vault = _vault;
token = _token;
}
function deposit(uint256 amount) public {
amount = bound(amount, 1, 1e24);
deal(address(token), msg.sender, amount);
vm.startPrank(msg.sender);
token.approve(address(vault), amount);
vault.deposit(amount, msg.sender);
vm.stopPrank();
}
function withdraw(uint256 shares) public {
uint256 maxShares = vault.balanceOf(msg.sender);
if (maxShares == 0) return;
shares = bound(shares, 1, maxShares);
vm.prank(msg.sender);
vault.redeem(shares, msg.sender, msg.sender);
}
}
运行不变性测试
# 默认深度(每个序列15次调用,256个序列)
forge test
# 更深探索
forge test --fuzz-runs 1000
# 在foundry.toml中配置
# [invariant]
# runs = 512
# depth = 50
不要测试什么
- OpenZeppelin内部。 不要测试
ERC20.transfer是否有效。它已经被数十家公司审计并被数千个合约使用。测试你在它之上添加的逻辑。 - Solidity语言特性。 不要测试
require是否恢复或mapping是否存储值。编译器有效。 - 每个getter。 如果
name()返回你传给构造函数的名称,那不是测试 — 那是重言式。 - 只测试快乐路径。 快乐路径可能有效。测试不快乐路径:零会发生什么?最大uint?未经授权的调用者?重入?
将你的测试精力集中在: 自定义业务逻辑、数学运算、与外部协议的集成点、访问控制边界和经济边缘情况。
预部署测试清单
- [ ] 所有自定义逻辑都有单元测试包括边缘情况
- [ ] 测试零金额、最大uint、空数组、自我转移
- [ ] 验证访问控制 — 未经授权的调用恢复
- [ ] 对所有数学运算进行模糊测试(最少1000次运行)
- [ ] 为每个外部协议集成进行fork测试
- [ ] 对状态化协议进行不变性测试(金库、AMM、借贷)
- [ ] 使用
expectEmit验证事件 - [ ] 使用
forge snapshot拍摄gas快照以捕获退化 - [ ] 使用
slither .进行静态分析 — 无未处理的高/中风险发现 - [ ] 所有测试通过:
forge test -vvv