智能合约测试Skill testing

这个技能专注于使用Foundry框架对智能合约进行全面的测试,包括单元测试、模糊测试、fork测试和不变性测试,旨在确保区块链应用的安全性、可靠性和经济稳健性。关键词:智能合约测试,Foundry,模糊测试,fork测试,不变性测试,区块链开发,DeFi,Web3,安全审计,单元测试,智能合约安全。

智能合约 0 次安装 0 次浏览 更新于 3/24/2026

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