W3
合约测试中级预计 45-60 分钟10 个步骤28 次学习

合约测试:单元测试、模糊测试与主网分叉测试

建立专业的合约测试体系:理解为什么不可篡改、直接管理资金的合约把测试变成生命线;掌握测试金字塔(单元 / 集成 / 模糊 / 不变量 / 分叉测试)各自覆盖什么;用 Hardhat + Mocha/Chai 写出第一个测试,用 fixture 标准化部署快照;断言事件、revert 与自定义错误;切换到 Foundry 用 Solidity 写高速测试并使用 prank / warp / roll 等 cheatcode;用 fuzz 让框架自动生成成千上万组输入找边界;用 fork 主网与真实 DeFi 协议交互测试;用 solidity-coverage 检查覆盖率、hardhat-gas-reporter 监控 Gas;最后把测试接入 GitHub Actions 持续集成。

合约测试:单元测试、模糊测试与主网分叉测试
⚠️
操作前必读

测试通过不等于合约安全:测试只能证明「你想到的场景没问题」,无法证明没有未知漏洞。但反过来,没有测试的合约几乎必然出问题。请把「所有权限函数的越权调用、所有边界数值(0、最大值、小数精度)、所有失败路径的回滚」当作最低测试标准,主网部署前测试覆盖率应接近 100%。

01
1. 测试的理念与分层

为什么合约测试是生命线而不是走流程

登录记录进度

普通软件出 bug 可以发版热修复,智能合约不行:代码部署后字节码不可更改,合约里锁着的是真金白银,且攻击者和你看到的是同一份源码。历史上单笔损失上亿美元的事故,很多事后复盘都是「某个组合场景没测到」。因此合约开发的时间分配通常是写代码 30%、测试与审计 70%。

一套合格的测试要回答四个问题:

  1. 正常路径:每个函数在合法输入下返回正确结果、状态正确变化。
  2. 权限边界:非 owner / 非授权角色调用管理函数是否必然 revert。
  3. 边界数值:0、1、极大值、精度截断、首次操作(份额为 0 时的除法)是否正确。
  4. 失败路径:错误信息是否准确、失败时状态是否整体回滚没有脏数据。
为什么合约测试是生命线而不是走流程
为什么合约测试是生命线而不是走流程|界面示意 · hardhat.org · 采集于 2026-09-18
💡
小技巧:

把每一条 require / revert 都对应至少一个「应当失败」的测试用例,这是审计师检查测试质量时最先看的点。

02
1. 测试的理念与分层

测试金字塔:五种测试各管什么

登录记录进度

合约测试按范围从窄到宽分五层: - 单元测试(Unit):针对单个函数 / 单个合约,隔离依赖,数量最多、跑得最快。 - 集成测试(Integration):多个合约协作的完整流程,如「approve → DEX 卖出 → 收到 ETH」整条链路。

  • 模糊测试(Fuzz):不手写输入,让框架随机生成海量数值组合(含极端值),特别适合发现算术边界漏洞。 - 不变量测试(Invariant):声明「无论怎么操作,系统总余额必须守恒」这类必须永远成立的性质,让框架随机编排操作序列去打破它。

  • 分叉测试(Fork):把以太坊主网某一时刻的真实状态复制到本地,与已经上线的 Uniswap、Aave 等真实协议交互,验证集成兼容性。

金字塔的含义是:底层单元测试数量最大、执行最快;越往上数量越少、环境越重。

测试金字塔:五种测试各管什么
测试金字塔:五种测试各管什么|界面示意 · hardhat.org · 采集于 2026-09-18
03
2. Hardhat 测试基础

Hardhat 第一个测试:describe / it / expect

登录记录进度

Hardhat 用 JavaScript/TypeScript 生态的 Mocha + Chai,并通过 hardhat-toolbox 集成了 ethers 测试辅助。在 test/MyToken.js 中: javascript const { expect } = require("chai"); describe("MyToken", function () { async function deploy() { const [owner, user] = await ethers.getSigners(); const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy(); return { token, owner, user }; } it("部署者可以 mint", async function () { const { token, owner } = await deploy(); await token.mint(owner.address, 1000); expect(await token.balanceOf(owner.address)).to.equal(1000); }); }); 执行 npx hardhat test,每个 it 是一条用例,绿勾表示通过。

Hardhat 每次测试都在全新的内存链上运行,用例之间互不污染。

Hardhat 第一个测试:describe / it / expect
Hardhat 第一个测试:describe / it / expect|界面示意 · book.getfoundry.sh · 采集于 2026-09-18
💡
小技巧:

