智能合约测试:Foundry 单元与集成、Fork 主网、模糊与不变量、Gas 与升级验证

系统讲解智能合约测试的工程化实践:Foundry forge test 与 Hardhat 的单元/集成测试、cheatcodes 与断言技巧、Fork 主网测试复现真实依赖、模糊测试(fuzzing)与不变量测试(invariant testing)、Gas 快照与回归门禁、重放攻击防护验证、以及代理升级模式下的存储布局兼容性检查。

智能合约测试的赌注比任何软件都高:代码一旦部署就不可修改,一个漏洞可能瞬间掏空数亿美元。 2022 年的 Ronin 桥被盗 6.24 亿美元、Wormhole 被盗 3.26 亿美元、Euler Finance 被闪电贷攻击损失近 2 亿——这些事故的共同点是:单元测试全绿,但没有一条用例覆盖"攻击者在同一笔交易里先借后还再操纵预言机"这种组合路径。智能合约测试的难点,不是"测功能对不对",而是"测攻击者在极端条件下能不能突破不变量"。本文要回答的是:如何用 Foundry 的单元/集成测试、Fork 主网测试、模糊与不变量测试、Gas 与升级验证这五层,构建一套能防住真实攻击的合约测试体系。

智能合约还有一个传统软件没有的约束:每一次状态变更都要付 Gas,每一行代码都直接对应真金白银。 这使"Gas 测试"成为合约测试的独特一层——不仅关心对不对,还关心贵不贵;也使得"升级"成为高危操作,因为代理模式下存储布局一旦错位,就会读出垃圾数据。

一、智能合约测试的独特性

1.1 与传统后端测试的差异

维度传统后端智能合约测试影响
可变性可热修复、可回滚部署后不可改(除非代理)上线前必须测尽,回滚几乎不可能
执行成本几乎为零每步操作消耗 Gas需测 Gas 消耗与回归
攻击面网络、应用、数据全部在链上公开可读攻击者可完整分析合约
依赖数据库、服务可 mock依赖的其他合约在链上需 Fork 主网复现真实依赖
资产数据泄露直接资金损失不变量测试是刚需

1.2 测试层次

层次一:单元测试    —— 单个函数的输入输出、边界、回滚(revert)
层次二:集成测试    —— 多合约协作、代币转账、权限流转
层次三:Fork 测试   —— 连到主网分叉,用真实预言机/池子/DEX
层次四:模糊测试    —— 随机输入找边界;不变量测试穷举状态
层次五:非功能测试  —— Gas 快照、重放防护、升级存储兼容

二、单元与集成测试

2.1 Foundry:Solidity 原生测试

Foundry 用 Solidity 写测试,测试与合约同语言,速度快、支持 cheatcodes:

// test/Counter.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";

contract CounterTest is Test {
    Counter counter;
    address alice = address(0xA11CE);

    function setUp() public {
        counter = new Counter();
    }

    function test_Increment() public {
        counter.increment();
        assertEq(counter.number(), 1);
    }

    function test_RevertWhen_Unauthorized() public {
        vm.prank(alice);                       // 模拟 alice 调用
        vm.expectRevert("not owner");          // 断言回滚与消息
        counter.ownerIncrement();
    }

    function testFuzz_IncrementAnyTimes(uint8 times) public {
        for (uint256 i; i < times; i++) counter.increment();
        assertEq(counter.number(), times);     // 对任意 times 都成立
    }
}
forge test                       # 运行全部测试
forge test -vvv                  # 打印调用 trace
forge test --match-test Fuzz     # 只跑模糊测试
forge coverage                   # 覆盖率

2.2 cheatcodes:Foundry 的杀手锏

cheatcodes(以 vm. 开头)让测试能操纵区块链环境:

function test_TimeDependent() public {
    vm.warp(1_700_000_000);                 // 操纵 block.timestamp
    vm.roll(19_000_000);                    // 操纵 block.number
    counter.incrementWithCooldown();
    vm.warp(1_700_000_001);
    counter.incrementWithCooldown();        // 冷却期内第二次
}

function test_ImpersonateWhale() public {
    address whale = 0x28C6c06298d514Db089934071355E5743bf21d60;
    vm.prank(whale);                        // 冒充任意地址
    token.transfer(address(this), 1000e18);
}

function test_ExpectEmit() public {
    vm.expectEmit(true, true, false, true); // 断言事件被正确 emit
    emit Transfer(alice, bob, 100);
    token.transfer(bob, 100);
}

2.3 Hardhat:JS/TS 生态

