返回 SC 笔记
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 #最佳实践


今日目标

  1. 深入掌握 Foundry cheatcodes(prank, deal, expectRevert, expectEmit, warp, roll, label)
  2. 理解测试组织和 setUp 模式
  3. 编写 10+ 个 ERC20 测试用例(覆盖所有边界情况)
  4. 学习测试最佳实践(命名、结构、覆盖率)

核心概念

一、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 path30%正常流程
Edge cases30%零值、最大值、自转账
Revert cases25%各种失败场景
Event verification10%事件正确性
Gas checks5%性能回归检测

常见误区

  1. expectRevert 放错位置: 必须紧贴在 revert 调用之前,中间不能有其他操作
  2. expectEmit 参数顺序: (checkTopic1, checkTopic2, checkTopic3, checkData),不是事件参数
  3. 忘记 stopPrank: startPrank 后不 stopPrank,后续测试的 msg.sender 不对
  4. 测试函数不以 test 开头: Foundry 静默跳过,不报错
  5. 在 setUp 中 assert: setUp 中的 assert 失败会导致所有测试失败,不好定位问题

面试关联

面试题本课关联
"如何测试智能合约的权限控制?"vm.prank + expectRevert
"如何测试时间依赖的合约逻辑?"vm.warp + vm.roll
"ERC20 测试应该覆盖哪些场景?"正常、边界、revert、事件
"如何检测 Gas 回归?"forge snapshot + gas 断言
"如何测试合约的存储布局?"vm.load + vm.store

参考资源