5月27日 19:59
如何在 Hardhat 中编写智能合约测试?
在以太坊智能合约开发中,测试是保障合约安全性和功能正确性的关键环节。Hardhat 内置了基于 Mocha 和 Chai 的测试框架,配合 ethers.js 提供了强大的合约交互能力。本文将从基础到进阶,系统讲解如何在 Hardhat 中编写高质量的智能合约测试。
一、测试环境搭建
确保项目已安装 Hardhat 及相关依赖:
bashnpm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
hardhat-toolbox 集成了 ethers.js、chai、hardhat-network-helpers 等常用测试工具,推荐直接使用。
测试文件统一放在 test/ 目录下,以 .js 或 .ts 为后缀。
二、测试文件基本结构
一个典型的 Hardhat 测试文件结构如下:
javascriptconst { expect } = require("chai"); const { ethers } = require("hardhat"); describe("TokenContract", function () { let TokenContract; let token; let owner; let addr1; let addr2; // 每个测试用例执行前部署全新合约 beforeEach(async function () { [owner, addr1, addr2] = await ethers.getSigners(); TokenContract = await ethers.getContractFactory("TokenContract"); token = await TokenContract.deploy(); await token.waitForDeployment(); }); it("部署后应正确设置 owner", async function () { expect(await token.owner()).to.equal(owner.address); }); it("应能正确转账", async function () { await token.transfer(addr1.address, 100); expect(await token.balanceOf(addr1.address)).to.equal(100); }); });
关键要点:
describe对测试用例分组,it定义单个测试beforeEach确保每个测试在干净状态运行getSigners()获取测试账户,owner是默认的第一个账户- Hardhat 2.x 使用
deployed(),3.x/最新版推荐waitForDeployment()
三、核心测试技巧
1. 合约部署与初始化
javascriptdescribe("部署", function () { it("部署时传入初始参数", async function () { const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy("MyToken", "MTK", 1000000); await token.waitForDeployment(); expect(await token.name()).to.equal("MyToken"); expect(await token.symbol()).to.equal("MTK"); expect(await token.totalSupply()).to.equal(1000000); }); });
2. 状态读取与断言
javascriptit("应正确记录余额", async function () { // 写入操作 const tx = await token.connect(addr1).mint(500); await tx.wait(); // 等待交易确认 // 读取状态并断言 expect(await token.balanceOf(addr1.address)).to.equal(500); });
使用 .connect(signer) 切换调用者身份,模拟不同用户操作。
3. 事件测试
验证合约是否正确触发事件及其参数:
javascriptit("转账时应触发 Transfer 事件", async function () { await expect(token.transfer(addr1.address, 100)) .to.emit(token, "Transfer") .withArgs(owner.address, addr1.address, 100); });
4. 异常与回滚测试
测试 revert 是安全审计的重点。常见的三种断言方式:
javascript// 匹配特定错误消息 await expect(token.connect(addr1).mint(100)) .to.be.revertedWith("Only owner can mint"); // 匹配自定义错误(Solidity 0.8.16+) await expect(token.connect(addr1).mint(100)) .to.be.revertedWithCustomError(token, "Unauthorized"); // 仅验证是否 revert(不关心具体消息) await expect(token.transfer(addr1.address, 0)) .to.be.reverted;
5. 使用快照重置状态
当测试用例需要共享状态但又要在某些场景重置时,使用快照:
javascriptdescribe("快照测试", function () { let snapshotId; beforeEach(async function () { snapshotId = await ethers.provider.send("evm_snapshot", []); }); afterEach(async function () { await ethers.provider.send("evm_revert", [snapshotId]); }); it("状态修改后可回滚", async function () { await token.transfer(addr1.address, 100); expect(await token.balanceOf(addr1.address)).to.equal(100); // afterEach 会自动回滚状态 }); });
6. 时间操作
测试锁仓、投票等时间相关逻辑:
javascriptconst { time } = require("@nomicfoundation/hardhat-network-helpers"); it("锁仓到期后应能提取", async function () { const lock = await Lock.deploy(oneYearInSec, { value: ethers.parseEther("1") }); // 快进时间 await time.increase(oneYearInSec); // 验证到期后可提取 await expect(lock.withdraw()).not.to.be.reverted; });
推荐使用 hardhat-network-helpers 提供的 time.increase()、time.increaseTo()、helpers.mine() 等方法,比直接发送 evm_increaseTime 更直观。
四、进阶测试场景
Gas 消耗测试
监控关键函数的 Gas 消耗,防止异常飙升:
javascriptit("转账 Gas 消耗应在合理范围", async function () { const tx = await token.transfer(addr1.address, 100); const receipt = await tx.wait(); console.log(`Gas used: ${receipt.gasUsed.toString()}`); expect(receipt.gasUsed).to.be.below(80000); // 设定合理上限 });
权限与访问控制测试
javascriptdescribe("访问控制", function () { it("非 admin 不能调用受限函数", async function () { await expect(token.connect(addr1).setPause(true)) .to.be.revertedWithCustomError(token, "AccessControlUnauthorizedAccount"); }); it("admin 可授权后调用", async function () { await token.grantRole(ADMIN_ROLE, addr1.address); await expect(token.connect(addr1).setPause(true)).not.to.be.reverted; }); });
可升级合约测试
javascriptconst { upgrades } = require("@openzeppelin/hardhat-upgrades"); it("应能升级合约并保留状态", async function () { const V1 = await ethers.getContractFactory("TokenV1"); const proxy = await upgrades.deployProxy(V1, ["MTK"]); await proxy.waitForDeployment(); // 写入一些状态 await proxy.mint(addr1.address, 100); // 升级到 V2 const V2 = await ethers.getContractFactory("TokenV2"); const upgraded = await upgrades.upgradeProxy(await proxy.getAddress(), V2); // 验证状态保留 expect(await upgraded.balanceOf(addr1.address)).to.equal(100); // 验证新功能可用 expect(await upgraded.version()).to.equal(2); });
五、常见陷阱与排查
| 陷阱 | 说明 | 解决方式 |
|---|---|---|
忘记 await tx.wait() | 只发送交易未等待确认就断言 | 写操作后必须 await tx.wait() |
使用 this 在箭头函数中 | Mocha 箭头函数中 this 不指向 Mocha 上下文 | 使用 describe(function() {}) 而非箭头函数 |
| Gas 估算失败 | 交易本身会 revert 导致无法估算 Gas | 直接用 expect(...).to.be.reverted 捕获 |
| BigInt 比较 | ethers v6 返回 BigInt | 使用 expect(value).to.equal(n) 而非 === |
| 测试间状态污染 | beforeEach 未正确重置 | 确保每个测试前重新部署或使用快照 |
六、测试最佳实践
- 测试覆盖要全面 — 每个公开函数至少一个正向用例和一个异常用例
- 边界值测试 — 测试 0、最大值、溢出边界等极端情况
- 保持测试独立性 — 不依赖其他测试的执行顺序
- 使用有意义的描述 —
it("应在余额不足时 revert")而非it("test1") - 模拟真实场景 — 不要只测理想路径,考虑前端调用方式、多签名流程等
- 集成 CI — 在 GitHub Actions 中自动运行测试,配合
hardhat coverage输出覆盖率报告 - 使用 hardhat-gas-reporter — 持续监控 Gas 变化趋势,及时发现性能退化
运行全部测试:
bashnpx hardhat test
运行特定文件并输出 Gas 报告:
bashREPORT_GAS=true npx hardhat test test/Token.test.js
查看测试覆盖率:
bashnpx hardhat coverage