关于 testing
面向 Solidity 开发者的智能合约测试指南,重点介绍 Foundry 单元、模糊、分叉和不变量测试,帮助检查边界情况、集成问题与经济属性。
主要功能
- 01Foundry 单元测试与断言
- 02模糊测试及输入约束
- 03基于真实协议状态的分叉测试
- 04跨随机调用序列验证不变量
- 05部署前测试与分析检查清单
适用场景
- 01检查合约数学运算和用户输入边界
- 02在分叉环境测试外部协议集成
- 03验证金库、AMM 等状态属性
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ austintgriffith/ ethskills --skill testing安装后在 Claude Code 中输入/testing确认已加载。
详细介绍
智能合约测试
你可能做错了什么
你在测试 getter 和简单函数。 测试 name() 是否返回名称毫无意义。应该测试边界情况、失败模式和经济不变量——这些问题一旦出错就会造成资金损失。
你没有进行模糊测试。 forge test 能找到你想到的 bug。模糊测试能找到你没想到的 bug。如果合约会进行数学运算,就对它进行模糊测试。如果它会处理用户输入,就对它进行模糊测试。如果它会转移价值,那就更应该进行模糊测试。
你没有进行分叉测试。 如果合约会调用 Uniswap、Aave 或任何外部协议(已验证的地址:addresses/SKILL.md),就在分叉环境中针对它们实际部署的合约进行测试。Mock 会掩盖只有在真实状态下才会出现的集成 bug。
你编写的测试照搬了实现逻辑。 测试 deposit(100) 会将 balance[user] 设为 100 是同义反复——你只是在测试 Solidity 赋值是否有效。应该测试属性:“存款后再取款,用户能拿回自己的代币。”测试不变量:“存款总额始终等于合约余额。”
你跳过了有状态协议的不变量测试。 如果合约包含多个会随时间改变状态的交互函数(Vault、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);
// Give alice some tokens for testing
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); // More than alice has
}
function test_RevertWhen_TransferToZeroAddress() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(address(0), 100e18);
}
}
核心断言模式
// Equality
assertEq(actual, expected);
assertEq(actual, expected, "descriptive error message");
// Comparisons
assertGt(a, b); // a > b
assertGe(a, b); // a >= b
assertLt(a, b); // a < b
assertLe(a, b); // a <= b
// Approximate equality (for math with rounding)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%)
// Revert expectations
vm.expectRevert(); // Any revert
vm.expectRevert("Insufficient balance"); // Specific message
vm.expectRevert(MyContract.CustomError.selector); // Custom error
// Event expectations
vm.expectEmit(true, true, false, true); // (topic1, topic2, topic3, data)
emit MyEvent(expectedArg1, expectedArg2);
实际应该测试的内容
// ✅ TEST: Edge cases that lose money
function test_TransferZeroAmount() public { /* ... */ }
function test_TransferEntireBalance() public { /* ... */ }
function test_TransferToSelf() public { /* ... */ }
function test_ApproveOverwrite() public { /* ... */ }
function test_TransferFromWithExactAllowance() public { /* ... */ }
// ✅ TEST: Access control
function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ }
function test_OwnerCanPause() public { /* ... */ }
// ✅ TEST: Failure modes
function test_RevertWhen_DepositZero() public { /* ... */ }
function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ }
function test_RevertWhen_ContractPaused() public { /* ... */ }
// ❌ DON'T TEST: OpenZeppelin internals
// function test_NameReturnsName() — they already tested this
// function test_SymbolReturnsSymbol() — waste of time
// function test_DecimalsReturns18() — it does, trust it
模糊测试
Foundry 会自动对带参数的测试函数进行模糊测试。它不会只测试一个值,而是会测试数百个随机值。
基本模糊测试
// Foundry calls this with random amounts
function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public {
// Bound input to valid range
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();
// Property: user gets back what they deposited (minus any fees)
assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding
}
限定输入范围
// bound() is preferred over vm.assume() — bound reshapes, assume discards
function testFuzz_Fee(uint256 amount, uint256 feeBps) public {
amount = bound(amount, 1e6, 1e30); // Reasonable token amounts
feeBps = bound(feeBps, 1, 10_000); // 0.01% to 100%
uint256 fee = (amount * feeBps) / 10_000;
uint256 afterFee = amount - fee;
// Property: fee + remainder always equals original
assertEq(fee + afterFee, amount);
}
// vm.assume() discards inputs — use sparingly
function testFuzz_Division(uint256 a, uint256 b) public {
vm.assume(b > 0); // Skip zero (would revert)
// ...
}
增加迭代次数运行
# Default: 256 runs
forge test
# More thorough: 10,000 runs
forge test --fuzz-runs 10000
# Set in foundry.toml for CI
# [fuzz]
# runs = 1000
分叉测试
在主网分叉环境中针对实际部署的协议测试合约。这样可以发现 Mock 无法发现的集成 bug。
基本分叉测试
contract SwapTest is Test {
// Real mainnet addresses — full verified list: addresses/SKILL.md
address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
function setUp() public {
// Fork mainnet at a specific block for reproducibility
vm.createSelectFork("mainnet", 19_000_000);
}
function test_SwapETHForUSDC() public {
address user = makeAddr("user");
vm.deal(user, 1 ether);
vm.startPrank(user);
// Build swap path
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: user,
amountIn: 0.1 ether,
amountOutMinimum: 0, // In production, NEVER set to 0
sqrtPriceLimitX96: 0
});
// Execute swap
uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params);
vm.stopPrank();
// Verify we got USDC back
assertGt(amountOut, 0, "Should receive USDC");
assertGt(IERC20(USDC).balanceOf(user), 0);
}
}
何时进行分叉测试
- 始终: 任何会调用外部协议的合约(Uniswap、Aave、Chainlink)
- 始终: 任何会处理具有特殊行为的代币的合约(USDT、转账收费代币、重基代币)
- 始终: 任何会读取预言机价格的合约
- 绝不: 没有外部调用的纯逻辑合约——使用单元测试
运行分叉测试
# Fork from RPC URL
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
# Fork at specific block (reproducible)
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000
# Set in foundry.toml to avoid CLI flags
# [rpc_endpoints]
# mainnet = "${MAINNET_RPC_URL}"
不变量测试
不变量测试会验证属性在数千个随机函数调用序列中是否都成立。对于有状态协议至关重要。
什么是不变量?
不变量是无论用户采取何种操作序列,都必须始终成立的属性:
- “总供应量等于所有余额之和”(ERC-20)
- “存款总额等于总份额乘以份额价格”(Vault)
- “每次交换后,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);
// Tell Foundry which contract to call randomly
targetContract(address(handler));
}
// This runs after every random sequence
function invariant_TotalAssetsMatchesBalance() public view {
assertEq(
vault.totalAssets(),
token.balanceOf(address(vault)),
"Total assets must equal actual balance"
);
}
function invariant_SharePriceNeverZero() public view {
if (vault.totalSupply() > 0) {
assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero");
}
}
}
// Handler: guided random actions
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);
}
}
运行不变量测试
# Default depth (15 calls per sequence, 256 sequences)
forge test
# Deeper exploration
forge test --fuzz-runs 1000
# Configure in foundry.toml
# [invariant]
# runs = 512
# depth = 50
不要测试什么
- OpenZeppelin 内部实现。 不要测试
ERC20.transfer是否有效。它已由数十家机构审计,并被数千个合约使用。应该测试基于它实现的自定义逻辑。 - Solidity 语言特性。 不要测试
require是否会回退,也不要测试mapping是否会存储值。编译器正常工作。 - 每个 getter。 如果
name()返回你传给构造函数的名称,这不算测试——这只是同义反复。 - 只测试成功路径。 成功路径大概能正常工作。还要测试失败路径:输入为零时会怎样?达到最大 uint 时会怎样?调用者未获授权时会怎样?发生重入时会怎样?
测试应重点关注: 自定义业务逻辑、数学运算、与外部协议的集成点、访问控制边界以及经济边界情况。
部署前测试清单
- 所有自定义逻辑都已编写单元测试并覆盖边界情况
- 已测试零金额、最大 uint、空数组和向自身转账的情况
- 已验证访问控制——未经授权的调用会回滚
- 对所有数学运算进行模糊测试(至少运行 1000 次)
- 对每个外部协议集成进行分叉测试
- 对有状态协议(vault、AMM、借贷协议)进行不变量测试
- 使用
expectEmit验证事件 - 使用
forge snapshot生成 Gas 快照,以捕获回归问题 - 使用
slither .进行静态分析——不存在尚未处理的高危或中危发现 - 所有测试均通过:
forge test -vvv