Testing Smart Contract with Ethers and Mocha:[https://hardhat.org/docs/guides/testing/using-ethers]
Hardhat-Ethers-Chai[https://hardhat.org/docs/plugins/hardhat-ethers-chai-matchers]
Ether.js V6 文档:[https://docs.ethers.org/v6/]
Hardhat Ethers:[https://hardhat.org/docs/plugins/hardhat-ethers]
开始
写测试代码其实就是在做科学实验。每一个测试用例(it)都遵循AAA模式:
- Arrange (准备):准备环境、部署合约、准备测试账户。
- Act (执行):调用合约的某个函数(比如
mint 或 transfer)。
- Assert (断言):验证结果是否符合预期(比如余额变了吗?报错了吗?)。
Mocha
Mocha是一个测试框架。提供了一个结构来组织和运行你的测试代码。它定义了测试的骨架。
1 2 3 4 5 6 7 8 9 10 11
| describe("测试套件名称", function () {
it("测试用例描述", async function () { }); it("另一个测试用例描述", async function () { }); });
|
describe(...): 定义一个测试套件。
it(...): 定义一个测试用例,它必须是一个独立的、可运行的测试,并且应该清晰地描述其目的(如 "Should deploy with correct name, symbol and cap")。
async function(): 几乎所有的智能合约操作都是异步的,所以回调函数必须是 async的,才能使用await。
Chai
Chai是一个断言库。提供了清晰易读的函数和语法,用于表达你对代码结果的预期。它负责判断测试的成败。
1
| expect(实际值).to.equal(期望值);
|
Mocha 和 Chai 是两个独立的、通用的 JavaScript 测试工具,但它们经常被一起使用。它们并不是专门为智能合约设计的,而是用于测试任何 JavaScript 或 TypeScript 代码。
Hardhat 将 Mocha 和 Chai 集成进来,并使用插件扩展了 Chai 的功能。
JS与TS

Ethers.js 是一个用 JavaScript 编写的库,用于和以太坊区块链进行交互。
TS 是 JS 的超集,这意味着所有合法的 JS 代码在 TS 中都是合法的。TS 可以运行任何 JS 库。
所以,你在用 TS 的类型安全来编写代码,但底层运行的依然是 JS 库(Ethers.js)。
代码实现
以下面这个合约为例,写它的测试合约。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| // SPDX-License-Identifier: MIT pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; import "@openzeppelin/contracts/access/Ownable.sol";
contract MarchToken is ERC20Capped, ERC20Pausable, Ownable {
constructor() ERC20("March", "MARCH") ERC20Capped(10000 * 10 ** 18) Ownable(msg.sender) { }
function mint(address to, uint256 amount)public onlyOwner{ _mint(to, amount); }
function pause() public onlyOwner{ _pause(); }
function unpause() public onlyOwner{ _unpause(); }
function _update(address from, address to, uint256 value)internal override(ERC20Capped, ERC20Pausable){ super._update(from, to, value); }
function withdrawToken(address _tokenContract, uint256 _amount)external onlyOwner{ IERC20 tokenContract = IERC20(_tokenContract); tokenContract.transfer(msg.sender, _amount); } }
|
引入与定义
1 2 3 4 5 6
| import { expect } from "chai"; import { network } from "hardhat";
const { ethers } = await network.connect();
|
核心测试用例
- 部署于基本信息验证:
1 2 3 4 5 6 7 8 9
| it("Should deploy with correct name, symbol and cap", async function () { const marchToken = await ethers.deployContract("MarchToken"); expect(await marchToken.name()).to.equal("March"); expect(await marchToken.symbol()).to.equal("MARCH"); expect(await marchToken.cap()).to.equal(ethers.parseEther("10000")); });
|
await是谁在调用?
其实默认是部署者在调用。那部署者是谁?测试账户的第一个。测试账户通过getSingers()获取。
- 验证初始供应量:
1 2 3 4 5
| it("Should have zero initial supply", async function () { const marchToken = await ethers.deployContract("MarchToken"); expect(await marchToken.totalSupply()).to.equal(0n); });
|
- 铸造功能mint验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| it("Should mint tokens by owner", async function () { const marchToken = await ethers.deployContract("MarchToken"); const [owner, user1] = await ethers.getSigners();
const mintAmount = ethers.parseEther("1000"); await marchToken.mint(user1.address, mintAmount);
expect(await marchToken.balanceOf(user1.address)).to.equal(mintAmount); expect(await marchToken.totalSupply()).to.equal(mintAmount); });
it("Should emit Transfer event when minting", async function () { const marchToken = await ethers.deployContract("MarchToken"); const [owner, user1] = await ethers.getSigners(); const mintAmount = ethers.parseEther("500"); await expect(marchToken.mint(user1.address, mintAmount)) .to.emit(marchToken, "Transfer") .withArgs(ethers.ZeroAddress, user1.address, mintAmount); });
|
await expect(tx).to.emit(...):事件断言。验证交易(这里是 marchToken.mint(...))是否触发了特定的事件。
.withArgs(ethers.ZeroAddress, user1.address, mintAmount):验证该事件的参数是否为:from 地址为零地址(因为铸造没有来源),to 地址为 user1,数量为 mintAmount。
- 权限于限制验证:
1 2 3 4 5 6 7 8 9
| it("Should not allow minting beyond cap", async function () {
const exceedAmount = ethers.parseEther("10001"); await expect( marchToken.mint(user1.address, exceedAmount) ).to.be.revertedWithCustomError(marchToken, "ERC20ExceededCap"); });
|
await expect( ).to.be.revertedWithCustomError(contract, "ErrorName");:失败断言。这是验证交易失败的标准方法。它检查交易是否失败,并且失败的原因是合约中定义的 自定义错误 "ERC20ExceededCap"。(这个错误在继承的合约里面)
那如果我只想验证它是否被回滚了怎么写?
await expect(marchToken.connect(user1).pause()).to.be.rejected; // 只检查是否被拒绝
1 2 3 4 5 6 7 8
| it("Should only allow owner to mint", async function () {
await expect( marchToken.connect(user1).mint(user1.address, mintAmount) ).to.be.revertedWithCustomError(marchToken, "OwnableUnauthorizedAccount"); });
|
- 暂停功能Pausable验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| it("Should pause and unpause transfers", async function () {
await marchToken.pause(); expect(await marchToken.paused()).to.equal(true);
await expect( marchToken.connect(user1).transfer(...) ).to.be.revertedWithCustomError(marchToken, "EnforcedPause");
await marchToken.unpause(); expect(await marchToken.paused()).to.equal(false); await marchToken.connect(user1).transfer(...); });
|