Hardhat 还内置 console.log——在 Solidity 里写 import "hardhat/console.sol"; console.log(x); 就能在测试终端打印链上变量,调试神器。

04
2. Hardhat 测试基础

用 fixture 让测试又快又稳

登录记录进度

如果每个用例都重新部署一遍合约,测试套件会越来越慢。标准优化是使用 fixture(测试夹具):第一次执行部署函数时,hardhat-network-helpers 会对链状态拍快照;之后每个用例只需 evm_revert 回滚到快照,毫秒级恢复到「刚部署完」的干净状态。

javascript const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers"); async function deployFixture() { /* 部署 token、做授权准备 */ return {...}; it("用例 A", async () => { const { token, user } = await loadFixture(deployFixture); ... }); fixture 里应完成所有用例的公共准备(部署合约、铸造初始代币、建立授权、添加流动性),单个用例只写自己要验证的差异。

这样既快,又避免用例之间因为执行顺序产生隐式依赖。

用 fixture 让测试又快又稳
用 fixture 让测试又快又稳|界面示意 · book.getfoundry.sh · 采集于 2026-09-18
🚫
避坑提醒:

不要依赖用例执行顺序传状态——并行或乱序执行时这类测试会随机失败,所有前置条件都应在 fixture 或用例内部显式准备。

05
2. Hardhat 测试基础

断言事件与回滚:测试安全逻辑的两个核心动作

登录记录进度