团队更熟悉 JS 时,Hardhat 配合 ethers/viem 也很成熟:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Counter", () => {
  let counter, owner, alice;

  beforeEach(async () => {
    [owner, alice] = await ethers.getSigners();
    const Counter = await ethers.getContractFactory("Counter");
    counter = await Counter.deploy();
  });

  it("仅 owner 可调用受保护方法", async () => {
    await expect(counter.connect(alice).ownerIncrement())
      .to.be.revertedWith("not owner");
  });

  it("状态变化后余额与事件都正确", async () => {
    await expect(counter.increment())
      .to.emit(counter, "Incremented").withArgs(1);
    expect(await counter.number()).to.equal(1);
  });
});

ℹ️ Foundry vs Hardhat 的选择:Foundry 用 Solidity 写测试、原生支持模糊/不变量测试、速度快,适合纯合约项目;Hardhat 生态更广(插件、部署脚本、TS 类型),适合与前端/脚本混合的项目。很多团队两者并用——Foundry 跑核心逻辑与模糊测试,Hardhat 跑部署与集成。

三、Fork 主网测试

3.1 为什么需要 Fork

合约的真实依赖(Chainlink 预言机、Uniswap 池子、USDC 合约)在主网上已有确定状态。Mock 它们会失真——尤其是"攻击者用闪电贷操纵真实池子价格"这类路径,只有 Fork 真实状态才能复现:

// test/Fork.t.sol
contract ForkTest is Test {
    // 通过 RPC 分叉主网某个区块
    uint256 mainnetFork = vm.createFork(vm.envString("MAINNET_RPC_URL"), 18_500_000);

    function setUp() public {
        vm.selectFork(mainnetFork);          // 切到主网分叉
    }

    function test_SwapOnRealUniswap() public {
        // 使用主网上真实的 WETH/USDC 池
        IUniswapV2Pair pair = IUniswapV2Pair(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc);
        (uint112 r0, uint112 r1,) = pair.getReserves();
        assertGt(r0, 0, "池子应有储备");
        // 在这里测试你的合约与真实池子的交互
    }
}
export MAINNET_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/..."
forge test --match-contract ForkTest --fork-url $MAINNET_RPC_URL

3.2 Fork 测试的用例设计

function test_FlashLoanPriceManipulation() public {
    // 复现攻击:闪电贷巨额资金 → 操纵池子价格 → 触发你的合约错误定价
    uint256 loanAmount = 10_000_000e6;   // 1000 万 USDC
    vm.deal(address(this), 100 ether);

    // 1. 借入闪电贷
    // 2. 在真实池子大额 swap,把价格打偏
    // 3. 调用被攻击合约,观察是否被薅走资金
    // 4. 断言:合约应拒绝这笔交易,或价格未被操纵
    vm.expectRevert("price deviation too high");
    vulnerableContract.execute();
}

⚠️ Fork 测试的两个纪律:(1) 固定区块号——不指定区块号,分叉会随主网前进而变化,测试结果不稳定;用 vm.createFork(url, blockNumber) 锁定。(2) 别在 CI 里依赖公共 RPC——公共节点有速率限制且会漂移,用付费归档节点或本地缓存(--fork-block-number + 本地 fork 快照)。

3.3 Fork 的局限

Fork 测试覆盖不了的:
  · 未来才会发生的链上状态变化
  · 其他合约在同区块内的行为(需要精确复现整块交易)
  · 跨链消息与 L2 → L1 的延迟

因此 Fork 测试是"集成测试"的一种,不能替代单元测试与不变量测试。

四、模糊与不变量测试

4.1 模糊测试:随机输入找边界

function testFuzz_TransferNeverExceedsBalance(
    address to, uint256 amount
) public {
    vm.assume(to != address(0));           // 排除无效输入
    uint256 balance = token.balanceOf(address(this));

    if (amount > balance) {
        vm.expectRevert();                  // 超额必须回滚
        token.transfer(to, amount);
    } else {
        token.transfer(to, amount);
        assertEq(token.balanceOf(to), amount);
    }
}
# Foundry 默认每个 fuzz 用例跑 256 次随机输入
forge test --fuzz-runs 10000
# 失败时输出使测试失败的具体输入:
# [FAIL. Reason: assertion failed]
#   counterexample: calldata=0x..., args=[1234]

4.2 不变量测试:穷举状态转换

不变量测试(invariant testing)是合约测试的重武器——它让 fuzzer 随机调用合约的所有函数,然后断言某些性质永远成立:

