SC Day 27
Solidity/Foundry 测试深入 - Cheatcodes + 完整 ERC20 测试套件
### 一、Foundry Cheatcodes 详解
2026-04-27
第二阶段:框架实战 (Day 25-30)Foundry测试cheatcodesERC20最佳实践
日期: 2026-04-27 方向: Solidity / Foundry 阶段: 第二阶段:框架实战 (Day 25-30) 标签: #Foundry #测试 #cheatcodes #ERC20 #最佳实践
今日目标
- 深入掌握 Foundry cheatcodes(prank, deal, expectRevert, expectEmit, warp, roll, label)
- 理解测试组织和 setUp 模式
- 编写 10+ 个 ERC20 测试用例(覆盖所有边界情况)
- 学习测试最佳实践(命名、结构、覆盖率)
核心概念
一、Foundry Cheatcodes 详解
Cheatcodes 是 Foundry 独有的测试工具,通过 vm 对象调用,可以操纵 EVM 的各种状态。这些在真实合约中不可用,只在测试环境中有效。
1.1 身份模拟 (Identity)
import "forge-std/Test.sol";
contract IdentityCheatcodes is Test {
// vm.prank(address) — 下一次调用以指定地址身份执行
function test_PrankSingleCall() public {
address alice = makeAddr("alice");
vm.prank(alice);
// 下一行的 msg.sender 是 alice
// someContract.doSomething();
// 之后 msg.sender 恢复为测试合约地址
}
// vm.startPrank(address) / vm.stopPrank() — 持续身份模拟
function test_PrankMultipleCalls() public {
address alice = makeAddr("alice");
vm.startPrank(alice);
// 以下所有调用的 msg.sender 都是 alice
// someContract.doFirst();
// someContract.doSecond();
// someContract.doThird();
vm.stopPrank();
// 恢复正常
}
// vm.prank(sender, origin) — 同时设置 msg.sender 和 tx.origin
function test_PrankWithOrigin() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
vm.prank(alice, bob);
// msg.sender = alice, tx.origin = bob
}
}
1.2 余额操纵 (Balance)
contract BalanceCheatcodes is Test {
// vm.deal(address, amount) — 设置 ETH 余额
function test_DealETH() public {
address alice = makeAddr("alice");
vm.deal(alice, 100 ether);
assertEq(alice.balance, 100 ether);
// 可以设置为任意值
vm.deal(alice, 0); // 清空余额
assertEq(alice.balance, 0);
}
// deal(address token, address to, uint256 amount) — 设置 ERC20 余额
// 注意: 这是 forge-std 的辅助函数,不是 vm cheatcode
function test_DealERC20() public {
address alice = makeAddr("alice");
address usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // USDC mainnet
// 需要在 fork 测试中使用
// deal(usdc, alice, 10000 * 1e6); // 给 alice 10000 USDC
}
// hoax(address, amount) — prank + deal 的组合
function test_Hoax() public {
address alice = makeAddr("alice");
hoax(alice, 10 ether);
// 等价于:
// vm.deal(alice, 10 ether);
// vm.prank(alice);
}
}
1.3 期望与断言 (Expectations)
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ExpectationCheatcodes is Test {
// vm.expectRevert() — 期望下一次调用 revert
function test_ExpectRevert() public {
vm.expectRevert(); // 不检查 revert 消息
// revertingFunction();
vm.expectRevert("Insufficient balance"); // 检查 revert 字符串
// revertingFunction();
vm.expectRevert(
abi.encodeWithSelector(
InsufficientBalance.selector,
100, // required
50 // available
)
); // 检查自定义错误
// revertingFunction();
}
// vm.expectEmit() — 期望发出事件
function test_ExpectEmit() public {
// 参数: checkTopic1, checkTopic2, checkTopic3, checkData
// Topic 对应 indexed 参数, Data 对应非 indexed 参数
// 检查所有字段
vm.expectEmit(true, true, false, true);
emit IERC20.Transfer(address(this), address(1), 100);
// token.transfer(address(1), 100);
// 只检查 from 和 to (不检查 amount)
vm.expectEmit(true, true, false, false);
emit IERC20.Transfer(address(this), address(1), 0); // amount 随便填
// token.transfer(address(1), 100);
}
// vm.expectCall() — 期望对某个合约发起调用
function test_ExpectCall() public {
address target = makeAddr("target");
bytes memory callData = abi.encodeWithSignature("foo(uint256)", 42);
vm.expectCall(target, callData);
// someContract.doSomethingThatCallsTarget();
}
error InsufficientBalance(uint256 required, uint256 available);
}
1.4 时间操纵 (Time)
contract TimeCheatcodes is Test {
// vm.warp(timestamp) — 设置 block.timestamp
function test_Warp() public {
vm.warp(1700000000); // 设置为特定时间戳
assertEq(block.timestamp, 1700000000);
// 常用: 向前推进时间
uint256 currentTime = block.timestamp;
vm.warp(currentTime + 1 days);
assertEq(block.timestamp, currentTime + 1 days);
// 向前推进 7 天
skip(7 days); // forge-std 辅助函数
assertEq(block.timestamp, currentTime + 8 days);
// 向后回退
rewind(1 days); // forge-std 辅助函数
}
// vm.roll(blockNumber) — 设置 block.number
function test_Roll() public {
vm.roll(18000000);
assertEq(block.number, 18000000);
// 推进区块
vm.roll(block.number + 100);
}
// vm.fee(baseFee) — 设置 block.basefee
function test_Fee() public {
vm.fee(20 gwei);
assertEq(block.basefee, 20 gwei);
}
}
1.5 标签与调试 (Debugging)
contract DebuggingCheatcodes is Test {
// vm.label(address, name) — 给地址命名 (trace 中显示)
function test_Label() public {
address alice = makeAddr("alice"); // 自动 label
address router = address(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
vm.label(router, "UniswapV2Router");
// 现在 trace 中会显示 "UniswapV2Router" 而不是一长串地址
}
// console.log — 调试输出
function test_ConsoleLog() public pure {
console.log("Simple message");
console.log("Value:", 42);
console.log("Address:", address(0x1234));
console.log("Bool:", true);
// 最多 4 个参数
console.log("a:", 1, "b:", 2);
// console2.log 支持格式化
// console2.log("Balance: %d ETH", balance);
}
}
1.6 存储操纵 (Storage)
contract StorageCheatcodes is Test {
// vm.store(address, slot, value) — 直接写存储
function test_Store() public {
address target = address(0x1234);
bytes32 slot = bytes32(uint256(0));
bytes32 value = bytes32(uint256(999));
vm.store(target, slot, value);
bytes32 stored = vm.load(target, slot);
assertEq(stored, value);
}
// vm.load(address, slot) — 直接读存储
function test_Load() public view {
address target = address(this);
bytes32 slot0 = vm.load(target, bytes32(uint256(0)));
console.logBytes32(slot0);
}
}
二、测试组织最佳实践
2.1 测试命名规范
// 命名模式: test_[功能]_[场景]_[期望结果]
// 或: test_[Action][Context][Expected]
function test_Transfer_WithSufficientBalance_UpdatesBalances() public {}
function test_Transfer_ToZeroAddress_Reverts() public {}
function test_Mint_WhenCallerIsOwner_IncreasesSupply() public {}
function test_Mint_WhenCallerIsNotOwner_Reverts() public {}
// Revert 测试命名
function test_RevertWhen_TransferExceedsBalance() public {}
function test_RevertIf_CallerIsNotOwner() public {}
2.2 测试文件组织
test/
├── unit/ # 单元测试
│ ├── Token.t.sol # Token 合约测试
│ └── Vault.t.sol # Vault 合约测试
├── integration/ # 集成测试
│ └── TokenVault.t.sol # 跨合约交互测试
├── invariant/ # 不变量测试
│ └── Token.invariant.t.sol
├── fork/ # Fork 测试
│ └── UniswapIntegration.t.sol
└── helpers/ # 测试辅助
├── BaseTest.sol # 基础测试合约
└── Actors.sol # 测试角色
2.3 基础测试合约
// test/helpers/BaseTest.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
abstract contract BaseTest is Test {
// 通用测试角色
address public deployer;
address public admin;
address public alice;
address public bob;
address public carol;
address public attacker;
// 通用常量
uint256 public constant ONE_ETH = 1 ether;
uint256 public constant ONE_DAY = 1 days;
function _setupActors() internal {
deployer = makeAddr("deployer");
admin = makeAddr("admin");
alice = makeAddr("alice");
bob = makeAddr("bob");
carol = makeAddr("carol");
attacker = makeAddr("attacker");
// 给每个角色一些 ETH
vm.deal(deployer, 1000 ether);
vm.deal(admin, 100 ether);
vm.deal(alice, 100 ether);
vm.deal(bob, 100 ether);
vm.deal(carol, 100 ether);
vm.deal(attacker, 100 ether);
}
}
代码实战
完整 ERC20 测试套件(10+ 测试用例)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MomoToken.sol";
/**
* @title MomoTokenFullTest
* @notice 完整的 ERC20 测试套件,覆盖所有核心功能和边界情况
*/
contract MomoTokenFullTest is Test {
MomoToken public token;
address public owner;
address public alice;
address public bob;
address public carol;
uint256 public constant INITIAL_SUPPLY = 100_000_000 * 1e18;
uint256 public constant MAX_SUPPLY = 1_000_000_000 * 1e18;
// ============ Events (需要重新声明以在 expectEmit 中使用) ============
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event Minted(address indexed to, uint256 amount);
// ============ setUp ============
function setUp() public {
owner = makeAddr("owner");
alice = makeAddr("alice");
bob = makeAddr("bob");
carol = makeAddr("carol");
vm.prank(owner);
token = new MomoToken(owner, INITIAL_SUPPLY);
}
// ============ 1. 部署 & 初始状态测试 ============
function test_Deployment_CorrectMetadata() public view {
assertEq(token.name(), "Momo Token");
assertEq(token.symbol(), "MOMO");
assertEq(token.decimals(), 18);
}
function test_Deployment_CorrectInitialState() public view {
assertEq(token.totalSupply(), INITIAL_SUPPLY);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
assertEq(token.owner(), owner);
assertEq(token.MAX_SUPPLY(), MAX_SUPPLY);
}
function test_Deployment_RevertWhen_InitialSupplyExceedsMax() public {
vm.expectRevert("Exceeds max supply");
new MomoToken(owner, MAX_SUPPLY + 1);
}
// ============ 2. Transfer 测试 ============
function test_Transfer_Success() public {
uint256 amount = 1000 * 1e18;
vm.prank(owner);
assertTrue(token.transfer(alice, amount));
assertEq(token.balanceOf(alice), amount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
}
function test_Transfer_EntireBalance() public {
vm.prank(owner);
token.transfer(alice, INITIAL_SUPPLY);
assertEq(token.balanceOf(owner), 0);
assertEq(token.balanceOf(alice), INITIAL_SUPPLY);
}
function test_Transfer_ZeroAmount() public {
vm.prank(owner);
assertTrue(token.transfer(alice, 0));
assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
assertEq(token.balanceOf(alice), 0);
}
function test_Transfer_EmitsEvent() public {
uint256 amount = 500 * 1e18;
vm.expectEmit(true, true, false, true);
emit Transfer(owner, alice, amount);
vm.prank(owner);
token.transfer(alice, amount);
}
function test_Transfer_RevertWhen_InsufficientBalance() public {
vm.prank(alice); // alice 余额为 0
vm.expectRevert();
token.transfer(bob, 1);
}
function test_Transfer_RevertWhen_ToZeroAddress() public {
vm.prank(owner);
vm.expectRevert();
token.transfer(address(0), 100);
}
function test_Transfer_ToSelf() public {
uint256 balanceBefore = token.balanceOf(owner);
vm.prank(owner);
token.transfer(owner, 100 * 1e18);
// 余额不变 (转给自己)
assertEq(token.balanceOf(owner), balanceBefore);
}
// ============ 3. Approve & Allowance 测试 ============
function test_Approve_Success() public {
uint256 amount = 1000 * 1e18;
vm.prank(owner);
assertTrue(token.approve(alice, amount));
assertEq(token.allowance(owner, alice), amount);
}
function test_Approve_EmitsEvent() public {
uint256 amount = 500 * 1e18;
vm.expectEmit(true, true, false, true);
emit Approval(owner, alice, amount);
vm.prank(owner);
token.approve(alice, amount);
}
function test_Approve_OverwritePreviousApproval() public {
vm.startPrank(owner);
token.approve(alice, 1000);
token.approve(alice, 2000); // 覆盖
vm.stopPrank();
assertEq(token.allowance(owner, alice), 2000);
}
function test_Approve_MaxUint() public {
vm.prank(owner);
token.approve(alice, type(uint256).max);
assertEq(token.allowance(owner, alice), type(uint256).max);
}
// ============ 4. TransferFrom 测试 ============
function test_TransferFrom_Success() public {
uint256 amount = 1000 * 1e18;
// owner approve alice
vm.prank(owner);
token.approve(alice, amount);
// alice transferFrom owner to bob
vm.prank(alice);
assertTrue(token.transferFrom(owner, bob, amount));
assertEq(token.balanceOf(bob), amount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
assertEq(token.allowance(owner, alice), 0);
}
function test_TransferFrom_PartialAllowance() public {
vm.prank(owner);
token.approve(alice, 1000 * 1e18);
vm.prank(alice);
token.transferFrom(owner, bob, 600 * 1e18);
assertEq(token.allowance(owner, alice), 400 * 1e18); // 剩余 allowance
}
function test_TransferFrom_MaxAllowanceNotDecreased() public {
// max uint256 allowance 不应该减少 (ERC20 惯例)
vm.prank(owner);
token.approve(alice, type(uint256).max);
vm.prank(alice);
token.transferFrom(owner, bob, 1000 * 1e18);
// allowance 应该保持不变 (无限授权)
assertEq(token.allowance(owner, alice), type(uint256).max);
}
function test_TransferFrom_RevertWhen_InsufficientAllowance() public {
vm.prank(owner);
token.approve(alice, 100);
vm.prank(alice);
vm.expectRevert();
token.transferFrom(owner, bob, 200); // 超过 allowance
}
function test_TransferFrom_RevertWhen_NotApproved() public {
vm.prank(alice);
vm.expectRevert();
token.transferFrom(owner, bob, 1); // 没有 approve
}
// ============ 5. Mint 测试 ============
function test_Mint_ByOwner() public {
uint256 mintAmount = 1000 * 1e18;
vm.expectEmit(true, false, false, true);
emit Minted(alice, mintAmount);
vm.prank(owner);
token.mint(alice, mintAmount);
assertEq(token.balanceOf(alice), mintAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
}
function test_Mint_UpToMaxSupply() public {
uint256 remaining = MAX_SUPPLY - INITIAL_SUPPLY;
vm.prank(owner);
token.mint(alice, remaining);
assertEq(token.totalSupply(), MAX_SUPPLY);
}
function test_Mint_RevertWhen_ExceedsMaxSupply() public {
uint256 remaining = MAX_SUPPLY - INITIAL_SUPPLY;
vm.prank(owner);
vm.expectRevert("Exceeds max supply");
token.mint(alice, remaining + 1);
}
function test_Mint_RevertWhen_CallerNotOwner() public {
vm.prank(alice);
vm.expectRevert();
token.mint(alice, 100);
}
// ============ 6. Burn 测试 ============
function test_Burn_Success() public {
uint256 burnAmount = 1000 * 1e18;
vm.prank(owner);
token.burn(burnAmount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - burnAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount);
}
function test_Burn_RevertWhen_ExceedsBalance() public {
vm.prank(alice); // alice 余额为 0
vm.expectRevert();
token.burn(1);
}
// ============ 7. 边界 & 安全测试 ============
function test_MultipleTransfers_BalancesConsistent() public {
// owner -> alice -> bob -> carol 连续转账
vm.prank(owner);
token.transfer(alice, 3000 * 1e18);
vm.prank(alice);
token.transfer(bob, 2000 * 1e18);
vm.prank(bob);
token.transfer(carol, 1000 * 1e18);
// 验证余额一致性
uint256 total = token.balanceOf(owner)
+ token.balanceOf(alice)
+ token.balanceOf(bob)
+ token.balanceOf(carol);
assertEq(total, INITIAL_SUPPLY); // 总供应量不变
assertEq(token.balanceOf(alice), 1000 * 1e18);
assertEq(token.balanceOf(bob), 1000 * 1e18);
assertEq(token.balanceOf(carol), 1000 * 1e18);
}
// ============ 8. Gas 测试 ============
function test_Transfer_GasUsage() public {
vm.prank(owner);
uint256 gasBefore = gasleft();
token.transfer(alice, 1000);
uint256 gasUsed = gasBefore - gasleft();
console.log("Transfer gas used:", gasUsed);
// 一般 ERC20 transfer 约 ~51000 gas (首次) / ~34000 gas (非首次)
assertLt(gasUsed, 60000); // 上限检查
}
}
关键要点总结
Cheatcodes 速查表
| Cheatcode | 功能 | 常用场景 |
|---|---|---|
vm.prank(addr) | 模拟身份(单次) | 测试权限控制 |
vm.startPrank(addr) | 模拟身份(持续) | 连续操作 |
vm.deal(addr, amount) | 设置 ETH 余额 | 准备测试环境 |
vm.expectRevert() | 期望 revert | 负面测试 |
vm.expectEmit(...) | 期望事件 | 验证事件发出 |
vm.warp(timestamp) | 设置时间 | 测试时间锁 |
vm.roll(blockNumber) | 设置区块号 | 测试区块相关逻辑 |
vm.label(addr, name) | 地址标签 | 调试 trace |
vm.store(addr, slot, val) | 写存储 | 直接操纵状态 |
vm.load(addr, slot) | 读存储 | 验证存储状态 |
makeAddr(name) | 创建标签地址 | 测试角色 |
hoax(addr, amount) | prank + deal | 快捷方式 |
测试覆盖目标
| 测试类型 | 占比 | 说明 |
|---|---|---|
| Happy path | 30% | 正常流程 |
| Edge cases | 30% | 零值、最大值、自转账 |
| Revert cases | 25% | 各种失败场景 |
| Event verification | 10% | 事件正确性 |
| Gas checks | 5% | 性能回归检测 |
常见误区
- expectRevert 放错位置: 必须紧贴在 revert 调用之前,中间不能有其他操作
- expectEmit 参数顺序:
(checkTopic1, checkTopic2, checkTopic3, checkData),不是事件参数 - 忘记 stopPrank: startPrank 后不 stopPrank,后续测试的 msg.sender 不对
- 测试函数不以 test 开头: Foundry 静默跳过,不报错
- 在 setUp 中 assert: setUp 中的 assert 失败会导致所有测试失败,不好定位问题
面试关联
| 面试题 | 本课关联 |
|---|---|
| "如何测试智能合约的权限控制?" | vm.prank + expectRevert |
| "如何测试时间依赖的合约逻辑?" | vm.warp + vm.roll |
| "ERC20 测试应该覆盖哪些场景?" | 正常、边界、revert、事件 |
| "如何检测 Gas 回归?" | forge snapshot + gas 断言 |
| "如何测试合约的存储布局?" | vm.load + vm.store |