资金类合约的两大测试重点: 断言事件——关键操作必须发出正确事件: javascript await expect(token.mint(user, 500)) .to.emit(token, "Transfer").withArgs(ethers.ZeroAddress, user.address, 500n); (mint 的 from 约定为零地址。

断言回滚——越权与非法输入必须 revert: javascript // 普通用户调用 onlyOwner 函数应失败 await expect(token.connect(user).mint(user.address, 1)) .to.be.revertedWithCustomError(token, "OwnableUnauthorizedAccount"); // 旧版字符串错误用 revertedWith("not owner") 还要覆盖:余额不足转账回滚、授权额度不足 transferFrom 回滚、向零地址转账的行为。

自定义错误(custom error)省 Gas 但测试要用 revertedWithCustomError 匹配。

断言事件与回滚:测试安全逻辑的两个核心动作
断言事件与回滚:测试安全逻辑的两个核心动作|界面示意 · book.getfoundry.sh · 采集于 2026-09-18
💡
小技巧:

安全审计有个粗判标准:统计测试文件里 expect 与合约里 require 的比例,revert 路径测试严重不足的项目,审计评级会被直接下调。

06
3. Foundry 与进阶测试手法

Foundry 测试:直接用 Solidity 写测试

登录记录进度

Foundry 把测试也变成 Solidity:测试文件以 .t.sol 结尾,测试合约继承 forge-std/Test.sol,以 test 开头、参数为空的 external 函数会被自动执行:

contract MyTokenTest is Test {
  MyToken token; address user = makeAddr("user");
  function setUp() public { token = new MyToken(); }
  function test_OwnerCanMint() public {
    token.mint(user, 1000);
    assertEq(token.balanceOf(user), 1000);
  }
  function test_RevertWhen_NotOwner() public {
    vm.prank(user);                       // cheatcode:下一次调用伪装成 user
    vm.expectRevert();
    token.mint(user, 1);
  }
}

执行 forge test -vvv(v 越多输出越详细,失败时会打印调用轨迹)。断言库提供 assertEq / assertApproxEqAbs 等,setUp() 相当于 beforeEach。

Foundry 测试:直接用 Solidity 写测试
Foundry 测试:直接用 Solidity 写测试|界面示意 · book.getfoundry.sh · 采集于 2026-09-18
07
3. Foundry 与进阶测试手法

Cheatcodes:操纵区块链环境的测试超能力

登录记录进度

Foundry 通过 vm 提供一组只在测试环境生效的作弊码(cheatcodes),让你控制区块链环境,Hardhat 的 network-helpers 也有等价能力: - vm.prank(addr) / vm.startPrank(addr):下一次(或之后所有)调用的 msg.sender 伪装为指定地址,专门测试权限。

  • vm.warp(timestamp):把区块时间拨到任意值,测试锁仓、解锁、奖励周期。 - vm.roll(number):设置区块高度,测试依赖区块号的逻辑。 - vm.deal(addr, amount):直接给任意地址注入 ETH 余额。

  • deal(address(token), addr, amount, true):给地址注入 ERC-20 余额(分叉测试常用)。 - vm.expectEmit:声明预期事件;vm.expectRevert:预期回滚。

这些能力让「构造一个一年后、某地址有 100 万 USDT 的场景」只需两行代码。

Cheatcodes:操纵区块链环境的测试超能力
Cheatcodes:操纵区块链环境的测试超能力|界面示意 · book.getfoundry.sh · 采集于 2026-09-18
💡
小技巧:

测试时间相关逻辑时,永远用 warp 显式设置时间而不是依赖真实流逝的几秒——CI 机器的执行时间不确定,依赖真实时间的测试会偶发红。

08
3. Foundry 与进阶测试手法

模糊测试与不变量:让机器替你想到极端输入

登录记录进度

手写用例只能覆盖你想得到的场景。Foundry 原生支持参数化模糊测试:给测试函数加参数,框架自动跑成百上千组随机输入: solidity function testFuzz_DepositThenWithdraw(uint256 amount) public { amount = bound(amount, 1, 1e30); // 限定合理范围 token.mint(address(this), amount); pool.deposit(amount); pool.withdraw(amount); assertEq(token.balanceOf(address(this)), amount); } 一旦某组输入触发 revert 或断言失败,Foundry 会缩小输入(counterexample)并把最小复现值打印出来。

不变量测试更进一步:你只声明全局性质(如「合约里代币余额总和 == 所有用户存款总和」),框架用一组随机 handler 函数编排任意操作序列去尝试违反它,专门发现组合状态下的会计错误——DeFi 协议审计中最有价值的测试类型之一。

模糊测试与不变量:让机器替你想到极端输入
模糊测试与不变量:让机器替你想到极端输入|界面示意 · book.getfoundry.sh · 采集于 2026-09-18
💡
小技巧:

可以用 FOUNDRY_FUZZ_RUNS 环境变量提高每组用例的随机次数(如 10000),上线前跑一轮深度 fuzz 是高价值的睡前任务。

09
3. Foundry 与进阶测试手法

主网分叉测试:和真实 DeFi 协议同台验证

登录记录进度

当你的合约要调用 Uniswap、Aave、Chainlink 等已部署协议时,在假数据上测试没有意义。分叉(fork)把主网在某个区块的真实状态复制到本地:真实的流动性池、真实的价格、真实的代币余额都在。

Foundry 一条命令即可:forge test --fork-url $MAINNET_RPC_URL --fork-block-number 21000000;Hardhat 在 hardhat.config.js 的 networks 里配置 hardhat: { forking: { url: process.env.MAINNET_RPC_URL, blockNumber: 21000000 } }

固定 blockNumber 能保证测试可复现。 典型场景:验证你的合约通过真实 Uniswap V3 路由换币的滑点、用 deal 给测试地址注入真实 USDC 后走通完整存款流程、验证升级合约时读取旧合约真实存储。注意:分叉只是本地复制,任何操作都不会花真钱、不影响主网。

主网分叉测试:和真实 DeFi 协议同台验证
主网分叉测试:和真实 DeFi 协议同台验证|界面示意 · hardhat.org · 采集于 2026-09-18
Foundry 分叉测试文档
10
4. 覆盖率与持续集成

覆盖率、Gas 报告与持续集成

登录记录进度

三项工程化收尾: 1. 覆盖率:Hardhat 安装并运行 npx hardhat coverage(底层 solidity-coverage),生成逐行 / 逐分支的覆盖率报告,红色行就是测试没走到的逻辑;Foundry 用 forge coverage

核心资金合约目标是接近 100% 行覆盖与分支覆盖。 2. Gas 报告:启用 hardhat-gas-reporter 后跑测试,会打印每个函数的部署成本与调用 Gas 表,用代币价格换算成美元,帮你发现昂贵的 storage 写法。

  1. 持续集成(CI):在仓库 .github/workflows/test.yml 配置 GitHub Actions:每次 push / PR 自动安装依赖、启动编译、跑全部测试、上传覆盖率。

合并到主分支的代码必须测试全绿,这是正规团队的底线。 至此「写完即验证」的闭环建立完成,可以进入部署章节。

覆盖率、Gas 报告与持续集成
覆盖率、Gas 报告与持续集成|界面示意 · hardhat.org · 采集于 2026-09-18
💡
小技巧:

Gas 报告里重点关注部署成本(写满 storage 的构造函数)和高频调用函数;一次性优化收益最大的,通常是把可缓存的 storage 读改成 local 变量或 immutable。

来源与时效

教程记录平台 ethereum.org · 客户端 Web / App · 版本 v1。各平台界面会随版本更新变化,请以官方当前界面为准。

全部步骤已完成

操作完成后建议再核对一次到账金额与手续费,并保留交易哈希作为凭证。