// test/Invariant.t.sol
contract VaultInvariantTest is Test {
    Vault vault;
    VaultHandler handler;

    function setUp() public {
        vault = new Vault();
        handler = new VaultHandler(vault);
        // 让 fuzzer 只调用 handler 的受控入口
        targetContract(address(handler));
    }

    // 核心不变量:合约持有的资产 >= 所有用户存款总和
    function invariant_Solvency() public view {
        assertGe(
            address(vault).balance,
            vault.totalDeposits(),
            "不变量违反:合约资不抵债"
        );
    }

    // 不变量:总供应量守恒
    function invariant_TotalSupplyConserved() public view {
        assertEq(vault.totalSupply(), handler.sumOfBalances());
    }
}
forge test --match-test invariant --invariant-runs 500
# Fuzzer 随机调用 deposit/withdraw/transfer 的组合,
# 一旦 Solvency 被打破,立即输出导致失败的函数调用序列

ℹ️ 不变量测试为什么比用例测试强:用例测试是"我列举的攻击路径",不变量测试是"fuzzer 帮我找的攻击路径"。前者受限于人的想象力,后者能发现"先 withdraw 再 deposit 再 transfer"这种没人想到的组合。DeFi 协议(如 Aave、Compound)都把不变量测试作为核心防线。

4.3 属性表达:从"用例"到"不变式"

把业务规则写成不变量,是合约测试的思维方式转变:

用例思维:  转账 100 → 断言 A 减少 100、B 增加 100
不变式思维:任何操作序列后,Σ(所有余额) == totalSupply
           任何操作序列后,合约余额 >= 用户可取总额
           任何操作序列后,owner 权限不变

五、Gas 与重放

5.1 Gas 快照与回归门禁

Gas 优化是合约的硬需求,测试要固化 Gas 基线并在超支时报警:

function test_GasIncrement() public {
    uint256 gasBefore = gasleft();
    counter.increment();
    uint256 gasUsed = gasBefore - gasleft();
    assertLt(gasUsed, 30_000, "increment 超过 Gas 预算");
}
# Foundry 生成 Gas 快照
forge snapshot
# 输出 .gas-snapshot:
# CounterTest:test_Increment() (gas: 24318)
# VaultTest:test_Deposit() (gas: 87102)

# CI 中对比,超支则失败
forge snapshot --check .gas-snapshot

⚠️ Gas 回归要设"容忍阈值"而非精确相等。编译器版本、优化器设置、Solidity 补丁都可能让 Gas 微变。forge snapshot --check 支持 --tolerance,对超支 5% 以上才报警更实用。

5.2 重放攻击防护

签名(如 EIP-712)必须包含 nonce 与 chainId,否则会被重放:

function test_SignatureCannotBeReplayed() public {
    bytes memory sig = _sign(ownerKey, msgHash, nonce);

    // 第一次使用成功
    vault.executeWithSig(action, nonce, sig);
    assertEq(vault.executedCount(), 1);

    // 第二次用同一签名重放,必须失败
    vm.expectRevert("nonce already used");
    vault.executeWithSig(action, nonce, sig);
}

function test_SignatureBoundToChainId() public {
    // 在 fork 上签名,切到另一条链应失效
    uint256 forkB = vm.createFork(vm.envString("OTHER_RPC_URL"));
    vm.selectFork(forkB);
    vm.expectRevert("invalid chainId");
    vault.executeWithSig(action, nonce, sig);
}

六、升级与代理模式验证

6.1 代理模式回顾

可升级合约用代理(Proxy)把状态存在代理合约里,逻辑存在实现合约里:

用户 → Proxy(存 storage,含 implementation 地址)
              ↓ delegatecall
           Implementation V1(只有逻辑,无自己的 storage)

6.2 存储布局兼容性检查

升级最容易出的致命 bug 是存储槽错位——新实现改了变量顺序或类型,导致读出的状态全是垃圾:

// V1
contract VaultV1 {
    address owner;        // slot 0
    uint256 totalDeposits;// slot 1
}

// V2 错误写法:在中间插入新变量 → 全部错位!
contract VaultV2Bad {
    address owner;        // slot 0
    uint256 feeRate;      // slot 1 ← 挤走了 totalDeposits
    uint256 totalDeposits;// slot 2 ← 读到的是 feeRate 的值
}

// V2 正确写法:只在末尾追加
contract VaultV2Good {
    address owner;        // slot 0
    uint256 totalDeposits;// slot 1
    uint256 feeRate;      // slot 2 ← 追加在末尾
}
# 用 OpenZeppelin 的升级检查插件自动验证
npx @openzeppelin/upgrades-core validate
# 输出:
# ✔ Storage layout is compatible
# 或
# ✖ Storage layout changed: variable 'totalDeposits' moved from slot 1 to slot 2

