mcpField
T
testing

testing

github.com/austintgriffith测试与质量30238 次安装3 天前更新
代码仓库

关于 testing

面向 Solidity 开发者的智能合约测试指南,重点介绍 Foundry 单元、模糊、分叉和不变量测试,帮助检查边界情况、集成问题与经济属性。

主要功能

  1. 01Foundry 单元测试与断言
  2. 02模糊测试及输入约束
  3. 03基于真实协议状态的分叉测试
  4. 04跨随机调用序列验证不变量
  5. 05部署前测试与分析检查清单

适用场景

  1. 01检查合约数学运算和用户输入边界
  2. 02在分叉环境测试外部协议集成
  3. 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

评论

支持 Markdown