智能合约测试的赌注比任何软件都高:代码一旦部署就不可修改,一个漏洞可能瞬间掏空数亿美元。 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 锁定 |
| 依赖公共 RPC | CI 限流/超时 | 付费归档节点 + 本地缓存 |
| 签名无 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 复现、把升级兼容自动化,才配得上"部署后不可改"这份重量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。