6.3 升级测试用例

function test_UpgradePreservesState() public {
    // V1 部署并写入状态
    VaultV1 v1 = new VaultV1();
    ERC1967Proxy proxy = new ERC1967Proxy(address(v1), "");
    VaultV1(address(proxy)).deposit{value: 1 ether}();
    uint256 depositsBefore = VaultV1(address(proxy)).totalDeposits();

    // 升级到 V2
    VaultV2 v2 = new VaultV2();
    VaultV1(address(proxy)).upgradeTo(address(v2));

    // 断言:状态完好,新功能可用
    assertEq(VaultV2(address(proxy)).totalDeposits(), depositsBefore, "状态丢失");
    VaultV2(address(proxy)).setFeeRate(100);
    assertEq(VaultV2(address(proxy)).feeRate(), 100);
}

function test_UpgradeOnlyByAuthorized() public {
    vm.prank(alice);
    vm.expectRevert("not authorized");
    VaultV1(address(proxy)).upgradeTo(address(newImpl));
}

⚠️ 代理升级必须同时验证三件事:(1) 存储布局兼容(用工具自动检查);(2) 升级后原状态完好(读回旧值断言);(3) 升级权限受控(非授权地址无法升级)。只测其一都是不完整的——历史上多起事故就源于"忘了测权限"或"存储错位"。

七、CI 集成与常见陷阱

7.1 合约测试流水线

jobs:
  contract-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: foundry-rs/foundry-toolchain@v1
      - run: forge fmt --check              # 格式检查
      - run: forge build --sizes            # 合约大小(EIP-170 限制 24KB)
      - run: forge test -vvv                # 单元 + 集成
      - run: forge test --fuzz-runs 10000   # 模糊测试(更多轮次)
      - run: forge test --match-test invariant --invariant-runs 500
      - run: forge snapshot --check         # Gas 回归门禁
      - run: forge coverage --report summary
      - name: Fork tests
        env:
          MAINNET_RPC_URL: ${{ secrets.ALCHEMY_KEY }}
        run: forge test --match-contract Fork --fork-block-number 18500000
      - run: npx @openzeppelin/upgrades-core validate   # 存储布局

7.2 常见陷阱对照表

陷阱现象对策
只测 happy path攻击路径漏测不变量测试 + Fork 攻击复现
Fork 不锁区块测试结果漂移--fork-block-number 锁定
依赖公共 RPCCI 限流/超时付费归档节点 + 本地缓存
签名无 nonce/chainId重放攻击断言重放必失败
代理存储错位升级后状态乱码OpenZeppelin validate
升级不测权限任何人可换实现非授权升级必回滚
Gas 精确断言编译器一变就红--tolerance 容忍阈值
覆盖率当质量100% 覆盖仍有漏洞覆盖率 + 不变量 + 审计

ℹ️ 覆盖率在合约里尤其有欺骗性:100% 行覆盖意味着每行都被执行过,但不代表每种状态组合都被测过。Euler Finance 攻击的代码路径很可能被覆盖了,但"闪电贷操纵预言机后的清算逻辑"这个组合没被覆盖。所以合约测试的完成标准不是覆盖率,而是不变量集合是否覆盖了所有"钱不能凭空产生"的规则。

八、总结

智能合约测试的核心,是把"代码不可变、资金即赌注"这两个约束转化成测试强度:单元测试守住函数边界,集成测试守住多合约协作,Fork 测试用真实主网状态复现攻击路径,模糊与不变量测试让 fuzzer 替你想出没人想到的组合,Gas 快照守住成本,代理升级验证守住状态兼容。延伸阅读可参考 https://plumephp.com/property-based-testing/ 了解属性断言与不变式的通用方法论,https://plumephp.com/fuzz-testing/ 了解模糊测试的输入生成与覆盖率引导机制,https://plumephp.com/security-testing-devsecops/ 了解安全测试如何左移到 CI 流水线。一句话收尾:在合约世界里,测试不是"提高质量的手段",而是"防止破产的最后一道闸门"——把不变量写全、把攻击路径 Fork 复现、把升级兼容自动化,才配得上"部署后不可改"这份重量。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「testing」更多文章

  1. 国际化与本地化测试:文案抽取、复数与性别规则、RTL 布局、时区与伪本地化
  2. 并发竞态测试:数据竞争检测、确定性复现、TSan/Loom 与调度扰动
  3. 实时通信与 WebSocket 测试:连接生命周期、消息时序、断线重连与并发压测