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 (执行):调用合约的某个函数(比如 minttransfer)。
  • Assert (断言):验证结果是否符合预期(比如余额变了吗?报错了吗?)。

Mocha

Mocha是一个测试框架。提供了一个结构来组织和运行你的测试代码。它定义了测试的骨架。

1
2
3
4
5
6
7
8
9
10
11
describe("测试套件名称", function () {
// 可以在这里放 beforeEach, afterEach

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

JSandTS
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";

// 关键步骤:使用 await network.connect() 来获取 Hardhat 运行时环境的 ethers 对象
// 这是 Hardhat 3/Ethers v6 中推荐的 ESM 导入/连接方式。
const { ethers } = await network.connect();

核心测试用例

  1. 部署于基本信息验证:
1
2
3
4
5
6
7
8
9
it("Should deploy with correct name, symbol and cap", async function () {
//获取合约工厂并部署名为"MarchToken"的合约实例。
    const marchToken = await ethers.deployContract("MarchToken");
//断言。
    expect(await marchToken.name()).to.equal("March");
    expect(await marchToken.symbol()).to.equal("MARCH");
    //将10000转换为10000个以太坊单位Wei(即BigInt类型)
    expect(await marchToken.cap()).to.equal(ethers.parseEther("10000"));
  });

await是谁在调用?
其实默认是部署者在调用。那部署者是谁?测试账户的第一个。测试账户通过getSingers()获取。

  1. 验证初始供应量:
1
2
3
4
5
it("Should have zero initial supply", async function () {
const marchToken = await ethers.deployContract("MarchToken");
//0n是JavaScript中的BigInt零
expect(await marchToken.totalSupply()).to.equal(0n);
});
  1. 铸造功能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");
//获取测试账户:owner默认为部署者,user1是另一个测试账户。
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");
    //期望marchToken合约发出了一个名为"Transfer"的事件。
    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. 权限于限制验证:
1
2
3
4
5
6
7
8
9
it("Should not allow minting beyond cap", async function () {
// ... 部署和 Signers 获取

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 () {
// ... 部署和 Signers 获取

await expect(
//切换调用者uesr1使用.connect(address)
marchToken.connect(user1).mint(user1.address, mintAmount) //user1尝试铸造
).to.be.revertedWithCustomError(marchToken, "OwnableUnauthorizedAccount");
});
  1. 暂停功能Pausable验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
it("Should pause and unpause transfers", async function () {
// ... 部署、铸造、Signers

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(...); // 验证交易成功
});